From ed531d9d36561da4cbab19a78d3f6f7ceeaccac1 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:37:13 +0000
Subject: [PATCH 001/116] chore(coderd/notifications): sync formatting and
rendered-template goldens with generators
make fmt/go (gci) regroups the imports in smtp_internal_test.go, and
make gen/golden-files re-renders the AI budget notification goldens with
HTML-escaped apostrophes. Both were committed out of sync with the
repo's pinned tools, so every local pre-commit run failed its unstaged
check. No behavior change.
---
coderd/notifications/dispatch/smtp_internal_test.go | 3 +--
.../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ----
.../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++--
.../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++--
.../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++--
5 files changed, 7 insertions(+), 12 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 5aee2385c29..83eb7e88b73 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -7,10 +7,9 @@ import (
"github.com/stretchr/testify/require"
- markdown "github.com/coder/coder/v2/coderd/render"
-
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
+ markdown "github.com/coder/coder/v2/coderd/render"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
index 550fd9b56fd..f64981add03 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
@@ -17,8 +17,6 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
Effective group: Engineering
-This limit is a per-user override.
-
AI budget period: July 1, 2026 - August 1, 2026
@@ -56,8 +54,6 @@ limit ($1000.00). Subsequent requests will be blocked.
Effective group: Engineering
-This limit is a per-user override.
-
AI budget period: July 1, 2026 - August 1, 2026
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
index 37db6f733cc..94d00686923 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+ Codestin Search App
- You've reached your monthly AI budget limit
+ You've reached your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
index 3927ab28e31..4d5ffdf4744 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+
Codestin Search App
- You're approaching your monthly AI budget limit
+ You're approaching your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
index 4315def7665..1523ba12285 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
@@ -24,6 +24,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
- "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
- "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
+ "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
+ "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
}
\ No newline at end of file
From 233795fd706d8bd2d622ae82b803cde0e6c93bfa Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:38:12 +0000
Subject: [PATCH 002/116] fix: enforce uniqueness and hour alignment for agent
runtime usage events
The usage generator writes hb_agent_runtime_v1 rows with created_at at
the UTC hourly bucket start and exactly one row per bucket, but nothing
in the schema enforced either invariant. A duplicate bucket row under a
different id would be double-counted by any consumer summing runtime_ms,
and a misaligned created_at would skew which usage period a bucket is
attributed to.
Replace the non-unique partial index idx_usage_events_agent_runtime with
a unique index of the same shape and add an hour-alignment CHECK
constraint. generateBucket treats a unique violation on the bucket index
as another replica having won the race, mirroring the existing
ON CONFLICT (id) no-op for committed rows.
---
coderd/database/check_constraint.go | 1 +
coderd/database/dump.sql | 5 ++-
...e_events_agent_runtime_invariants.down.sql | 9 ++++
...age_events_agent_runtime_invariants.up.sql | 26 +++++++++++
coderd/database/querier.go | 8 +++-
coderd/database/querier_test.go | 41 +++++++++++++++---
coderd/database/queries.sql.go | 8 +++-
coderd/database/queries/usageevents.sql | 8 +++-
coderd/database/unique_constraint.go | 1 +
enterprise/coderd/usage/generator.go | 38 ++++++++--------
.../coderd/usage/generator_internal_test.go | 43 +++++++++++++++++++
11 files changed, 155 insertions(+), 33 deletions(-)
create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
create mode 100644 enterprise/coderd/usage/generator_internal_test.go
diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go
index dbf0debfcab..e90397c9ec9 100644
--- a/coderd/database/check_constraint.go
+++ b/coderd/database/check_constraint.go
@@ -54,6 +54,7 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
+ CheckUsageEventsAgentRuntimeHourAligned CheckConstraint = "usage_events_agent_runtime_hour_aligned" // usage_events
CheckUserAIBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
CheckUserAIProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
CheckUserSecretsEnabledRequiresTarget CheckConstraint = "user_secrets_enabled_requires_target" // user_secrets
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 808111c3764..b209504c0d8 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -3543,7 +3543,8 @@ CREATE TABLE usage_events (
publish_started_at timestamp with time zone,
published_at timestamp with time zone,
failure_message text,
- CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text])))
+ CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text]))),
+ CONSTRAINT usage_events_agent_runtime_hour_aligned CHECK (((event_type <> 'hb_agent_runtime_v1'::text) OR (date_trunc('hour'::text, timezone('UTC'::text, created_at)) = timezone('UTC'::text, created_at))))
);
COMMENT ON TABLE usage_events IS 'usage_events contains usage data that is collected from the product and potentially shipped to the usage collector service.';
@@ -4886,7 +4887,7 @@ CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree
CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
-CREATE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
+CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
CREATE INDEX idx_usage_events_ai_seats ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_ai_seats_v1'::text);
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
new file mode 100644
index 00000000000..ed8b4ae78b8
--- /dev/null
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
@@ -0,0 +1,9 @@
+-- IF EXISTS matches 000561's down migration, so rolling back out of order
+-- (e.g. during an incident) is a no-op instead of a failure.
+DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
+CREATE INDEX idx_usage_events_agent_runtime
+ ON usage_events (event_type, created_at)
+ WHERE event_type = 'hb_agent_runtime_v1';
+
+ALTER TABLE usage_events
+ DROP CONSTRAINT IF EXISTS usage_events_agent_runtime_hour_aligned;
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
new file mode 100644
index 00000000000..33c0c28f1a7
--- /dev/null
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
@@ -0,0 +1,26 @@
+-- The usage generator writes hb_agent_runtime_v1 rows with created_at at
+-- the UTC hourly bucket start and exactly one row per bucket. Uniqueness
+-- keeps any consumer that sums runtime_ms from counting a bucket twice;
+-- the alignment CHECK protects the attribution model, which charges a
+-- bucket to the usage period containing its start.
+--
+-- Both statements validate existing rows. Every supported writer has always
+-- produced conforming data, so a pre-existing violator is anomalous and
+-- failing the migration loudly beats silently rewriting usage rows.
+ALTER TABLE usage_events
+ ADD CONSTRAINT usage_events_agent_runtime_hour_aligned
+ CHECK (
+ event_type <> 'hb_agent_runtime_v1'
+ OR date_trunc('hour', (created_at AT TIME ZONE 'UTC')) = (created_at AT TIME ZONE 'UTC')
+ );
+
+-- Replace the non-unique partial index with a unique one of the same shape,
+-- so reads are served identically. Inserts keep their (id) arbiter:
+-- re-inserting a bucket under its deterministic id stays a silent no-op,
+-- while a duplicate bucket row under a different id raises instead of being
+-- counted twice (generateBucket in enterprise/coderd/usage/generator.go
+-- handles the violation).
+DROP INDEX idx_usage_events_agent_runtime;
+CREATE UNIQUE INDEX idx_usage_events_agent_runtime
+ ON usage_events (event_type, created_at)
+ WHERE event_type = 'hb_agent_runtime_v1';
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 882e4cb9d58..05147dad109 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -1168,8 +1168,12 @@ type sqlcQuerier interface {
InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error
InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error)
InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error)
- // Duplicate events are ignored intentionally to allow for multiple replicas to
- // publish heartbeat events.
+ // Duplicate events are ignored intentionally to allow for multiple replicas
+ // to publish heartbeat events. The (id) arbiter scopes that tolerance to
+ // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+ // bucket under a different id raises on idx_usage_events_agent_runtime
+ // instead, which generateBucket in enterprise/coderd/usage/generator.go
+ // handles.
InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
// InsertUserGroupsByID adds a user to all provided groups, if they exist.
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index e84b81b79cc..abb2d0c39e1 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -10847,11 +10847,10 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb_agent_runtime_v1:2025-01-02_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 250}`, day2)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
- // Re-inserting a bucket must not double-count it. The daily rollup
- // sums runtime_ms, so idempotency rests on the aggregate trigger
- // being AFTER INSERT: Postgres does not fire it for rows suppressed
- // by ON CONFLICT (id) DO NOTHING. Concurrent replicas and backfill
- // re-runs both take this path.
+ // Re-inserting a bucket under its deterministic id must not
+ // double-count it: the daily rollup's AFTER INSERT trigger does not
+ // fire for rows suppressed by the insert's ON CONFLICT (id)
+ // arbiter.
insert("hb_agent_runtime_v1:2025-01-01_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 1000}`, day1)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
@@ -10859,6 +10858,38 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb-seats-1", "hb_ai_seats_v1", `{"count": 3}`, day2)
rows := getDailyRows(ctx, sqlDB)
require.Len(t, rows, 3)
+
+ // The same bucket under a different id is not an idempotent
+ // re-insert but a duplicate that would double any aggregate summing
+ // runtime_ms; the unique partial index
+ // idx_usage_events_agent_runtime rejects it loudly instead of the
+ // (id) arbiter silently dropping it.
+ err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "different-id-same-bucket",
+ EventType: "hb_agent_runtime_v1",
+ EventData: []byte(`{"runtime_ms": 9999}`),
+ CreatedAt: day1,
+ })
+ require.True(t, database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime),
+ "expected unique violation on idx_usage_events_agent_runtime, got %v", err)
+ // The rejected row must not have reached the daily rollup either.
+ rows = getDailyRows(ctx, sqlDB)
+ require.Len(t, rows, 3)
+ require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
+
+ // created_at must be the exact UTC hourly bucket start;
+ // usage_events_agent_runtime_hour_aligned rejects a misaligned row
+ // so it cannot skew the period a bucket is attributed to.
+ err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "hb_agent_runtime_v1:misaligned",
+ EventType: "hb_agent_runtime_v1",
+ EventData: []byte(`{"runtime_ms": 100}`),
+ CreatedAt: day1.Add(30 * time.Minute),
+ })
+ require.ErrorContains(t, err, string(database.CheckUsageEventsAgentRuntimeHourAligned))
+ rows = getDailyRows(ctx, sqlDB)
+ require.Len(t, rows, 3)
+ require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
})
t.Run("UnknownEventType", func(t *testing.T) {
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index e499234f558..20b5f33bea5 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28462,8 +28462,12 @@ type InsertUsageEventParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
-// Duplicate events are ignored intentionally to allow for multiple replicas to
-// publish heartbeat events.
+// Duplicate events are ignored intentionally to allow for multiple replicas
+// to publish heartbeat events. The (id) arbiter scopes that tolerance to
+// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+// bucket under a different id raises on idx_usage_events_agent_runtime
+// instead, which generateBucket in enterprise/coderd/usage/generator.go
+// handles.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 8ba706b0c8a..03f435c5af7 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -1,6 +1,10 @@
-- name: InsertUsageEvent :exec
--- Duplicate events are ignored intentionally to allow for multiple replicas to
--- publish heartbeat events.
+-- Duplicate events are ignored intentionally to allow for multiple replicas
+-- to publish heartbeat events. The (id) arbiter scopes that tolerance to
+-- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+-- bucket under a different id raises on idx_usage_events_agent_runtime
+-- instead, which generateBucket in enterprise/coderd/usage/generator.go
+-- handles.
INSERT INTO
usage_events (
id,
diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go
index 4b1a4376f2d..23256ed3b86 100644
--- a/coderd/database/unique_constraint.go
+++ b/coderd/database/unique_constraint.go
@@ -160,6 +160,7 @@ const (
UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text)));
UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true);
UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
+ UniqueIndexUsageEventsAgentRuntime UniqueConstraint = "idx_usage_events_agent_runtime" // CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE ((deleted = false) AND (email <> ''::text));
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index 91d911b57a7..a5b6788a214 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -48,8 +48,11 @@ const (
// Cron jobs, which sample live state when they fire, the Generator derives
// events from data already persisted in the database, so it can
// deterministically backfill hours missed while the deployment was down,
-// zero-filling idle hours. Deterministic event IDs plus the database's
-// ON CONFLICT (id) DO NOTHING make concurrent replicas safe without locking.
+// zero-filling idle hours. Deterministic event IDs make concurrent replicas
+// safe without locking: a re-insert of a committed bucket is a no-op via the
+// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted
+// bucket surface a unique violation that generateBucket recognizes as the
+// other replica winning.
//
// Events are generated unconditionally in enterprise builds; the
// publish_usage_data license flag only gates publishing to Tallyman.
@@ -155,24 +158,12 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error {
return xerrors.Errorf("list existing agent runtime events: %w", err)
}
// A row marks its bucket complete regardless of publish outcome, so a
- // bucket whose event Tallyman permanently rejected is never
- // regenerated (re-inserting under the deterministic ID is a no-op via
- // ON CONFLICT (id) DO NOTHING).
- //
- // The runtime is not lost locally: the row still holds it, and the
- // event can be re-queued for publishing with
- //
- // UPDATE usage_events
- // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL
- // WHERE id = 'hb_agent_runtime_v1:';
- //
- // That re-arm only has an effect while the bucket is inside the
- // publisher's 30-day cutoff: SelectUsageEventsForPublishing also
- // filters created_at > now - INTERVAL '30 days', and created_at is the
- // bucket start, so past that the UPDATE reports success but the row is
- // never picked up again. The release gate (Tallyman must accept this
- // event type before coderd ships it) is what keeps permanent
- // rejections exceptional.
+ // bucket whose event Tallyman permanently rejected is never regenerated
+ // (re-inserting under the deterministic ID is a no-op via the insert's
+ // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row
+ // keeps it, and clearing the row's publish columns re-queues it while
+ // the bucket is within SelectUsageEventsForPublishing's 30-day
+ // created_at cutoff.
existing := make(map[time.Time]struct{}, len(existingTimes))
for _, ts := range existingTimes {
// created_at is always the exact bucket start for this event type;
@@ -235,6 +226,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
// time) so daily rollups attribute backfilled hours to the correct day.
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
+ if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
+ // The insert's ON CONFLICT (id) arbiter only sees committed rows, so
+ // a concurrent replica inserting the same bucket can trip the bucket
+ // unique index instead. Either way a row for this bucket already
+ // exists, which is all generateBucket needs.
+ return nil
+ }
if err != nil {
return xerrors.Errorf("insert usage event: %w", err)
}
diff --git a/enterprise/coderd/usage/generator_internal_test.go b/enterprise/coderd/usage/generator_internal_test.go
new file mode 100644
index 00000000000..fa6aeb63440
--- /dev/null
+++ b/enterprise/coderd/usage/generator_internal_test.go
@@ -0,0 +1,43 @@
+package usage
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ "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/testutil"
+ "github.com/coder/quartz"
+)
+
+// TestGenerateBucketUniqueViolation pins that a unique violation on the
+// bucket index resolves the bucket as complete: another writer already
+// recorded it. TestGeneratorConcurrentReplicas also reaches this path, but
+// only when its goroutines actually interleave; this case cannot pass by
+// scheduling accident.
+func TestGenerateBucketUniqueViolation(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitShort)
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+ gen := NewGenerator(quartz.NewMock(t), slogtest.Make(t, nil), mDB, NewDBInserter())
+
+ mDB.EXPECT().
+ GetTotalChatMessageRuntimeMsInRange(gomock.Any(), gomock.Any()).
+ Return(int64(1000), nil)
+ mDB.EXPECT().
+ InsertUsageEvent(gomock.Any(), gomock.Any()).
+ Return(&pq.Error{
+ Code: "23505", // unique_violation
+ Constraint: string(database.UniqueIndexUsageEventsAgentRuntime),
+ })
+
+ bucket := time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC)
+ require.NoError(t, gen.generateBucket(ctx, bucket))
+}
From 3359ec53db87f50708768e4ab4d44adf320a230b Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:58:22 +0000
Subject: [PATCH 003/116] feat: report stable license measurement diagnostics
and tolerate unusable runtime hours claims
Two coupled changes to the license/entitlements layer.
Measurement diagnostics: extract a measureUsage helper that owns the
usage-query failure policy. A query failure now publishes the stable
codersdk.LicenseManagedAgentUsageUnavailableErrorText on the
entitlements Errors channel (keeping the alertable coderd_license_errors
gauge counting) while the raw cause goes to the coderd log through the
new FeatureArguments.Logger. A nil usage closure becomes a hard
developer error, and a failure with a dead context still aborts the
whole refresh. The managed agent count closure switches from
AsSystemRestricted to the narrower AsUsagePublisher subject. The
dashboard recognizes diagnostic texts and renders them muted, without
the exceedance heading or a sales link.
Claim tolerance: unusable agent_runtime_hours_* claim combinations no
longer reject the whole license. decodeAgentRuntimeHours drops the
unusable claims, reports them in a stable
LicenseAgentRuntimeHoursClaimsIgnoredWarningText warning (deduplicated
across licenses), and logs the affected license and claims;
validateAgentRuntimeHours and its license-invalidating errors are
removed. Rejecting a signed license over a cosmetic threshold claim
would drop the deployment to unlicensed.
Part of the stack for coder/coder#27796.
---
codersdk/licenses.go | 13 +
enterprise/coderd/coderd_test.go | 6 +
.../coderd/coderdenttest/coderdenttest.go | 15 +
enterprise/coderd/license/license.go | 228 +++++----
enterprise/coderd/license/license_test.go | 476 +++++++++++++++---
enterprise/coderd/licenses_test.go | 35 +-
site/src/api/typesGenerated.ts | 23 +
.../dashboard/LicenseBanner/LicenseBanner.tsx | 54 +-
.../LicenseBannerView.stories.tsx | 113 ++++-
.../LicenseBanner/LicenseBannerView.tsx | 20 +-
10 files changed, 762 insertions(+), 221 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 24a8366b62b..9cb0dbc88b1 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,6 +17,19 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ // managed agent usage query fails while computing entitlements; the
+ // cause is logged server-side. It travels in the entitlements Errors
+ // channel so the alertable coderd_license_errors gauge counts
+ // measurement failures, but the dashboard recognizes the exact text and
+ // renders it as a muted diagnostic rather than a license error.
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
+ // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ // license carries unusable Coder Agent runtime hour claims (see
+ // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ // the license and the dropped claims. The dashboard renders the exact
+ // text as a muted diagnostic without a sales link.
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
type AddLicenseRequest struct {
diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go
index e023da7b1df..7be01405353 100644
--- a/enterprise/coderd/coderd_test.go
+++ b/enterprise/coderd/coderd_test.go
@@ -92,6 +92,12 @@ func TestEntitlements(t *testing.T) {
// Enable all features
features := make(license.Features)
for _, feature := range codersdk.FeatureNames {
+ if feature == codersdk.FeatureAgentRuntimeHours {
+ // The feature name is not a valid license claim; the
+ // feature is encoded as its allocation claim.
+ features[license.ClaimAgentRuntimeHoursAllocation] = 1
+ continue
+ }
features[feature] = 1
}
features[codersdk.FeatureUserLimit] = 100
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 1115ba12118..8f5f1e4a708 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,6 +237,21 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
+// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
+// or hardLimit omits that claim, which a license is allowed to do; a non-nil
+// value is emitted verbatim, so tests can express explicit zero or negative
+// claims.
+func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
+ opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
+ if softLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitSoft, *softLimit)
+ }
+ if hardLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitHard, *hardLimit)
+ }
+ return opts
+}
+
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
if opts.Features == nil {
opts.Features = license.Features{}
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8cce657de65..e6f3598475e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -93,6 +93,7 @@ func Entitlements(
}
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
+ Logger: logger,
ActiveUserCount: activeUserCount,
ActiveAISeatCount: activeAISeatCount,
ReplicaCount: replicaCount,
@@ -114,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Reading usage events requires the usage publisher subject.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -129,6 +130,9 @@ func Entitlements(
}
type FeatureArguments struct {
+ // Logger receives the causes behind operator-facing diagnostics whose
+ // published message is a stable text. The zero value discards them.
+ Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
ReplicaCount int
@@ -508,11 +512,8 @@ func LicensesEntitlements(
continue
}
- // Agent runtime hours are encoded as up to three claims and are
- // decoded together after this loop, see
- // decodeAgentRuntimeHours. The feature name itself is never a
- // valid claim. The allocation must come from the dedicated claim
- // so it is validated against the soft and hard limits.
+ // Agent runtime hour claims are decoded together after this
+ // loop; see decodeAgentRuntimeHours.
if featureName == codersdk.FeatureAgentRuntimeHours ||
isAgentRuntimeHoursClaim(featureName) {
continue
@@ -577,14 +578,25 @@ func LicensesEntitlements(
}
}
- // The loop above skips Agent runtime hours because the
- // three claims that encode them decode into a single feature.
- if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
+ runtimeFeature, granted, ignoredClaims := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
- }); ok {
- entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature)
+ })
+ if granted {
+ entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
+ }
+ if len(ignoredClaims) > 0 {
+ // The published warning is a stable text, so the details a
+ // support case needs go to the log.
+ featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
+ slog.F("license_id", license.UUID),
+ slog.F("ignored_claims", ignoredClaims),
+ )
+ if !slices.Contains(entitlements.Warnings, codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) {
+ entitlements.Warnings = append(entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ }
}
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
@@ -701,24 +713,18 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- var (
- managedAgentCount int64
- err = xerrors.New("dev error: managed agent count function is not set")
- )
- if featureArguments.ManagedAgentCountFn != nil {
- managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
- }
- if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
- // If the context is canceled, we want to bail the entire
- // LicensesEntitlements call.
- return entitlements, xerrors.Errorf("get managed agent count: %w", err)
- }
+ managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
+ featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
+ "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
if err != nil {
- entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
- // no return
- } else {
+ return entitlements, err
+ }
+ if ok {
agentLimit.Actual = &managedAgentCount
- entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
+ // Written back directly: the feature contest is already
+ // settled, so AddFeature's Compare must not get a chance to
+ // drop the write.
+ entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -857,6 +863,42 @@ func LicensesEntitlements(
return entitlements, nil
}
+// measureUsage runs one usage query over the feature's usage period and owns
+// the shared failure policy: a nil fn is a wiring bug and fails the whole
+// LicensesEntitlements call; a failure with a dead context fails the call
+// without logging; any other failure logs the cause and publishes the stable
+// unavailableText instead. It returns the measured value and true only on
+// success.
+func measureUsage(
+ ctx context.Context,
+ entitlements *codersdk.Entitlements,
+ logger slog.Logger,
+ fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
+ usagePeriod codersdk.UsagePeriod,
+ what string,
+ unavailableText string,
+) (int64, bool, error) {
+ if fn == nil {
+ return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
+ }
+ value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
+ switch {
+ case err != nil && ctx.Err() != nil:
+ // The computation's own context is dead, so abort the whole call
+ // without logging. Do not classify by error shape instead: Postgres
+ // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
+ // as well as client cancels, and aborting on those would fail every
+ // entitlements refresh on a deployment whose statement_timeout is
+ // shorter than a usage query.
+ return 0, false, xerrors.Errorf("get %s: %w", what, err)
+ case err != nil:
+ logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
+ entitlements.Errors = append(entitlements.Errors, unavailableText)
+ return 0, false, nil
+ }
+ return value, true, nil
+}
+
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
@@ -885,23 +927,22 @@ const (
VersionClaim = "version"
)
-// Agent runtime hour license claims. These are the canonical claim names
-// minted by github.com/coder/license. All three claims map to the single
-// codersdk.FeatureAgentRuntimeHours feature and are validated together when
-// the license is parsed, see validateClaims.
-//
-// The unit for all three claims is hours.
+// Agent runtime hour license claims, minted by github.com/coder/license.
+// All three are in hours and decode together into the single
+// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit.
+ // allocation for the license term. It becomes the feature's Limit. A
+ // negative allocation is ignored, in which case the license does not
+ // grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // must satisfy 0 <= soft < allocation, so it may only be set when the
- // allocation is greater than 0. It becomes the feature's SoftLimit.
+ // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
- // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be
- // absent or >= allocation, and may only be set when the allocation is
- // greater than 0. It becomes the feature's HardLimit.
+ // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
+ // the feature's HardLimit when the allocation is greater than 0 and
+ // hard >= allocation, and is ignored otherwise.
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
@@ -917,19 +958,12 @@ var (
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
ErrMissingAccountType = xerrors.New("license must contain valid account type")
ErrMissingAccountID = xerrors.New("license must contain valid account ID")
-
- ErrMissingAgentRuntimeHoursAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation)
- ErrAgentRuntimeHoursLimitsWithZeroAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but the %s claim is 0", ClaimAgentRuntimeHoursAllocation)
)
type Features map[codersdk.FeatureName]int64
-// isAgentRuntimeHoursClaim reports whether the claim name is one of the three
-// claims that encode the codersdk.FeatureAgentRuntimeHours feature. These
-// claims are decoded together, see decodeAgentRuntimeHours.
+// isAgentRuntimeHoursClaim reports whether name is one of the three claims
+// decoded by decodeAgentRuntimeHours.
func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
switch name {
case ClaimAgentRuntimeHoursAllocation,
@@ -941,62 +975,65 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
}
}
-// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours feature
-// from the claims that encode it. It reports false when the license carries no
-// allocation claim, in which case the license does not grant the feature.
+// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours
+// feature from its claims. granted is false when there is no usable
+// allocation claim; per-claim validity rules live on the Claim* constants
+// above.
+//
+// Unusable claims are dropped, never license-invalidating: rejecting a
+// signed license over a cosmetic threshold claim would drop the deployment
+// to unlicensed. ignoredClaims names each dropped claim (including the
+// feature name itself minted as a claim, the most plausible issuer mistake)
+// so the caller can warn and log instead of letting an incorrectly issued
+// license look healthy.
//
-// The claim combination is validated when the license is parsed, see
-// Features.validateAgentRuntimeHours. The allocation is never negative here
-// and the soft and hard limits are only present alongside a positive
-// allocation.
-func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (codersdk.Feature, bool) {
- allocation, ok := features[ClaimAgentRuntimeHoursAllocation]
- if !ok {
- return codersdk.Feature{}, false
+// A zero allocation grants the feature disabled and drops both threshold
+// claims, but Actual is still measured and published. CODAGT-856 will make a
+// zero allocation force a concurrency-limited mode; that mode does not exist
+// yet.
+func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
+ if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
+ ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
}
- feature := codersdk.Feature{
+ allocation, allocOk := features[ClaimAgentRuntimeHoursAllocation]
+ soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
+ hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+
+ if !allocOk || allocation < 0 {
+ if allocOk && allocation < 0 {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
+ }
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{}, false, ignoredClaims
+ }
+
+ feature = codersdk.Feature{
Enabled: allocation > 0,
Entitlement: entitlement,
Limit: &allocation,
UsagePeriod: &usagePeriod,
}
- if soft, ok := features[ClaimAgentRuntimeHoursLimitSoft]; ok {
- feature.SoftLimit = &soft
- }
- if hard, ok := features[ClaimAgentRuntimeHoursLimitHard]; ok {
- feature.HardLimit = &hard
- }
- return feature, true
-}
-
-// validateAgentRuntimeHours validates the relationship between the agent
-// runtime hour claims. Invalid combinations reject the entire license.
-func (f Features) validateAgentRuntimeHours() error {
- allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation]
- soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft]
- hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard]
- if !hasAllocation {
- if hasSoft || hasHard {
- return ErrMissingAgentRuntimeHoursAllocation
+ if softOk {
+ if soft > 0 && soft < allocation {
+ feature.SoftLimit = &soft
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
}
- return nil
- }
- if allocation < 0 {
- return ErrInvalidAgentRuntimeHoursAllocation
- }
- // A zero allocation disables the feature.
- // A zero hard limit is not permitted.
- if allocation == 0 && (hasSoft || hasHard) {
- return ErrAgentRuntimeHoursLimitsWithZeroAllocation
}
- if hasSoft && (soft < 0 || soft >= allocation) {
- return ErrInvalidAgentRuntimeHoursSoftLimit
- }
- if hasHard && hard < allocation {
- return ErrInvalidAgentRuntimeHoursHardLimit
+ if hardOk {
+ if allocation > 0 && hard >= allocation {
+ feature.HardLimit = &hard
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
}
- return nil
+ return feature, true, ignoredClaims
}
// Claims is the full set of claims in a license.
@@ -1089,9 +1126,6 @@ func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims.AccountID == "" {
return nil, ErrMissingAccountID
}
- if err := claims.Features.validateAgentRuntimeHours(); err != nil {
- return nil, err
- }
return claims, nil
}
return nil, xerrors.New("unable to parse Claims")
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 6fe50902850..db68fb8b60a 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -1,9 +1,11 @@
package license_test
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "maps"
"slices"
"testing"
"time"
@@ -13,12 +15,17 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
+ "golang.org/x/xerrors"
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "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/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -30,6 +37,42 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
+// premiumRuntimeHoursFixture returns a mock store primed with a Premium
+// license carrying runtime hour claims (allocation 100, soft limit 80, hard
+// limit 120) plus the store expectations every entitlements refresh consumes
+// before usage is measured. Callers add expectations for the usage queries
+// under test.
+func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
+ t.Helper()
+
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+
+ licenseOpts := (&coderdenttest.LicenseOptions{
+ FeatureSet: codersdk.FeatureSetPremium,
+ IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
+ NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
+ GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
+ ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
+ // The addon marks AI Bridge as explicitly entitled, suppressing
+ // the unrelated "AI Governance add-on is required to use AI
+ // Gateway" warning that Premium would otherwise produce.
+ }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
+
+ lic := database.License{
+ ID: 1,
+ JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
+ Exp: licenseOpts.ExpiresAt,
+ }
+
+ mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
+ mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
+ mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
+ mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
+
+ return mDB, licenseOpts
+}
+
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -920,6 +963,63 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
+ t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
+ t.Parallel()
+
+ // Drive the real Entitlements closure with a mock database so
+ // measureUsage's failure path is exercised end to end: the cause
+ // must land in the coderd log, which the stable payload text points
+ // at, and must not land on the unauthenticated entitlements payload.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), xerrors.New("kaboom managed"))
+
+ // The error-level logs are the behavior under test, so the default
+ // failing test logger cannot be used.
+ var logBuf bytes.Buffer
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
+ AppendSinks(sloghuman.Sink(&logBuf))
+
+ entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+
+ // The failure surfaces its stable text without the raw cause,
+ // on the channel the codersdk constant docs prescribe.
+ require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
+ for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
+ require.NotContains(t, entry, "kaboom")
+ }
+
+ logs := logBuf.String()
+ require.Contains(t, logs, "get managed agent count for entitlements")
+ require.Contains(t, logs, "kaboom managed")
+ })
+
+ t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
+ t.Parallel()
+
+ // A query failing while the refresh's own context is canceled,
+ // e.g. during shutdown, aborts the whole entitlements refresh and
+ // must not log a false query-failure alarm at error level.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), context.Canceled)
+
+ var logBuf bytes.Buffer
+ logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.ErrorContains(t, err, "get managed agent count")
+ require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
+ })
+
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1299,6 +1399,12 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
+ // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
+ // injection below so the nil dev-error path can be exercised.
+ KeepNilManagedAgentCountFn bool
+ // CancelContext cancels the context passed to LicensesEntitlements
+ // before the call, exercising the usage-measurement abort policy.
+ CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1550,6 +1656,59 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
+ {
+ // A query failure is surfaced as a stable text in Errors (see
+ // the codersdk constant docs for the channel choice) and
+ // leaves Actual unset without aborting the rest of the
+ // entitlements.
+ Name: "ManagedAgentLimit/QueryError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, xerrors.New("kaboom")
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoWarnings(t, entitlements)
+ require.Len(t, entitlements.Errors, 1)
+ assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
+ // The raw error is logged rather than exposed on the
+ // unauthenticated entitlements payload.
+ assert.NotContains(t, entitlements.Errors[0], "kaboom")
+ feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
+ assert.Nil(t, feature.Actual)
+ },
+ },
+ {
+ // Forgetting to wire ManagedAgentCountFn is a dev error:
+ // production always provides the closure, so it fails the whole
+ // call loudly instead of degrading into an operator-facing
+ // message.
+ Name: "ManagedAgentLimit/NilFnDevError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ KeepNilManagedAgentCountFn: true,
+ ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
+ },
+ {
+ // A failure while the computation's own context is canceled
+ // aborts the whole call rather than degrading to an
+ // entitlements error.
+ Name: "ManagedAgentLimit/ContextCanceled",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ CancelContext: true,
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, context.Canceled
+ },
+ },
+ ExpectedErrorContains: "get managed agent count",
+ },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1581,13 +1740,18 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil {
+ if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
-
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+ ctx := context.Background()
+ if tc.CancelContext {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithCancel(ctx)
+ cancel()
+ }
+ entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1612,6 +1776,15 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
+ // A Premium license grants a managed agent limit by default, and a nil
+ // usage closure is a hard developer error, so these subtests wire a
+ // zero-usage measurement closure.
+ zeroUsageArgs := license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
+ }
+
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1631,7 +1804,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1658,7 +1831,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1690,7 +1863,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1721,7 +1894,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1734,7 +1907,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -2535,15 +2708,22 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
})
}
-// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the
-// agent runtime hour claims reject the entire license.
-func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
+// TestAgentRuntimeHoursClaimTolerance pins decodeAgentRuntimeHours's
+// tolerate-and-warn contract; see that function's doc for the rationale.
+func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
t.Parallel()
testCases := []struct {
- name string
- features license.Features
- expectedErr error
+ name string
+ features license.Features
+
+ // expectFeature is nil when the feature must be absent.
+ expectFeature *codersdk.Feature
+ // expectClaimsIgnored is true when at least one present claim is
+ // dropped, which must surface the claims-ignored warning: tolerating
+ // a claim and signaling nothing would make an incorrectly issued license
+ // undetectable from the deployment.
+ expectClaimsIgnored bool
}{
{
name: "AllClaims",
@@ -2552,109 +2732,172 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](80),
+ HardLimit: ptr.Ref[int64](120),
+ },
},
{
name: "AllocationOnly",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
},
{
+ // A zero soft limit would warn at zero usage forever, so it is
+ // dropped rather than rejecting the license. The canonical way
+ // to express "no soft limit" is omitting the claim, so a
+ // present-but-dropped zero still warns.
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "HardEqualsAllocation",
+ name: "NegativeSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocation",
+ // A soft limit at or above the allocation could never fire
+ // before the allocation warning supersedes it.
+ name: "SoftEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithZeroHard",
+ name: "SoftAboveAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithPositiveHard",
+ name: "HardEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 100,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ HardLimit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
- name: "SoftWithoutAllocation",
+ name: "HardBelowAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 99,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardWithoutAllocation",
+ name: "ZeroAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitHard: 120,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
- name: "NegativeAllocation",
+ // A zero allocation has no hour budget, so threshold claims
+ // alongside it are dropped, with the warning.
+ name: "ZeroAllocationWithLimits",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "NegativeSoft",
+ name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -1,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftEqualsAllocation",
+ name: "SoftWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftAboveAllocation",
+ name: "HardWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftWithZeroAllocation",
+ // The feature name itself is never a valid claim: the
+ // allocation must come from the dedicated claim. It is the
+ // shape every other metered feature uses, so a license minting
+ // it is the most plausible issuer mistake and must warn
+ // rather than being dropped silently.
+ name: "FeatureNameAsClaim",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitSoft: 0,
+ codersdk.FeatureAgentRuntimeHours: 100,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardBelowAllocation",
+ // The feature name claim is dropped (with the warning) even
+ // when a usable allocation claim grants the feature.
+ name: "FeatureNameAlongsideAllocation",
features: license.Features{
+ codersdk.FeatureAgentRuntimeHours: 50,
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 99,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
}
@@ -2662,46 +2905,115 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
- jwt := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: tc.features,
- })
- _, err := license.ParseClaims(jwt, coderdenttest.Keys)
- if tc.expectedErr == nil {
- require.NoError(t, err)
+ features := license.Features{
+ codersdk.FeatureUserLimit: 100,
+ }
+ maps.Copy(features, tc.features)
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: features,
+ }),
+ }
+
+ var logBuf bytes.Buffer
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
+ )
+ require.NoError(t, err)
+
+ // The license as a whole survives: unrelated paid features are
+ // unaffected by an unusable runtime hour claim.
+ require.Empty(t, entitlements.Errors)
+ require.True(t, entitlements.HasLicense)
+ userLimit := entitlements.Features[codersdk.FeatureUserLimit]
+ require.NotNil(t, userLimit.Limit)
+ require.EqualValues(t, 100, *userLimit.Limit)
+
+ // Dropped claims are tolerated but never silent: the operator
+ // sees the stable warning, and the log names the license and
+ // the dropped claims for support.
+ if tc.expectClaimsIgnored {
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ logs := logBuf.String()
+ require.Contains(t, logs, "ignored unusable Coder Agent runtime hour claims in license")
+ require.Contains(t, logs, lic.UUID.String())
+ } else {
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ require.Empty(t, logBuf.String())
+ }
+
+ // Every known feature name has a default entry in the map, so
+ // "the license does not grant the feature" surfaces as the
+ // default: no limit, no usage period, not enabled.
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ if tc.expectFeature == nil {
+ require.Nil(t, feature.Limit, "feature must not be granted")
+ require.Nil(t, feature.UsagePeriod, "feature must not be granted")
+ require.False(t, feature.Enabled)
return
}
- require.ErrorIs(t, err, tc.expectedErr)
+ require.NotNil(t, feature.UsagePeriod, "feature must be granted")
+ require.Equal(t, tc.expectFeature.Enabled, feature.Enabled)
+ require.Equal(t, tc.expectFeature.Limit, feature.Limit)
+ require.Equal(t, tc.expectFeature.SoftLimit, feature.SoftLimit)
+ require.Equal(t, tc.expectFeature.HardLimit, feature.HardLimit)
})
}
- // An invalid license already stored in the database is rejected entirely
- // and produces an entitlements error.
- t.Run("EntitlementsError", func(t *testing.T) {
+ t.Run("WarningDeduplicatedAcrossLicenses", func(t *testing.T) {
t.Parallel()
- lic := database.License{
- ID: 1,
- UploadedAt: time.Now(),
- Exp: time.Now().Add(time.Hour),
- UUID: uuid.New(),
- JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
- },
- }),
+ // Two licenses with unusable claims must publish the stable warning
+ // once, or the banner would stack identical texts, while the log
+ // names each affected license so the operator can tell which ones
+ // need re-issuing.
+ newLicense := func(id int32) database.License {
+ return database.License{
+ ID: id,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
+ // A threshold without an allocation is unusable.
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ }),
+ }
}
+ licenses := []database.License{newLicense(1), newLicense(2)}
+ var logBuf bytes.Buffer
entitlements, err := license.LicensesEntitlements(
- context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ context.Background(), time.Now(), licenses,
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
)
require.NoError(t, err)
- require.Len(t, entitlements.Errors, 1)
- require.Contains(t, entitlements.Errors[0], fmt.Sprintf("Invalid license (%s) parsing claims", lic.UUID))
- require.False(t, entitlements.HasLicense)
- feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
- require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
+
+ warningCount := 0
+ for _, warning := range entitlements.Warnings {
+ if warning == codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText {
+ warningCount++
+ }
+ }
+ require.Equal(t, 1, warningCount, "the claims-ignored warning must appear exactly once")
+
+ logs := logBuf.String()
+ for _, lic := range licenses {
+ require.Contains(t, logs, lic.UUID.String())
+ }
})
}
diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go
index c88f975f2c0..811929f0939 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -105,35 +106,41 @@ func TestPostLicense(t *testing.T) {
require.Contains(t, errResp.Message, "Invalid license")
})
- t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) {
+ t.Run("UnusableAgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- // A soft limit claim without an allocation claim rejects the whole
- // license.
+ // A soft limit claim without an allocation claim is unusable, but it
+ // never rejects the whole license: the license stays valid, the
+ // runtime hours feature is simply not granted, and the dropped claim
+ // is surfaced as a warning. See decodeAgentRuntimeHours.
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: lic,
})
- errResp := &codersdk.Error{}
- require.ErrorAs(t, err, &errResp)
- require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
- require.Contains(t, errResp.Message, "Invalid license")
+ require.NoError(t, err)
+ // The claims round-trip through GET /api/v2/entitlements.
+ //nolint:gocritic // This test asserts license state, not authz behavior.
+ entitlements, err := client.Entitlements(context.Background())
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+ require.Empty(t, entitlements.Errors)
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.UsagePeriod)
})
t.Run("AgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
- license.ClaimAgentRuntimeHoursLimitHard: 120,
- },
- })
+ coderdenttest.AddLicense(t, client,
+ *(&coderdenttest.LicenseOptions{}).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120)))
// The claims round-trip through GET /api/v2/entitlements.
//nolint:gocritic // This test asserts license state, not authz behavior.
entitlements, err := client.Entitlements(context.Background())
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index ba98c91351f..3f7d38b761f 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5722,6 +5722,17 @@ export const LicenseAIGovernance90PercentWarningText =
export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
+// From codersdk/licenses.go
+/**
+ * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ * license carries unusable Coder Agent runtime hour claims (see
+ * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ * the license and the dropped claims. The dashboard renders the exact
+ * text as a muted diagnostic without a sales link.
+ */
+export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
+ "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
+
// From codersdk/licenses.go
export const LicenseExpiryClaim = "license_expires";
@@ -5729,6 +5740,18 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
+// From codersdk/licenses.go
+/**
+ * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ * managed agent usage query fails while computing entitlements; the
+ * cause is logged server-side. It travels in the entitlements Errors
+ * channel so the alertable coderd_license_errors gauge counts
+ * measurement failures, but the dashboard recognizes the exact text and
+ * renders it as a muted diagnostic rather than a license error.
+ */
+export const LicenseManagedAgentUsageUnavailableErrorText =
+ "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
+
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index ba5f739d93d..9ef4dda8bb1 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -1,8 +1,10 @@
import type { FC } from "react";
import {
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -24,8 +26,32 @@ const isAIGovernanceWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
message.startsWith(aiGovernanceOverLimitWarningPrefix);
-const isAIGovernanceNearLimitWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix);
+// Substitutes the given values into the template's %d placeholders in order.
+// No other fmt verb, width, or flag is implemented.
+const formatLicenseMessage = (template: string, ...values: number[]): string =>
+ values.reduce(
+ (message, value) => message.replace("%d", `${value}`),
+ template,
+ );
+
+// Diagnostics about the license or the usage measurement rather than about
+// usage itself. They render muted, without the exceedance heading or a sales
+// link. The "unavailable" pair arrives via entitlements.errors but must not
+// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+const diagnosticMessages: readonly string[] = [
+ LicenseManagedAgentUsageUnavailableErrorText,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
+];
+
+const isDiagnosticMessage = (message: string): boolean =>
+ diagnosticMessages.includes(message);
+
+// Advisories and diagnostics render in the muted variant: nothing is wrong
+// yet, so they must be visually distinct from warnings that demand action,
+// such as exceeding a license limit.
+const isMutedWarning = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
+ isDiagnosticMessage(message);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -48,9 +74,12 @@ const aiGovernanceOverLimitMessage = (
}
const overLimitSeats = actual - limit;
- return LicenseAIGovernanceOverLimitWarningText.replace("%d", `${actual}`)
- .replace("%d", `${limit}`)
- .replace("%d", `${overLimitSeats}`);
+ return formatLicenseMessage(
+ LicenseAIGovernanceOverLimitWarningText,
+ actual,
+ limit,
+ overLimitSeats,
+ );
};
const aiGovernanceNearLimitMessage = (
@@ -99,7 +128,7 @@ const normalizeAIGovernanceWarning = (
);
};
-const messageLink = (message: string): LicenseBannerLink => {
+const messageLink = (message: string): LicenseBannerLink | undefined => {
if (message === LicenseManagedAgentLimitExceededWarningText) {
return {
href: docs("/ai-coder/ai-governance"),
@@ -115,6 +144,11 @@ const messageLink = (message: string): LicenseBannerLink => {
showExternalIcon: false,
};
}
+ // Diagnostics point the operator at the logs or support, so they do not
+ // get a sales link.
+ if (isDiagnosticMessage(message)) {
+ return undefined;
+ }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -146,12 +180,16 @@ export const LicenseBanner: FC = () => {
const messages: LicenseBannerMessage[] = [
...errors.map((message) => ({
message,
- variant: "error" as const,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message)
+ ? ("warning" as const)
+ : ("error" as const),
link: messageLink(message),
})),
...normalizedWarnings.map((message) => ({
message,
- variant: isAIGovernanceNearLimitWarning(message)
+ variant: isMutedWarning(message)
? ("warning" as const)
: ("warningProminent" as const),
link: messageLink(message),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 582493a4eff..6c3dd4e662b 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import {
+ type Entitlements,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -180,30 +183,24 @@ export const ManagedAgentLimitExceededWithOtherWarnings: Story = {
},
};
-const renderLicenseBannerWithAIGovernance = ({
- actual,
- entitlement = "entitled",
- limit,
+const renderLicenseBanner = ({
+ errors = [],
warnings = [],
+ features = {},
}: {
- actual: number;
- entitlement?: "entitled" | "grace_period" | "not_entitled";
- limit?: number;
+ errors?: string[];
warnings?: string[];
+ features?: Partial;
}) => {
const mockDashboardValue: DashboardValue = {
entitlements: {
...MockEntitlements,
has_license: true,
+ errors,
warnings,
features: {
...MockEntitlements.features,
- ai_governance_user_limit: {
- enabled: true,
- entitlement,
- actual,
- ...(limit !== undefined ? { limit } : {}),
- },
+ ...features,
},
},
experiments: MockExperiments,
@@ -215,12 +212,39 @@ const renderLicenseBannerWithAIGovernance = ({
};
return (
-
+
-
+
);
};
+const renderLicenseBannerWithAIGovernance = ({
+ actual,
+ entitlement = "entitled",
+ limit,
+ warnings = [],
+}: {
+ actual: number;
+ entitlement?: "entitled" | "grace_period" | "not_entitled";
+ limit?: number;
+ warnings?: string[];
+}) =>
+ renderLicenseBanner({
+ warnings,
+ features: {
+ ai_governance_user_limit: {
+ enabled: true,
+ entitlement,
+ actual,
+ ...(limit !== undefined ? { limit } : {}),
+ },
+ },
+ });
+
+// Without the data-variant assertions, every story would keep passing with
+// the muted/prominent classifier disabled.
+const mutedVariant = "warning";
+
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -230,9 +254,13 @@ export const AIGovernanceNearLimit: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByRole("status")).toHaveTextContent(
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
+ // Pins the AI Governance near-limit branch of isMutedWarning,
+ // independently of the runtime soft-limit branch below.
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -267,3 +295,56 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
);
},
};
+
+// Each entry of the frontend's diagnosticMessages set is pinned on both
+// properties the set drives: the muted variant and the suppressed sales
+// link. The "unavailable" message arrives on the errors channel; see the
+// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+const playMutedDiagnostic =
+ (message: string): Story["play"] =>
+ async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(message);
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(
+ canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
+ ).not.toBeInTheDocument();
+ };
+
+export const ManagedAgentUsageUnavailable: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ }),
+ play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
+};
+
+export const AgentRuntimeHoursClaimsIgnored: Story = {
+ render: () =>
+ renderLicenseBanner({
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText),
+};
+
+// An all-diagnostic banner must not claim license limits were exceeded.
+export const UsageDiagnosticsOnlyHeading: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByText("License notices")).toBeInTheDocument();
+ await expect(
+ canvas.queryByText("Your license limits have been exceeded"),
+ ).not.toBeInTheDocument();
+ await expect(
+ canvas.queryByText("License errors require attention"),
+ ).not.toBeInTheDocument();
+ },
+};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 80674fcbb7e..bd278dc591c 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,10 +70,19 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-const bannerTitle = (variant: LicenseBannerVariant): string =>
- variant === "error"
- ? "License errors require attention"
- : "Your license limits have been exceeded";
+// The muted variant only wins when every message is muted (see
+// getBannerVariant), which means advisories and diagnostics: nothing has
+// been exceeded, so the heading must not assert exceedance.
+const bannerTitle = (variant: LicenseBannerVariant): string => {
+ switch (variant) {
+ case "error":
+ return "License errors require attention";
+ case "warningProminent":
+ return "Your license limits have been exceeded";
+ case "warning":
+ return "License notices";
+ }
+};
const bannerRole = (variant: LicenseBannerVariant): "alert" | "status" =>
variant === "error" ? "alert" : "status";
@@ -142,6 +151,9 @@ export const LicenseBannerView: React.FC = ({
return (
From 69e8b95ddb21bce9e5cf7a964983a2178a2a546b Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 11:09:44 +0000
Subject: [PATCH 004/116] feat: report agent runtime hours usage in
entitlements
Populate FeatureAgentRuntimeHours.Actual on every entitlements refresh
for licenses that grant the feature. A new
GetTotalUsageHBAgentRuntimeV1 query sums runtime_ms over the license's
usage period, reading usage_events directly: hb_agent_runtime_v1 is
exactly one row per hourly bucket deployment-wide, enforced by the
unique partial index idx_usage_events_agent_runtime.
The measurement reuses the shared measureUsage policy through a new
AgentRuntimeMsFn closure (usage publisher subject): failures publish
the stable LicenseAgentRuntimeUsageUnavailableErrorText and log the
cause. Usage is floored to whole hours, matching the unit of the
agent_runtime_hours_* claims, and at most one warning is emitted:
reaching the allocation supersedes the advisory soft limit. The
dashboard renders the soft-limit advisory muted without a sales link
and treats the runtime usage-unavailable text as a diagnostic.
Closes CODAGT-852.
---
coderd/apidoc/docs.go | 5 +-
coderd/apidoc/swagger.json | 5 +-
coderd/database/dbauthz/dbauthz.go | 7 +
coderd/database/dbauthz/dbauthz_test.go | 8 +
coderd/database/dbmetrics/querymetrics.go | 8 +
coderd/database/dbmock/dbmock.go | 15 +
coderd/database/querier.go | 14 +
coderd/database/querier_test.go | 86 ++++-
coderd/database/queries.sql.go | 38 ++
coderd/database/queries/usageevents.sql | 25 ++
coderd/database/queries_internal_test.go | 28 ++
codersdk/deployment.go | 25 +-
codersdk/licenses.go | 17 +
codersdk/licenses_test.go | 50 +++
docs/reference/api/schemas.md | 21 +-
enterprise/coderd/license/license.go | 77 ++++
.../coderd/license/license_internal_test.go | 82 ++++
enterprise/coderd/license/license_test.go | 354 ++++++++++++++++--
enterprise/coderd/licenses_test.go | 15 +
site/src/api/typesGenerated.ts | 57 ++-
.../dashboard/LicenseBanner/LicenseBanner.tsx | 26 +-
.../LicenseBannerView.stories.tsx | 78 +++-
22 files changed, 964 insertions(+), 77 deletions(-)
create mode 100644 coderd/database/queries_internal_test.go
create mode 100644 codersdk/licenses_test.go
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 39e1a97f23e..769a17ff1ab 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -20644,6 +20644,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"actual": {
+ "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds.",
"type": "integer"
},
"enabled": {
@@ -20660,11 +20661,11 @@ const docTemplate = `{
"type": "integer"
},
"soft_limit": {
- "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation.\n\nOnly certain features set this field:\n- FeatureAgentRuntimeHours",
+ "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation. Only FeatureAgentRuntimeHours sets this\nfield.",
"type": "integer"
},
"usage_period": {
- "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit\n- FeatureAgentRuntimeHours",
+ "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense). These dates are determined from the license that this\nentitlement comes from, see enterprise/coderd/license/license.go.\nOnly FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this\nfield.",
"allOf": [
{
"$ref": "#/definitions/codersdk.UsagePeriod"
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index e70e01bad93..738600449bc 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -18778,6 +18778,7 @@
"type": "object",
"properties": {
"actual": {
+ "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds.",
"type": "integer"
},
"enabled": {
@@ -18794,11 +18795,11 @@
"type": "integer"
},
"soft_limit": {
- "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation.\n\nOnly certain features set this field:\n- FeatureAgentRuntimeHours",
+ "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation. Only FeatureAgentRuntimeHours sets this\nfield.",
"type": "integer"
},
"usage_period": {
- "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense).\n\nThese dates are determined from the license that this entitlement comes\nfrom, see enterprise/coderd/license/license.go.\n\nOnly certain features set these fields:\n- FeatureManagedAgentLimit\n- FeatureAgentRuntimeHours",
+ "description": "UsagePeriod denotes that the usage is a counter that accumulates over\nthis period (and most likely resets with the issuance of the next\nlicense). These dates are determined from the license that this\nentitlement comes from, see enterprise/coderd/license/license.go.\nOnly FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this\nfield.",
"allOf": [
{
"$ref": "#/definitions/codersdk.UsagePeriod"
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index 58e8b989b25..056c3354dc2 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -4931,6 +4931,13 @@ func (q *querier) GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg databa
return q.db.GetTotalUsageDCManagedAgentsV1(ctx, arg)
}
+func (q *querier) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg database.GetTotalUsageHBAgentRuntimeV1Params) (int64, error) {
+ if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUsageEvent); err != nil {
+ return 0, err
+ }
+ return q.db.GetTotalUsageHBAgentRuntimeV1(ctx, arg)
+}
+
func (q *querier) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceLicense); err != nil {
return nil, err
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index c0cd5e280b8..c553c6e1afe 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -6490,6 +6490,14 @@ func (s *MethodTestSuite) TestUsageEvents() {
}).Asserts(rbac.ResourceUsageEvent, policy.ActionRead)
}))
+ s.Run("GetTotalUsageHBAgentRuntimeV1", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
+ db.EXPECT().GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()).Return(int64(1), nil)
+ check.Args(database.GetTotalUsageHBAgentRuntimeV1Params{
+ StartTime: time.Time{},
+ EndTime: time.Time{},
+ }).Asserts(rbac.ResourceUsageEvent, policy.ActionRead)
+ }))
+
s.Run("ListUsageEventCreatedAtsByTypeSince", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
params := database.ListUsageEventCreatedAtsByTypeSinceParams{
EventType: "hb_agent_runtime_v1",
diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go
index 2cbc9710286..a664865da2a 100644
--- a/coderd/database/dbmetrics/querymetrics.go
+++ b/coderd/database/dbmetrics/querymetrics.go
@@ -3113,6 +3113,14 @@ func (m queryMetricsStore) GetTotalUsageDCManagedAgentsV1(ctx context.Context, a
return r0, r1
}
+func (m queryMetricsStore) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg database.GetTotalUsageHBAgentRuntimeV1Params) (int64, error) {
+ start := time.Now()
+ r0, r1 := m.s.GetTotalUsageHBAgentRuntimeV1(ctx, arg)
+ m.queryLatencies.WithLabelValues("GetTotalUsageHBAgentRuntimeV1").Observe(time.Since(start).Seconds())
+ m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetTotalUsageHBAgentRuntimeV1").Inc()
+ return r0, r1
+}
+
func (m queryMetricsStore) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) {
start := time.Now()
r0, r1 := m.s.GetUnexpiredLicenses(ctx)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index 38ae689b768..e7bb6ba9381 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -5820,6 +5820,21 @@ func (mr *MockStoreMockRecorder) GetTotalUsageDCManagedAgentsV1(ctx, arg any) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTotalUsageDCManagedAgentsV1", reflect.TypeOf((*MockStore)(nil).GetTotalUsageDCManagedAgentsV1), ctx, arg)
}
+// GetTotalUsageHBAgentRuntimeV1 mocks base method.
+func (m *MockStore) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg database.GetTotalUsageHBAgentRuntimeV1Params) (int64, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetTotalUsageHBAgentRuntimeV1", ctx, arg)
+ ret0, _ := ret[0].(int64)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetTotalUsageHBAgentRuntimeV1 indicates an expected call of GetTotalUsageHBAgentRuntimeV1.
+func (mr *MockStoreMockRecorder) GetTotalUsageHBAgentRuntimeV1(ctx, arg any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTotalUsageHBAgentRuntimeV1", reflect.TypeOf((*MockStore)(nil).GetTotalUsageHBAgentRuntimeV1), ctx, arg)
+}
+
// GetUnexpiredLicenses mocks base method.
func (m *MockStore) GetUnexpiredLicenses(ctx context.Context) ([]database.License, error) {
m.ctrl.T.Helper()
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 05147dad109..5b01b474b1b 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -878,6 +878,20 @@ type sqlcQuerier interface {
// the events that happened on and between the two dates. Both dates are
// inclusive.
GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg GetTotalUsageDCManagedAgentsV1Params) (int64, error)
+ // Gets the total Coder Agent runtime in milliseconds between two timestamps.
+ // The start bound is inclusive and the end bound is exclusive.
+ //
+ // Unlike GetTotalUsageDCManagedAgentsV1 this reads usage_events directly
+ // rather than the usage_events_daily rollup: hb_agent_runtime_v1 is exactly
+ // one row per hourly bucket deployment-wide, with created_at at the bucket
+ // start, enforced by the unique partial index
+ // idx_usage_events_agent_runtime (which also keeps SUM from counting a
+ // bucket twice and serves this query). The result is bucket-granular: a
+ // bucket counts entirely against the period containing its start. See
+ // enterprise/coderd/usage/generator.go for what a bucket holds. If a
+ // usage_events retention policy ever lands, this must move to the daily
+ // rollup and accept day-granularity bounds.
+ GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg GetTotalUsageHBAgentRuntimeV1Params) (int64, error)
GetUnexpiredLicenses(ctx context.Context) ([]License, error)
GetUserAIBudgetOverride(ctx context.Context, userID uuid.UUID) (UserAIBudgetOverride, error)
GetUserAIProviderKeyByProviderID(ctx context.Context, arg GetUserAIProviderKeyByProviderIDParams) (UserAIProviderKey, error)
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index abb2d0c39e1..beacc1a0359 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -35,6 +35,7 @@ import (
"github.com/coder/coder/v2/coderd/provisionerdserver"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
+ "github.com/coder/coder/v2/coderd/usage/usagetypes"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/codersdk"
@@ -10860,8 +10861,8 @@ func TestUsageEventsTrigger(t *testing.T) {
require.Len(t, rows, 3)
// The same bucket under a different id is not an idempotent
- // re-insert but a duplicate that would double any aggregate summing
- // runtime_ms; the unique partial index
+ // re-insert but a duplicate that would double the SUM in
+ // GetTotalUsageHBAgentRuntimeV1; the unique partial index
// idx_usage_events_agent_runtime rejects it loudly instead of the
// (id) arbiter silently dropping it.
err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
@@ -10925,6 +10926,87 @@ func TestUsageEventsTrigger(t *testing.T) {
})
}
+func TestGetTotalUsageHBAgentRuntimeV1(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ db, _ := dbtestutil.NewDB(t)
+
+ // hb_agent_runtime_v1 events are one row per hourly bucket, created_at
+ // set to the bucket start.
+ hour := func(d, h int) time.Time {
+ return time.Date(2025, 1, d, h, 0, 0, 0, time.UTC)
+ }
+ // The event type and payload are built from the producer's types rather
+ // than hand-written literals, so a rename in usagetypes fails this test
+ // instead of leaving the query silently summing a key nothing writes.
+ insert := func(id string, runtimeMs int64, createdAt time.Time) {
+ t.Helper()
+ event := usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs}
+ eventData, err := json.Marshal(event.Fields())
+ require.NoError(t, err)
+ err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: id,
+ EventType: string(event.EventType()),
+ EventData: eventData,
+ CreatedAt: createdAt,
+ })
+ require.NoError(t, err)
+ }
+ total := func(start, end time.Time) int64 {
+ t.Helper()
+ got, err := db.GetTotalUsageHBAgentRuntimeV1(ctx, database.GetTotalUsageHBAgentRuntimeV1Params{
+ StartTime: start,
+ EndTime: end,
+ })
+ require.NoError(t, err)
+ return got
+ }
+
+ // No events at all sums to zero rather than NULL.
+ require.EqualValues(t, 0, total(hour(1, 0), hour(5, 0)))
+
+ insert("rt-d1h0", 1000, hour(1, 0))
+ insert("rt-d1h12", 500, hour(1, 12))
+ insert("rt-d1h18", 0, hour(1, 18))
+ insert("rt-d2h0", 250, hour(2, 0))
+ insert("rt-d4h0", 7, hour(4, 0))
+
+ // A multi-day range sums every bucket it covers.
+ require.EqualValues(t, 1757, total(hour(1, 0), hour(5, 0)))
+
+ // The start bound is inclusive and the end bound is exclusive: a bucket
+ // starting exactly at the end timestamp belongs to the next period.
+ require.EqualValues(t, 1500, total(hour(1, 0), hour(2, 0)))
+ require.EqualValues(t, 1750, total(hour(1, 0), hour(2, 1)))
+ require.EqualValues(t, 250, total(hour(2, 0), hour(4, 0)))
+ require.EqualValues(t, 0, total(hour(3, 0), hour(4, 0)))
+
+ // Bounds are exact timestamps rather than whole days: a period starting
+ // mid-day excludes that day's earlier buckets.
+ require.EqualValues(t, 757, total(hour(1, 12), hour(5, 0)))
+
+ // A non-UTC timestamp addresses the same instant. Sydney is UTC+11 in
+ // January, so 23:00 on Jan 1 in Sydney is 12:00 on Jan 1 in UTC.
+ locSydney, err := time.LoadLocation("Australia/Sydney")
+ require.NoError(t, err)
+ require.EqualValues(t, 750, total(
+ time.Date(2025, 1, 1, 23, 0, 0, 0, locSydney),
+ time.Date(2025, 1, 2, 12, 0, 0, 0, locSydney),
+ ))
+
+ // Other event types are never mixed in, even when they carry a
+ // runtime_ms key: without the event_type filter this would add 9999.
+ err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "seats-1",
+ EventType: "hb_ai_seats_v1",
+ EventData: []byte(`{"count": 1, "runtime_ms": 9999}`),
+ CreatedAt: hour(1, 0),
+ })
+ require.NoError(t, err)
+ require.EqualValues(t, 1757, total(hour(1, 0), hour(5, 0)))
+}
+
func TestGetTotalChatMessageRuntimeMsInRange(t *testing.T) {
t.Parallel()
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 20b5f33bea5..cb2f177d01a 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28439,6 +28439,44 @@ func (q *sqlQuerier) GetTotalUsageDCManagedAgentsV1(ctx context.Context, arg Get
return total_count, err
}
+const getTotalUsageHBAgentRuntimeV1 = `-- name: GetTotalUsageHBAgentRuntimeV1 :one
+SELECT
+ -- The first cast is necessary since you can't sum strings, and the second
+ -- cast is necessary to make sqlc happy.
+ COALESCE(SUM((event_data->>'runtime_ms')::bigint), 0)::bigint AS total_runtime_ms
+FROM
+ usage_events
+WHERE
+ event_type = 'hb_agent_runtime_v1'
+ AND created_at >= $1::timestamptz
+ AND created_at < $2::timestamptz
+`
+
+type GetTotalUsageHBAgentRuntimeV1Params struct {
+ StartTime time.Time `db:"start_time" json:"start_time"`
+ EndTime time.Time `db:"end_time" json:"end_time"`
+}
+
+// Gets the total Coder Agent runtime in milliseconds between two timestamps.
+// The start bound is inclusive and the end bound is exclusive.
+//
+// Unlike GetTotalUsageDCManagedAgentsV1 this reads usage_events directly
+// rather than the usage_events_daily rollup: hb_agent_runtime_v1 is exactly
+// one row per hourly bucket deployment-wide, with created_at at the bucket
+// start, enforced by the unique partial index
+// idx_usage_events_agent_runtime (which also keeps SUM from counting a
+// bucket twice and serves this query). The result is bucket-granular: a
+// bucket counts entirely against the period containing its start. See
+// enterprise/coderd/usage/generator.go for what a bucket holds. If a
+// usage_events retention policy ever lands, this must move to the daily
+// rollup and accept day-granularity bounds.
+func (q *sqlQuerier) GetTotalUsageHBAgentRuntimeV1(ctx context.Context, arg GetTotalUsageHBAgentRuntimeV1Params) (int64, error) {
+ row := q.db.QueryRowContext(ctx, getTotalUsageHBAgentRuntimeV1, arg.StartTime, arg.EndTime)
+ var total_runtime_ms int64
+ err := row.Scan(&total_runtime_ms)
+ return total_runtime_ms, err
+}
+
const insertUsageEvent = `-- name: InsertUsageEvent :exec
INSERT INTO
usage_events (
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 03f435c5af7..563f392f20f 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -121,3 +121,28 @@ WHERE
-- Parentheses are necessary to avoid sqlc from generating an extra
-- argument.
AND day BETWEEN date_trunc('day', (@start_date::timestamptz) AT TIME ZONE 'UTC')::date AND date_trunc('day', (@end_date::timestamptz) AT TIME ZONE 'UTC')::date;
+
+-- name: GetTotalUsageHBAgentRuntimeV1 :one
+-- Gets the total Coder Agent runtime in milliseconds between two timestamps.
+-- The start bound is inclusive and the end bound is exclusive.
+--
+-- Unlike GetTotalUsageDCManagedAgentsV1 this reads usage_events directly
+-- rather than the usage_events_daily rollup: hb_agent_runtime_v1 is exactly
+-- one row per hourly bucket deployment-wide, with created_at at the bucket
+-- start, enforced by the unique partial index
+-- idx_usage_events_agent_runtime (which also keeps SUM from counting a
+-- bucket twice and serves this query). The result is bucket-granular: a
+-- bucket counts entirely against the period containing its start. See
+-- enterprise/coderd/usage/generator.go for what a bucket holds. If a
+-- usage_events retention policy ever lands, this must move to the daily
+-- rollup and accept day-granularity bounds.
+SELECT
+ -- The first cast is necessary since you can't sum strings, and the second
+ -- cast is necessary to make sqlc happy.
+ COALESCE(SUM((event_data->>'runtime_ms')::bigint), 0)::bigint AS total_runtime_ms
+FROM
+ usage_events
+WHERE
+ event_type = 'hb_agent_runtime_v1'
+ AND created_at >= @start_time::timestamptz
+ AND created_at < @end_time::timestamptz;
diff --git a/coderd/database/queries_internal_test.go b/coderd/database/queries_internal_test.go
new file mode 100644
index 00000000000..798a3405335
--- /dev/null
+++ b/coderd/database/queries_internal_test.go
@@ -0,0 +1,28 @@
+package database
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/coderd/usage/usagetypes"
+)
+
+// TestGetTotalUsageHBAgentRuntimeV1QueryEventType pins the event type and
+// payload extraction literals in the generated SQL to the Go producer.
+// Renaming either would make this read-only query silently return 0 (->> on
+// a missing key yields NULL, SUM skips NULLs, COALESCE reports 0), which is
+// indistinguishable from zero usage at every layer above it.
+func TestGetTotalUsageHBAgentRuntimeV1QueryEventType(t *testing.T) {
+ t.Parallel()
+
+ require.Contains(t, getTotalUsageHBAgentRuntimeV1,
+ string(usagetypes.UsageEventTypeHBAgentRuntimeV1))
+ // The full extraction expression is pinned, not the bare key: the
+ // query's result alias (total_runtime_ms) contains "runtime_ms", so a
+ // bare-key assertion would keep passing after the ->> key was renamed.
+ for field := range (usagetypes.HBAgentRuntime{}).Fields() {
+ require.Contains(t, getTotalUsageHBAgentRuntimeV1,
+ "event_data->>'"+field+"'")
+ }
+}
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 14eca1624ec..2edeb087a8e 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -383,29 +383,28 @@ type Feature struct {
Limit *int64 `json:"limit,omitempty"`
// SoftLimit is the advisory warning threshold that accompanies Limit for
// features whose license carries it. For these features, Limit carries
- // the purchased allocation.
- //
- // Only certain features set this field:
- // - FeatureAgentRuntimeHours
+ // the purchased allocation. Only FeatureAgentRuntimeHours sets this
+ // field.
SoftLimit *int64 `json:"soft_limit,omitempty"`
// HardLimit is the enforcement threshold that accompanies Limit for
// features whose license carries it. See SoftLimit for the set of
// features that use these thresholds.
HardLimit *int64 `json:"hard_limit,omitempty"`
- Actual *int64 `json:"actual,omitempty"`
+ // Actual is the usage measured against Limit, when known: a
+ // point-in-time count for most features, or usage accumulated over
+ // UsagePeriod for features that set one. Its unit matches Limit's;
+ // FeatureAgentRuntimeHours reports whole hours floored from the
+ // recorded milliseconds.
+ Actual *int64 `json:"actual,omitempty"`
// Below is only for features that use usage periods.
// UsagePeriod denotes that the usage is a counter that accumulates over
// this period (and most likely resets with the issuance of the next
- // license).
- //
- // These dates are determined from the license that this entitlement comes
- // from, see enterprise/coderd/license/license.go.
- //
- // Only certain features set these fields:
- // - FeatureManagedAgentLimit
- // - FeatureAgentRuntimeHours
+ // license). These dates are determined from the license that this
+ // entitlement comes from, see enterprise/coderd/license/license.go.
+ // Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this
+ // field.
UsagePeriod *UsagePeriod `json:"usage_period,omitempty"`
}
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 9cb0dbc88b1..444a68bd91c 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,6 +17,20 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ // LicenseAgentRuntimeHoursSoftLimitWarningText is emitted while runtime
+ // usage is at or above the advisory soft limit but within the
+ // allocation. Placeholders are whole hours: used, allocation, soft
+ // limit.
+ //
+ // The dashboard's LicenseBanner matches this text's pre-placeholder
+ // prefix to render it muted and without a sales link, so the license
+ // warning texts must stay pairwise distinct before their first
+ // placeholder. See TestLicenseAgentRuntimeHoursWarningTexts.
+ LicenseAgentRuntimeHoursSoftLimitWarningText = "Your deployment is approaching its Coder Agent runtime hours allocation: %d of the %d hours included in the current license term are used, at or above the advisory soft limit of %d hours."
+ // LicenseAgentRuntimeHoursAllocationReachedWarningText is emitted once
+ // the deployment reaches its runtime hour allocation. Placeholders are
+ // whole hours: used, allocation.
+ LicenseAgentRuntimeHoursAllocationReachedWarningText = "Your deployment has used %d of the %d Coder Agent runtime hours included in the current license term."
// LicenseManagedAgentUsageUnavailableErrorText is emitted when the
// managed agent usage query fails while computing entitlements; the
// cause is logged server-side. It travels in the entitlements Errors
@@ -24,6 +38,9 @@ const (
// measurement failures, but the dashboard recognizes the exact text and
// renders it as a muted diagnostic rather than a license error.
LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
+ // LicenseAgentRuntimeUsageUnavailableErrorText is the Coder Agent
+ // runtime hours sibling of LicenseManagedAgentUsageUnavailableErrorText.
+ LicenseAgentRuntimeUsageUnavailableErrorText = "Unable to determine Coder Agent runtime usage. Reported runtime hours are unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
// LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
// license carries unusable Coder Agent runtime hour claims (see
// decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
diff --git a/codersdk/licenses_test.go b/codersdk/licenses_test.go
new file mode 100644
index 00000000000..2042cc50aaf
--- /dev/null
+++ b/codersdk/licenses_test.go
@@ -0,0 +1,50 @@
+package codersdk_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/codersdk"
+)
+
+// TestLicenseAgentRuntimeHoursWarningTexts pins the warning-text prefix
+// couplings consumed by the dashboard's LicenseBanner
+// (site/src/modules/dashboard/LicenseBanner).
+func TestLicenseAgentRuntimeHoursWarningTexts(t *testing.T) {
+ t.Parallel()
+
+ // Cut rather than Split so a template losing its placeholder fails the
+ // test instead of silently turning the whole message into the "prefix".
+ templatePrefix := func(text, placeholder string) string {
+ t.Helper()
+ prefix, _, ok := strings.Cut(text, placeholder)
+ require.True(t, ok, "template %q must contain placeholder %q", text, placeholder)
+ return prefix
+ }
+
+ aiGovNearLimitPrefix := templatePrefix(codersdk.LicenseAIGovernance90PercentWarningText, "%d%%")
+ aiGovOverLimitPrefix := templatePrefix(codersdk.LicenseAIGovernanceOverLimitWarningText, "%d")
+ softLimitPrefix := templatePrefix(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, "%d")
+
+ runtimeTexts := map[string]string{
+ "SoftLimit": codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText,
+ "AllocationReached": codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText,
+ }
+ for name, text := range runtimeTexts {
+ // isMutedWarning renders near-limit matches muted, and
+ // isAIGovernanceWarning matches either AI Governance prefix to
+ // suppress the banner's client-side over-limit fallback.
+ require.False(t, strings.HasPrefix(text, aiGovNearLimitPrefix),
+ "%s warning must not share the AI Governance near-limit prefix %q", name, aiGovNearLimitPrefix)
+ require.False(t, strings.HasPrefix(text, aiGovOverLimitPrefix),
+ "%s warning must not share the AI Governance over-limit prefix %q", name, aiGovOverLimitPrefix)
+ }
+
+ // isMutedWarning renders soft-limit matches muted and messageLink drops
+ // their sales link, so the allocation-reached warning must not match.
+ allocationReachedText := codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText
+ require.False(t, strings.HasPrefix(allocationReachedText, softLimitPrefix),
+ "the soft-limit prefix must not classify the allocation-reached warning")
+}
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 1dc9283ebea..105cdd7d6c7 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -7795,18 +7795,15 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
### Properties
-| Name | Type | Required | Restrictions | Description |
-|---------------|----------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `actual` | integer | false | | |
-| `enabled` | boolean | false | | |
-| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | |
-| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. |
-| `limit` | integer | false | | |
-|`soft_limit`|integer|false||Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation.
-Only certain features set this field: - FeatureAgentRuntimeHours|
-|`usage_period`|[codersdk.UsagePeriod](#codersdkusageperiod)|false||Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license).
-These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go.
-Only certain features set these fields: - FeatureManagedAgentLimit - FeatureAgentRuntimeHours|
+| Name | Type | Required | Restrictions | Description |
+|----------------|----------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `actual` | integer | false | | Actual is the usage measured against Limit, when known: a point-in-time count for most features, or usage accumulated over UsagePeriod for features that set one. Its unit matches Limit's; FeatureAgentRuntimeHours reports whole hours floored from the recorded milliseconds. |
+| `enabled` | boolean | false | | |
+| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | |
+| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. |
+| `limit` | integer | false | | |
+| `soft_limit` | integer | false | | Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation. Only FeatureAgentRuntimeHours sets this field. |
+| `usage_period` | [codersdk.UsagePeriod](#codersdkusageperiod) | false | | Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this field. |
## codersdk.FriendlyDiagnostic
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index e6f3598475e..b238e514d36 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -121,6 +121,15 @@ func Entitlements(
EndDate: endTime,
})
},
+ AgentRuntimeMsFn: func(ctx context.Context, startTime time.Time, endTime time.Time) (int64, error) {
+ // Bounds and bucket semantics are documented on the query.
+ //
+ // nolint:gocritic // Reading usage events requires the usage publisher subject.
+ return db.GetTotalUsageHBAgentRuntimeV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageHBAgentRuntimeV1Params{
+ StartTime: startTime,
+ EndTime: endTime,
+ })
+ },
})
if err != nil {
return entitlements, err
@@ -142,6 +151,9 @@ type FeatureArguments struct {
// state of the world, but a count between two points in time determined by
// the licenses.
ManagedAgentCountFn ManagedAgentCountFn
+ // AgentRuntimeMsFn is queried with two points in time determined by the
+ // licenses, like the managed agent count above.
+ AgentRuntimeMsFn AgentRuntimeMsFn
// UserCountingMode selects the count that FeatureUserLimit candidates
// from AI Governance addon licenses are evaluated against. Under
// UserCountingModeWorkspaceCapable they use WorkspaceCapableUserCountFn's
@@ -175,6 +187,10 @@ const (
type ManagedAgentCountFn func(ctx context.Context, from time.Time, to time.Time) (int64, error)
+// AgentRuntimeMsFn returns the total Coder Agent runtime in milliseconds
+// recorded between from (inclusive) and to (exclusive).
+type AgentRuntimeMsFn = ManagedAgentCountFn
+
type WorkspaceCapableUserCountFn func(ctx context.Context) (int64, error)
// userLimitCandidate is one license's FeatureUserLimit terms: its seat limit,
@@ -734,6 +750,32 @@ func LicensesEntitlements(
}
}
+ // Usage is measured even for a zero allocation, which reports the
+ // feature disabled: see decodeAgentRuntimeHours. Reported usage can
+ // trail real usage; the sources of staleness and loss are documented
+ // on the enterprise/coderd/usage.AgentRuntime* constants.
+ runtimeHours := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ if entitlements.HasLicense && runtimeHours.UsagePeriod != nil {
+ runtimeMs, ok, err := measureUsage(ctx, &entitlements,
+ featureArguments.Logger, featureArguments.AgentRuntimeMsFn, *runtimeHours.UsagePeriod,
+ "agent runtime", codersdk.LicenseAgentRuntimeUsageUnavailableErrorText)
+ if err != nil {
+ return entitlements, err
+ }
+ if ok {
+ actualHours := agentRuntimeMsToHours(runtimeMs)
+ runtimeHours.Actual = &actualHours
+ // Written back directly rather than through AddFeature; see
+ // the managed-agent write-back above for why.
+ entitlements.Features[codersdk.FeatureAgentRuntimeHours] = runtimeHours
+
+ // The allocation is dereferenced without a nil check because
+ // decodeAgentRuntimeHours always sets Limit for this feature.
+ entitlements.Warnings = appendAgentRuntimeHoursWarning(
+ entitlements.Warnings, actualHours, *runtimeHours.Limit, runtimeHours.SoftLimit)
+ }
+ }
+
if entitlements.HasLicense {
userLimit := entitlements.Features[codersdk.FeatureUserLimit]
// The enforced count and its meaning come from the selected
@@ -899,6 +941,28 @@ func measureUsage(
return value, true, nil
}
+// appendAgentRuntimeHoursWarning appends at most one warning: reaching the
+// allocation supersedes the advisory soft limit, so the dashboard banner
+// never stacks both messages.
+func appendAgentRuntimeHoursWarning(warnings []string, actualHours int64, allocation int64, softLimit *int64) []string {
+ if allocation <= 0 {
+ return warnings
+ }
+
+ switch {
+ case actualHours >= allocation:
+ return append(warnings, fmt.Sprintf(
+ codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText,
+ actualHours, allocation))
+ case softLimit != nil && actualHours >= *softLimit:
+ return append(warnings, fmt.Sprintf(
+ codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText,
+ actualHours, allocation, *softLimit))
+ }
+
+ return warnings
+}
+
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
@@ -975,6 +1039,19 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
}
}
+// agentRuntimeMsToHours floors milliseconds of Coder Agent runtime to whole
+// hours, the unit shared by the agent_runtime_hours_* claims and the
+// feature's limits. Flooring keeps the rendered value and the whole-hour
+// warning thresholds in agreement. Negative input (not producible by the
+// production query, but AgentRuntimeMsFn is a caller-supplied seam) clamps
+// to 0.
+func agentRuntimeMsToHours(ms int64) int64 {
+ if ms <= 0 {
+ return 0
+ }
+ return ms / int64(time.Hour/time.Millisecond)
+}
+
// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours
// feature from its claims. granted is false when there is no usable
// allocation claim; per-claim validity rules live on the Claim* constants
diff --git a/enterprise/coderd/license/license_internal_test.go b/enterprise/coderd/license/license_internal_test.go
index 616f0b5b989..161c980e436 100644
--- a/enterprise/coderd/license/license_internal_test.go
+++ b/enterprise/coderd/license/license_internal_test.go
@@ -1,10 +1,15 @@
package license
import (
+ "fmt"
+ "math"
"testing"
"time"
"github.com/stretchr/testify/assert"
+
+ "github.com/coder/coder/v2/coderd/util/ptr"
+ "github.com/coder/coder/v2/codersdk"
)
func TestNextLicenseValidityPeriod(t *testing.T) {
@@ -138,3 +143,80 @@ func permutations[T any](arr []T) [][]T {
helper(arr, 0)
return res
}
+
+func TestAgentRuntimeMsToHours(t *testing.T) {
+ t.Parallel()
+
+ const hourMs = int64(60 * 60 * 1000)
+
+ testCases := []struct {
+ name string
+ ms int64
+ want int64
+ }{
+ {"Zero", 0, 0},
+ // Any runtime below an hour floors to zero.
+ {"OneMillisecond", 1, 0},
+ {"JustUnderAnHour", hourMs - 1, 0},
+ {"ExactlyOneHour", hourMs, 1},
+ {"JustOverAnHour", hourMs + 1, 1},
+ {"JustUnderTwoHours", 2*hourMs - 1, 1},
+ {"ExactlyTwoHours", 2 * hourMs, 2},
+ // A realistic month of continuous runtime.
+ {"Large", 720 * hourMs, 720},
+ // Pins the divisor as milliseconds per hour.
+ {"MaxInt64", math.MaxInt64, math.MaxInt64 / hourMs},
+ // Negative input is not expected from the production query, which
+ // coalesces NULL to 0, but it must never produce a negative hour
+ // count that would compare oddly against the license limits.
+ {"Negative", -1, 0},
+ {"NegativeHour", -hourMs, 0},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tc.want, agentRuntimeMsToHours(tc.ms))
+ })
+ }
+}
+
+// TestAppendAgentRuntimeHoursWarning pins the warning arithmetic: thresholds
+// are "reached" (>=), and reaching the allocation supersedes the advisory
+// soft limit so at most one warning is appended.
+func TestAppendAgentRuntimeHoursWarning(t *testing.T) {
+ t.Parallel()
+
+ softLimit := ptr.Ref[int64](80)
+ softWarning := func(actual int64) []string {
+ return []string{fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, actual, 100, 80)}
+ }
+ allocationWarning := func(actual int64) []string {
+ return []string{fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, actual, 100)}
+ }
+
+ testCases := []struct {
+ name string
+ actual int64
+ allocation int64
+ softLimit *int64
+ want []string
+ }{
+ {"ZeroAllocation", 50, 0, softLimit, nil},
+ {"NegativeAllocation", 50, -1, softLimit, nil},
+ {"BelowSoftLimit", 79, 100, softLimit, nil},
+ {"AtSoftLimit", 80, 100, softLimit, softWarning(80)},
+ {"BetweenSoftLimitAndAllocation", 99, 100, softLimit, softWarning(99)},
+ {"AtAllocationSupersedesSoftLimit", 100, 100, softLimit, allocationWarning(100)},
+ {"OverAllocation", 150, 100, softLimit, allocationWarning(150)},
+ {"NoSoftLimitBelowAllocation", 99, 100, nil, nil},
+ {"NoSoftLimitAtAllocation", 100, 100, nil, allocationWarning(100)},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tc.want, appendAgentRuntimeHoursWarning(nil, tc.actual, tc.allocation, tc.softLimit))
+ })
+ }
+}
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index db68fb8b60a..0d9e38198db 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/google/uuid"
+ "github.com/lib/pq"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -963,18 +964,69 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
+ t.Run("AgentRuntimeHoursHasValue", func(t *testing.T) {
+ t.Parallel()
+
+ // Use a mock database so the production closure that reads
+ // usage_events can be observed directly.
+ mDB, licenseOpts := premiumRuntimeHoursFixture(t)
+
+ // The Premium feature set grants a default managed agent limit, so
+ // that usage is queried too. It is not what this test is about.
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), nil)
+ mDB.EXPECT().
+ GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Cond(func(params database.GetTotalUsageHBAgentRuntimeV1Params) bool {
+ // gomock doesn't seem to compare times very nicely, so check
+ // them manually. The bounds must be the usage period of the
+ // winning license.
+ if !assert.WithinDuration(t, licenseOpts.NotBefore, params.StartTime, time.Second) {
+ return false
+ }
+ if !assert.WithinDuration(t, licenseOpts.ExpiresAt, params.EndTime, time.Second) {
+ return false
+ }
+ return true
+ })).
+ // 90h30m of runtime floors to 90 hours.
+ Return((90*time.Hour + 30*time.Minute).Milliseconds(), nil)
+
+ entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+ require.Empty(t, entitlements.Errors)
+
+ runtimeHours, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.True(t, ok)
+ require.NotNil(t, runtimeHours.Actual)
+ require.EqualValues(t, 90, *runtimeHours.Actual)
+ require.NotNil(t, runtimeHours.Limit)
+ require.EqualValues(t, 100, *runtimeHours.Limit)
+
+ // 90 hours is past the soft limit of 80 but below the allocation of
+ // 100, so only the soft warning is emitted.
+ require.Len(t, entitlements.Warnings, 1)
+ require.Equal(t,
+ fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 90, 100, 80),
+ entitlements.Warnings[0])
+ })
+
t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
t.Parallel()
- // Drive the real Entitlements closure with a mock database so
+ // Drive the real Entitlements closures with a mock database so
// measureUsage's failure path is exercised end to end: the cause
- // must land in the coderd log, which the stable payload text points
+ // must land in the coderd log, which the stable payload texts point
// at, and must not land on the unauthenticated entitlements payload.
mDB, _ := premiumRuntimeHoursFixture(t)
mDB.EXPECT().
GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
Return(int64(0), xerrors.New("kaboom managed"))
+ mDB.EXPECT().
+ GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), xerrors.New("kaboom runtime"))
// The error-level logs are the behavior under test, so the default
// failing test logger cannot be used.
@@ -986,9 +1038,10 @@ func TestEntitlements(t *testing.T) {
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
- // The failure surfaces its stable text without the raw cause,
+ // Both failures surface their stable text without the raw cause,
// on the channel the codersdk constant docs prescribe.
require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
+ require.Contains(t, entitlements.Errors, codersdk.LicenseAgentRuntimeUsageUnavailableErrorText)
for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
require.NotContains(t, entry, "kaboom")
}
@@ -996,6 +1049,8 @@ func TestEntitlements(t *testing.T) {
logs := logBuf.String()
require.Contains(t, logs, "get managed agent count for entitlements")
require.Contains(t, logs, "kaboom managed")
+ require.Contains(t, logs, "get agent runtime for entitlements")
+ require.Contains(t, logs, "kaboom runtime")
})
t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
@@ -1008,6 +1063,9 @@ func TestEntitlements(t *testing.T) {
mDB.EXPECT().
GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), nil)
+ mDB.EXPECT().
+ GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()).
Return(int64(0), context.Canceled)
var logBuf bytes.Buffer
@@ -1016,8 +1074,8 @@ func TestEntitlements(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
- require.ErrorContains(t, err, "get managed agent count")
- require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
+ require.ErrorContains(t, err, "get agent runtime")
+ require.NotContains(t, logBuf.String(), "get agent runtime for entitlements")
})
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
@@ -1379,6 +1437,28 @@ func TestLicenseEntitlements(t *testing.T) {
}).Valid(time.Now())
}
+ // agentRuntimeHoursLicense builds an enterprise license carrying the
+ // agent runtime hour claims. A nil softLimit omits the claim; any
+ // non-nil value is minted verbatim so tests can construct zero or
+ // nonsensical soft limits. A positive allocation also carries a hard
+ // limit above the allocation (decodeAgentRuntimeHours ignores a lower
+ // one).
+ agentRuntimeHoursLicense := func(allocation int64, softLimit *int64) *coderdenttest.LicenseOptions {
+ var hard *int64
+ if allocation > 0 {
+ hard = ptr.Ref(allocation + 20)
+ }
+ return enterpriseLicense().UserLimit(100).AgentRuntimeHours(allocation, softLimit, hard)
+ }
+
+ // hoursToMsFn reports whole hours of runtime as the milliseconds the usage
+ // events actually record.
+ hoursToMsFn := func(hours int64) license.AgentRuntimeMsFn {
+ return func(_ context.Context, _, _ time.Time) (int64, error) {
+ return (time.Duration(hours) * time.Hour).Milliseconds(), nil
+ }
+ }
+
premiumLicense := func() *coderdenttest.LicenseOptions {
return (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
@@ -1399,8 +1479,11 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
- // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
+ // KeepNilAgentRuntimeMsFn skips the default AgentRuntimeMsFn
// injection below so the nil dev-error path can be exercised.
+ KeepNilAgentRuntimeMsFn bool
+ // KeepNilManagedAgentCountFn is the managed agent sibling of
+ // KeepNilAgentRuntimeMsFn.
KeepNilManagedAgentCountFn bool
// CancelContext cancels the context passed to LicensesEntitlements
// before the call, exercising the usage-measurement abort policy.
@@ -1656,11 +1739,143 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
+ {
+ // The soft warning end to end: the remaining threshold
+ // arithmetic is pinned by TestAppendAgentRuntimeHoursWarning.
+ Name: "AgentRuntimeHours/AtSoftLimit",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: hoursToMsFn(80),
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ require.Len(t, entitlements.Warnings, 1)
+ assert.Equal(t, fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 80, 100, 80),
+ entitlements.Warnings[0])
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.NotNil(t, feature.Actual)
+ assert.Equal(t, int64(80), *feature.Actual)
+ },
+ },
+ {
+ // At the allocation the soft warning is suppressed, so exactly one
+ // warning is emitted rather than both.
+ Name: "AgentRuntimeHours/AtAllocation",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: hoursToMsFn(100),
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ require.Len(t, entitlements.Warnings, 1)
+ assert.Equal(t, fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, 100, 100),
+ entitlements.Warnings[0])
+ assert.NotContains(t, entitlements.Warnings,
+ fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 100, 100, 80))
+ },
+ },
+ {
+ // A zero allocation carries no hour budget, so Enabled reports
+ // false and the hour thresholds never warn, but Actual is still
+ // reported. See decodeAgentRuntimeHours.
+ Name: "AgentRuntimeHours/ZeroAllocation",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(0, nil),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: hoursToMsFn(50),
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ assertNoWarnings(t, entitlements)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ assert.False(t, feature.Enabled)
+ require.NotNil(t, feature.Limit)
+ assert.Equal(t, int64(0), *feature.Limit)
+ require.NotNil(t, feature.Actual)
+ assert.Equal(t, int64(50), *feature.Actual)
+ },
+ },
+ {
+ // Partial hours are floored, so 99h59m59s does not reach the
+ // 100 hour allocation.
+ Name: "AgentRuntimeHours/PartialHourFloored",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return (100 * time.Hour).Milliseconds() - 1, nil
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ require.Len(t, entitlements.Warnings, 1)
+ assert.Equal(t, fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 99, 100, 80),
+ entitlements.Warnings[0])
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.NotNil(t, feature.Actual)
+ assert.Equal(t, int64(99), *feature.Actual)
+ },
+ },
+ {
+ // A license without the allocation claim does not grant the
+ // feature, so usage is never queried and nothing warns.
+ Name: "AgentRuntimeHours/NoClaimNoFeature",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ // Poison value: if the runtime block ever ran without the
+ // allocation claim, Actual would be set and the Nil
+ // assertion below would fail on the subtest's t.
+ return (9999 * time.Hour).Milliseconds(), nil
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ assertNoWarnings(t, entitlements)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ assert.Nil(t, feature.Actual)
+ assert.Nil(t, feature.UsagePeriod)
+ },
+ },
{
// A query failure is surfaced as a stable text in Errors (see
// the codersdk constant docs for the channel choice) and
// leaves Actual unset without aborting the rest of the
// entitlements.
+ Name: "AgentRuntimeHours/QueryError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, xerrors.New("kaboom")
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoWarnings(t, entitlements)
+ require.Len(t, entitlements.Errors, 1)
+ assert.Equal(t, codersdk.LicenseAgentRuntimeUsageUnavailableErrorText, entitlements.Errors[0])
+ // The raw error is logged rather than exposed on the
+ // unauthenticated entitlements payload.
+ assert.NotContains(t, entitlements.Errors[0], "kaboom")
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ assert.Nil(t, feature.Actual)
+ // The rest of the entitlements are still computed.
+ require.NotNil(t, feature.Limit)
+ assert.Equal(t, int64(100), *feature.Limit)
+ },
+ },
+ {
+ // The managed agent sibling of QueryError: the stable text
+ // hides the raw cause the same way.
Name: "ManagedAgentLimit/QueryError",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
@@ -1674,18 +1889,24 @@ func TestLicenseEntitlements(t *testing.T) {
assertNoWarnings(t, entitlements)
require.Len(t, entitlements.Errors, 1)
assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
- // The raw error is logged rather than exposed on the
- // unauthenticated entitlements payload.
assert.NotContains(t, entitlements.Errors[0], "kaboom")
feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
assert.Nil(t, feature.Actual)
},
},
{
- // Forgetting to wire ManagedAgentCountFn is a dev error:
- // production always provides the closure, so it fails the whole
- // call loudly instead of degrading into an operator-facing
- // message.
+ // Forgetting to wire AgentRuntimeMsFn is a dev error: production
+ // always provides both closures, so it fails the whole call
+ // loudly instead of degrading into an operator-facing message.
+ Name: "AgentRuntimeHours/NilRuntimeFnDevError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
+ },
+ KeepNilAgentRuntimeMsFn: true,
+ ExpectedErrorContains: "developer error: no closure provided to measure agent runtime usage",
+ },
+ {
+ // The managed agent sibling of NilRuntimeFnDevError.
Name: "ManagedAgentLimit/NilFnDevError",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
@@ -1697,6 +1918,20 @@ func TestLicenseEntitlements(t *testing.T) {
// A failure while the computation's own context is canceled
// aborts the whole call rather than degrading to an
// entitlements error.
+ Name: "AgentRuntimeHours/ContextCanceled",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
+ },
+ CancelContext: true,
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, context.Canceled
+ },
+ },
+ ExpectedErrorContains: "get agent runtime",
+ },
+ {
+ // The managed agent sibling of ContextCanceled.
Name: "ManagedAgentLimit/ContextCanceled",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
@@ -1709,6 +1944,49 @@ func TestLicenseEntitlements(t *testing.T) {
},
ExpectedErrorContains: "get managed agent count",
},
+ {
+ // Postgres raises the same SQLSTATE 57014 for statement_timeout
+ // kills. With a live context that is a query failure, not a
+ // shutdown: it must degrade into the stable diagnostic instead
+ // of aborting every refresh (and coderd startup) on deployments
+ // with an aggressive statement_timeout.
+ Name: "AgentRuntimeHours/StatementTimeout",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, xerrors.Errorf("query: %w", &pq.Error{Code: "57014", Message: "canceling statement due to statement timeout"})
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoWarnings(t, entitlements)
+ require.Len(t, entitlements.Errors, 1)
+ assert.Equal(t, codersdk.LicenseAgentRuntimeUsageUnavailableErrorText, entitlements.Errors[0])
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ assert.Nil(t, feature.Actual)
+ },
+ },
+ {
+ // A grace-period license still reports Actual and still warns at
+ // its thresholds.
+ Name: "AgentRuntimeHours/GracePeriod",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)).GracePeriod(time.Now()),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: hoursToMsFn(100),
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ assert.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
+ require.NotNil(t, feature.Actual)
+ assert.Equal(t, int64(100), *feature.Actual)
+ assert.Contains(t, entitlements.Warnings,
+ fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, 100, 100))
+ },
+ },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1745,6 +2023,13 @@ func TestLicenseEntitlements(t *testing.T) {
return 0, nil
}
}
+ // Default to 0 agent runtime.
+ if tc.Arguments.AgentRuntimeMsFn == nil && !tc.KeepNilAgentRuntimeMsFn {
+ tc.Arguments.AgentRuntimeMsFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
+ return 0, nil
+ }
+ }
+
ctx := context.Background()
if tc.CancelContext {
var cancel context.CancelFunc
@@ -1777,12 +2062,15 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
// A Premium license grants a managed agent limit by default, and a nil
- // usage closure is a hard developer error, so these subtests wire a
- // zero-usage measurement closure.
+ // usage closure is a hard developer error, so these subtests wire
+ // zero-usage measurement closures.
zeroUsageArgs := license.FeatureArguments{
ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
return 0, nil
},
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
}
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
@@ -2337,6 +2625,18 @@ func TestManagedAgentLimitDefault(t *testing.T) {
func TestAgentRuntimeHoursLicenses(t *testing.T) {
t.Parallel()
+ // These cases exercise claim decoding rather than usage accounting, so
+ // they report no runtime. A nil AgentRuntimeMsFn fails the whole
+ // LicensesEntitlements call as a developer error when the feature is
+ // present, so the closure must always be supplied.
+ noRuntime := func() license.FeatureArguments {
+ return license.FeatureArguments{
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
+ }
+ }
+
t.Run("AllClaims", func(t *testing.T) {
t.Parallel()
@@ -2362,7 +2662,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(),
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
@@ -2377,7 +2677,9 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
require.EqualValues(t, 80, *feature.SoftLimit)
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 120, *feature.HardLimit)
- require.Nil(t, feature.Actual)
+ // Actual is populated from usage, which is zero for this license.
+ require.NotNil(t, feature.Actual)
+ require.EqualValues(t, 0, *feature.Actual)
require.NotNil(t, feature.UsagePeriod)
require.WithinDuration(t, licIat, feature.UsagePeriod.IssuedAt, 2*time.Second)
require.WithinDuration(t, licNbf, feature.UsagePeriod.Start, 2*time.Second)
@@ -2420,7 +2722,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
entitlements, err := license.LicensesEntitlements(
context.Background(), now, []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(),
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
@@ -2454,7 +2756,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(),
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
@@ -2488,7 +2790,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(),
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
@@ -2550,7 +2852,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
{lic1, lic2},
{lic2, lic1},
} {
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime())
require.NoError(t, err)
feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
@@ -2610,7 +2912,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
{lic1, lic2},
{lic2, lic1},
} {
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime())
require.NoError(t, err)
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
@@ -2641,7 +2943,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(),
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
@@ -2692,7 +2994,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(),
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
@@ -2924,6 +3226,9 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
context.Background(), time.Now(), []database.License{lic},
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
},
)
require.NoError(t, err)
@@ -2998,6 +3303,9 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
context.Background(), time.Now(), licenses,
map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
},
)
require.NoError(t, err)
diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go
index 811929f0939..d006a5b16d0 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -2,6 +2,7 @@ package coderd_test
import (
"context"
+ "fmt"
"net/http"
"testing"
"time"
@@ -155,6 +156,20 @@ func TestPostLicense(t *testing.T) {
require.NotNil(t, feature.HardLimit)
require.EqualValues(t, 120, *feature.HardLimit)
require.NotNil(t, feature.UsagePeriod)
+ // Actual is read from usage_events, which has no runtime events in
+ // this deployment. It is reported in whole hours, matching the unit
+ // of the claims above.
+ require.NotNil(t, feature.Actual)
+ require.EqualValues(t, 0, *feature.Actual)
+ require.Empty(t, entitlements.Errors)
+ // Zero usage is below both thresholds, so no runtime warning
+ // fires. Unrelated warnings from this bare license are ignored.
+ // The negatives are built from the exported constants so a reword
+ // cannot silently disarm this guard.
+ require.NotContains(t, entitlements.Warnings,
+ fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursSoftLimitWarningText, 0, 100, 80))
+ require.NotContains(t, entitlements.Warnings,
+ fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, 0, 100))
})
t.Run("Unauthorized", func(t *testing.T) {
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 3f7d38b761f..f85d83a9143 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5150,10 +5150,8 @@ export interface Feature {
/**
* SoftLimit is the advisory warning threshold that accompanies Limit for
* features whose license carries it. For these features, Limit carries
- * the purchased allocation.
- *
- * Only certain features set this field:
- * - FeatureAgentRuntimeHours
+ * the purchased allocation. Only FeatureAgentRuntimeHours sets this
+ * field.
*/
readonly soft_limit?: number;
/**
@@ -5162,18 +5160,21 @@ export interface Feature {
* features that use these thresholds.
*/
readonly hard_limit?: number;
+ /**
+ * Actual is the usage measured against Limit, when known: a
+ * point-in-time count for most features, or usage accumulated over
+ * UsagePeriod for features that set one. Its unit matches Limit's;
+ * FeatureAgentRuntimeHours reports whole hours floored from the
+ * recorded milliseconds.
+ */
readonly actual?: number;
/**
* UsagePeriod denotes that the usage is a counter that accumulates over
* this period (and most likely resets with the issuance of the next
- * license).
- *
- * These dates are determined from the license that this entitlement comes
- * from, see enterprise/coderd/license/license.go.
- *
- * Only certain features set these fields:
- * - FeatureManagedAgentLimit
- * - FeatureAgentRuntimeHours
+ * license). These dates are determined from the license that this
+ * entitlement comes from, see enterprise/coderd/license/license.go.
+ * Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this
+ * field.
*/
readonly usage_period?: UsagePeriod;
}
@@ -5722,6 +5723,15 @@ export const LicenseAIGovernance90PercentWarningText =
export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
+// From codersdk/licenses.go
+/**
+ * LicenseAgentRuntimeHoursAllocationReachedWarningText is emitted once
+ * the deployment reaches its runtime hour allocation. Placeholders are
+ * whole hours: used, allocation.
+ */
+export const LicenseAgentRuntimeHoursAllocationReachedWarningText =
+ "Your deployment has used %d of the %d Coder Agent runtime hours included in the current license term.";
+
// From codersdk/licenses.go
/**
* LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
@@ -5733,6 +5743,29 @@ export const LicenseAIGovernanceOverLimitWarningText =
export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
"A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
+// From codersdk/licenses.go
+/**
+ * LicenseAgentRuntimeHoursSoftLimitWarningText is emitted while runtime
+ * usage is at or above the advisory soft limit but within the
+ * allocation. Placeholders are whole hours: used, allocation, soft
+ * limit.
+ *
+ * The dashboard's LicenseBanner matches this text's pre-placeholder
+ * prefix to render it muted and without a sales link, so the license
+ * warning texts must stay pairwise distinct before their first
+ * placeholder. See TestLicenseAgentRuntimeHoursWarningTexts.
+ */
+export const LicenseAgentRuntimeHoursSoftLimitWarningText =
+ "Your deployment is approaching its Coder Agent runtime hours allocation: %d of the %d hours included in the current license term are used, at or above the advisory soft limit of %d hours.";
+
+// From codersdk/licenses.go
+/**
+ * LicenseAgentRuntimeUsageUnavailableErrorText is the Coder Agent
+ * runtime hours sibling of LicenseManagedAgentUsageUnavailableErrorText.
+ */
+export const LicenseAgentRuntimeUsageUnavailableErrorText =
+ "Unable to determine Coder Agent runtime usage. Reported runtime hours are unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
+
// From codersdk/licenses.go
export const LicenseExpiryClaim = "license_expires";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 9ef4dda8bb1..0a37a582d3f 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -1,6 +1,8 @@
import type { FC } from "react";
import {
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
+ LicenseAgentRuntimeHoursSoftLimitWarningText,
+ LicenseAgentRuntimeUsageUnavailableErrorText,
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
@@ -19,6 +21,8 @@ const aiGovernanceOverLimitWarningPrefix =
LicenseAIGovernanceOverLimitWarningText.split("%d")[0];
const aiGovernanceNearLimitWarningPrefix =
LicenseAIGovernance90PercentWarningText.split("%d%%")[0];
+const agentRuntimeSoftLimitWarningPrefix =
+ LicenseAgentRuntimeHoursSoftLimitWarningText.split("%d")[0];
const AI_GOVERNANCE_NEAR_LIMIT_FALLBACK_MESSAGE =
"You are approaching your AI Governance add-on seat limit.";
@@ -27,8 +31,12 @@ const isAIGovernanceWarning = (message: string): boolean =>
message.startsWith(aiGovernanceOverLimitWarningPrefix);
// Substitutes the given values into the template's %d placeholders in order.
-// No other fmt verb, width, or flag is implemented.
-const formatLicenseMessage = (template: string, ...values: number[]): string =>
+// No other fmt verb, width, or flag is implemented. Exported for the
+// stories, so what they pin is what production renders.
+export const formatLicenseMessage = (
+ template: string,
+ ...values: number[]
+): string =>
values.reduce(
(message, value) => message.replace("%d", `${value}`),
template,
@@ -40,6 +48,7 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
const diagnosticMessages: readonly string[] = [
LicenseManagedAgentUsageUnavailableErrorText,
+ LicenseAgentRuntimeUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
];
@@ -48,9 +57,10 @@ const isDiagnosticMessage = (message: string): boolean =>
// Advisories and diagnostics render in the muted variant: nothing is wrong
// yet, so they must be visually distinct from warnings that demand action,
-// such as exceeding a license limit.
+// such as reaching the runtime hours allocation.
const isMutedWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
+ message.startsWith(agentRuntimeSoftLimitWarningPrefix) ||
isDiagnosticMessage(message);
const aiGovernanceOverLimitMessage = (
@@ -144,9 +154,13 @@ const messageLink = (message: string): LicenseBannerLink | undefined => {
showExternalIcon: false,
};
}
- // Diagnostics point the operator at the logs or support, so they do not
- // get a sales link.
- if (isDiagnosticMessage(message)) {
+ // Diagnostics point the operator at the logs or support, and the
+ // soft-limit advisory fires inside the purchased allocation, so neither
+ // gets a sales link.
+ if (
+ isDiagnosticMessage(message) ||
+ message.startsWith(agentRuntimeSoftLimitWarningPrefix)
+ ) {
return undefined;
}
return {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 6c3dd4e662b..76443dc50cd 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -2,7 +2,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import {
type Entitlements,
+ LicenseAgentRuntimeHoursAllocationReachedWarningText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
+ LicenseAgentRuntimeHoursSoftLimitWarningText,
+ LicenseAgentRuntimeUsageUnavailableErrorText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
LicenseManagedAgentUsageUnavailableErrorText,
@@ -17,7 +20,7 @@ import {
} from "#/testHelpers/entities";
import { docs } from "#/utils/docs";
import { DashboardContext, type DashboardValue } from "../DashboardProvider";
-import { LicenseBanner } from "./LicenseBanner";
+import { formatLicenseMessage, LicenseBanner } from "./LicenseBanner";
import { LicenseBannerView } from "./LicenseBannerView";
const meta: Meta = {
@@ -244,6 +247,7 @@ const renderLicenseBannerWithAIGovernance = ({
// Without the data-variant assertions, every story would keep passing with
// the muted/prominent classifier disabled.
const mutedVariant = "warning";
+const prominentVariant = "warningProminent";
export const AIGovernanceNearLimit: Story = {
render: () =>
@@ -296,9 +300,62 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
},
};
+export const AgentRuntimeHoursSoftLimit: Story = {
+ render: () =>
+ renderLicenseBanner({
+ warnings: [
+ formatLicenseMessage(
+ LicenseAgentRuntimeHoursSoftLimitWarningText,
+ 90,
+ 100,
+ 80,
+ ),
+ ],
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(
+ "Your deployment is approaching its Coder Agent runtime hours allocation: 90 of the 100 hours included in the current license term are used, at or above the advisory soft limit of 80 hours.",
+ );
+ // The advisory soft-limit warning renders in the muted variant,
+ // unlike the allocation-reached warning below.
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ // The operator is inside their allocation with nothing owed, so no
+ // sales call-to-action is rendered.
+ await expect(
+ canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
+ ).not.toBeInTheDocument();
+ },
+};
+
+export const AgentRuntimeHoursAllocationReached: Story = {
+ render: () =>
+ renderLicenseBanner({
+ warnings: [
+ formatLicenseMessage(
+ LicenseAgentRuntimeHoursAllocationReachedWarningText,
+ 100,
+ 100,
+ ),
+ ],
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(
+ "Your deployment has used 100 of the 100 Coder Agent runtime hours included in the current license term.",
+ );
+ await expect(banner).toHaveAttribute("data-variant", prominentVariant);
+ await expect(
+ canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
+ ).toHaveAttribute("href", "mailto:sales@coder.com");
+ },
+};
+
// Each entry of the frontend's diagnosticMessages set is pinned on both
// properties the set drives: the muted variant and the suppressed sales
-// link. The "unavailable" message arrives on the errors channel; see the
+// link. The "unavailable" pair arrives on the errors channel; see the
// LicenseManagedAgentUsageUnavailableErrorText doc for why.
const playMutedDiagnostic =
(message: string): Story["play"] =>
@@ -312,6 +369,14 @@ const playMutedDiagnostic =
).not.toBeInTheDocument();
};
+export const AgentRuntimeUsageUnavailable: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseAgentRuntimeUsageUnavailableErrorText],
+ }),
+ play: playMutedDiagnostic(LicenseAgentRuntimeUsageUnavailableErrorText),
+};
+
export const ManagedAgentUsageUnavailable: Story = {
render: () =>
renderLicenseBanner({
@@ -328,12 +393,15 @@ export const AgentRuntimeHoursClaimsIgnored: Story = {
play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText),
};
-// An all-diagnostic banner must not claim license limits were exceeded.
+// An all-diagnostic banner (e.g. one database blip failing both usage
+// queries) must not claim license limits were exceeded.
export const UsageDiagnosticsOnlyHeading: Story = {
render: () =>
renderLicenseBanner({
- errors: [LicenseManagedAgentUsageUnavailableErrorText],
- warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ errors: [
+ LicenseManagedAgentUsageUnavailableErrorText,
+ LicenseAgentRuntimeUsageUnavailableErrorText,
+ ],
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
From b485c5a4395761ed56933887c069ec3996b93564 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 12:04:06 +0000
Subject: [PATCH 005/116] revert: "chore(coderd/notifications): sync formatting
and rendered-template goldens with generators"
This reverts commit ed531d9d365720b4e394f4b422ea9776886c56a3.
The gen/fmt drift it patched over came from main commit 07f79af65b2
(fix: markdown rendering improvements) having landed out of sync with
its generators; main has since reverted that commit entirely in
8c2f7adeb1f (#27979), so the sync is obsolete and conflicts with the
regenerated goldens on latest main.
---
coderd/notifications/dispatch/smtp_internal_test.go | 3 ++-
.../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ++++
.../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++--
.../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++--
.../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++--
5 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 83eb7e88b73..5aee2385c29 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -7,9 +7,10 @@ import (
"github.com/stretchr/testify/require"
+ markdown "github.com/coder/coder/v2/coderd/render"
+
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
- markdown "github.com/coder/coder/v2/coderd/render"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
index f64981add03..550fd9b56fd 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
@@ -17,6 +17,8 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
Effective group: Engineering
+This limit is a per-user override.
+
AI budget period: July 1, 2026 - August 1, 2026
@@ -54,6 +56,8 @@ limit ($1000.00). Subsequent requests will be blocked.
Effective group: Engineering
+This limit is a per-user override.
+
AI budget period: July 1, 2026 - August 1, 2026
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
index 94d00686923..37db6f733cc 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+ Codestin Search App
- You've reached your monthly AI budget limit
+ You've reached your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
index 4d5ffdf4744..3927ab28e31 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+
Codestin Search App
- You're approaching your monthly AI budget limit
+ You're approaching your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
index 1523ba12285..4315def7665 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
@@ -24,6 +24,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
- "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
- "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
+ "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
+ "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
}
\ No newline at end of file
From 109ff262ffcfa0abe27a3cea474b23a01a981773 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 13:12:06 +0000
Subject: [PATCH 006/116] updates for PR review
---
coderd/apidoc/docs.go | 2 +-
coderd/apidoc/swagger.json | 2 +-
codersdk/deployment.go | 4 ++-
docs/reference/api/schemas.md | 18 +++++-----
enterprise/coderd/license/license.go | 4 +--
enterprise/coderd/license/license_test.go | 43 +++++++++++++++++++----
site/src/api/typesGenerated.ts | 4 ++-
7 files changed, 55 insertions(+), 22 deletions(-)
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 769a17ff1ab..781add1362b 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -20644,7 +20644,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"actual": {
- "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds.",
+ "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds. FeatureAgentRuntimeHours usage can trail by\nroughly one hour because the current hour is not emitted, plus the\nentitlement refresh interval.",
"type": "integer"
},
"enabled": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 738600449bc..5a03174e986 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -18778,7 +18778,7 @@
"type": "object",
"properties": {
"actual": {
- "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds.",
+ "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds. FeatureAgentRuntimeHours usage can trail by\nroughly one hour because the current hour is not emitted, plus the\nentitlement refresh interval.",
"type": "integer"
},
"enabled": {
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 2edeb087a8e..95f2708b5ae 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -394,7 +394,9 @@ type Feature struct {
// point-in-time count for most features, or usage accumulated over
// UsagePeriod for features that set one. Its unit matches Limit's;
// FeatureAgentRuntimeHours reports whole hours floored from the
- // recorded milliseconds.
+ // recorded milliseconds. FeatureAgentRuntimeHours usage can trail by
+ // roughly one hour because the current hour is not emitted, plus the
+ // entitlement refresh interval.
Actual *int64 `json:"actual,omitempty"`
// Below is only for features that use usage periods.
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 105cdd7d6c7..f0541373c1f 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -7795,15 +7795,15 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
### Properties
-| Name | Type | Required | Restrictions | Description |
-|----------------|----------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `actual` | integer | false | | Actual is the usage measured against Limit, when known: a point-in-time count for most features, or usage accumulated over UsagePeriod for features that set one. Its unit matches Limit's; FeatureAgentRuntimeHours reports whole hours floored from the recorded milliseconds. |
-| `enabled` | boolean | false | | |
-| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | |
-| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. |
-| `limit` | integer | false | | |
-| `soft_limit` | integer | false | | Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation. Only FeatureAgentRuntimeHours sets this field. |
-| `usage_period` | [codersdk.UsagePeriod](#codersdkusageperiod) | false | | Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this field. |
+| Name | Type | Required | Restrictions | Description |
+|----------------|----------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `actual` | integer | false | | Actual is the usage measured against Limit, when known: a point-in-time count for most features, or usage accumulated over UsagePeriod for features that set one. Its unit matches Limit's; FeatureAgentRuntimeHours reports whole hours floored from the recorded milliseconds. FeatureAgentRuntimeHours usage can trail by roughly one hour because the current hour is not emitted, plus the entitlement refresh interval. |
+| `enabled` | boolean | false | | |
+| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | |
+| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. |
+| `limit` | integer | false | | |
+| `soft_limit` | integer | false | | Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation. Only FeatureAgentRuntimeHours sets this field. |
+| `usage_period` | [codersdk.UsagePeriod](#codersdkusageperiod) | false | | Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this field. |
## codersdk.FriendlyDiagnostic
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index b238e514d36..cf2b19fa9f1 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -1001,7 +1001,7 @@ const (
// grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // becomes the feature's SoftLimit when 0 <= soft < allocation and is
// ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
// ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
@@ -1097,7 +1097,7 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
UsagePeriod: &usagePeriod,
}
if softOk {
- if soft > 0 && soft < allocation {
+ if soft >= 0 && soft < allocation {
feature.SoftLimit = &soft
} else {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 0d9e38198db..4f41a3bf006 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -1459,6 +1459,11 @@ func TestLicenseEntitlements(t *testing.T) {
}
}
+ // Captured by AgentRuntimeHours/UsagePeriodBounds. Only that case
+ // reads or writes these, so parallel siblings cannot race them.
+ var agentRuntimeUsageQueryFrom, agentRuntimeUsageQueryTo time.Time
+ var agentRuntimeUsageQueryCalled bool
+
premiumLicense := func() *coderdenttest.LicenseOptions {
return (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
@@ -1739,6 +1744,31 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
+ {
+ // hoursToMsFn discards the period bounds, so a swapped or wrong
+ // license period would still pass the other cases. Capture the
+ // arguments here and require they match the feature's UsagePeriod.
+ Name: "AgentRuntimeHours/UsagePeriodBounds",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: func(_ context.Context, from, to time.Time) (int64, error) {
+ agentRuntimeUsageQueryFrom = from
+ agentRuntimeUsageQueryTo = to
+ agentRuntimeUsageQueryCalled = true
+ return 0, nil
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ require.True(t, agentRuntimeUsageQueryCalled)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.NotNil(t, feature.UsagePeriod)
+ assert.Equal(t, feature.UsagePeriod.Start, agentRuntimeUsageQueryFrom)
+ assert.Equal(t, feature.UsagePeriod.End, agentRuntimeUsageQueryTo)
+ },
+ },
{
// The soft warning end to end: the remaining threshold
// arithmetic is pinned by TestAppendAgentRuntimeHoursWarning.
@@ -3052,20 +3082,19 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
},
},
{
- // A zero soft limit would warn at zero usage forever, so it is
- // dropped rather than rejecting the license. The canonical way
- // to express "no soft limit" is omitting the claim, so a
- // present-but-dropped zero still warns.
+ // A zero soft limit is valid (0 <= soft < allocation) and warns
+ // from the start of the usage period. Omitting the claim is the
+ // way to express "no soft limit".
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
expectFeature: &codersdk.Feature{
- Enabled: true,
- Limit: ptr.Ref[int64](100),
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](0),
},
- expectClaimsIgnored: true,
},
{
name: "NegativeSoft",
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index f85d83a9143..00a3bdc848b 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5165,7 +5165,9 @@ export interface Feature {
* point-in-time count for most features, or usage accumulated over
* UsagePeriod for features that set one. Its unit matches Limit's;
* FeatureAgentRuntimeHours reports whole hours floored from the
- * recorded milliseconds.
+ * recorded milliseconds. FeatureAgentRuntimeHours usage can trail by
+ * roughly one hour because the current hour is not emitted, plus the
+ * entitlement refresh interval.
*/
readonly actual?: number;
/**
From e679d63722fdb8fc43a8bd83dff04787067831e9 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 13:21:33 +0000
Subject: [PATCH 007/116] updates for PR review
---
.../000565_usage_events_agent_runtime_invariants.down.sql | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
index ed8b4ae78b8..8a6a0240988 100644
--- a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
@@ -1,5 +1,6 @@
--- IF EXISTS matches 000561's down migration, so rolling back out of order
--- (e.g. during an incident) is a no-op instead of a failure.
+-- IF EXISTS matches 000561's down migration: if the index is already gone
+-- (e.g. rolling back out of order during an incident), tolerate the miss
+-- instead of failing.
DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
CREATE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
From 8f661c1130151ac3b5c10f5fce61e89ff4c1d630 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 05:47:20 +0000
Subject: [PATCH 008/116] feat(enterprise/coderd/license): decode -1 runtime
hours allocation as unlimited
An agent_runtime_hours_allocation claim of exactly -1 now grants the
agent_runtime_hours feature enabled with a nil Limit, which the
entitlements API serves as an omitted "limit" field, the shape the UI
already renders as Unlimited. Threshold claims alongside it are dropped
with the existing claims-ignored warning, and any other negative
allocation remains unusable.
---
enterprise/coderd/license/license.go | 36 ++++++++-
enterprise/coderd/license/license_test.go | 90 ++++++++++++++++++++++-
2 files changed, 122 insertions(+), 4 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index e6f3598475e..8911f695d3d 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -932,9 +932,10 @@ const (
// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit. A
- // negative allocation is ignored, in which case the license does not
- // grant the feature.
+ // allocation for the license term. It becomes the feature's Limit.
+ // AgentRuntimeHoursUnlimitedAllocation (-1) is reserved to mean
+ // unlimited; any other negative allocation is ignored, in which case
+ // the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
// becomes the feature's SoftLimit when 0 < soft < allocation and is
@@ -946,6 +947,12 @@ const (
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
+// AgentRuntimeHoursUnlimitedAllocation is the reserved
+// ClaimAgentRuntimeHoursAllocation value meaning the license grants
+// unlimited runtime hours. It decodes to an enabled feature with a nil
+// Limit. Mirrored in github.com/coder/license.
+const AgentRuntimeHoursUnlimitedAllocation int64 = -1
+
var (
ValidMethods = []string{"EdDSA"}
@@ -991,6 +998,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// claims, but Actual is still measured and published. CODAGT-856 will make a
// zero allocation force a concurrency-limited mode; that mode does not exist
// yet.
+//
+// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
+// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
+// have nothing to threshold against, so they are dropped with the warning,
+// keeping an incorrectly issued license visible. Note that
+// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
+// issued-at and expiry tie a metered license outranks an unlimited one; ties
+// never happen for separately issued licenses, so this edge is documented
+// rather than special-cased.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
@@ -1000,6 +1016,20 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+ if allocOk && allocation == AgentRuntimeHoursUnlimitedAllocation {
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{
+ Enabled: true,
+ Entitlement: entitlement,
+ UsagePeriod: &usagePeriod,
+ }, true, ignoredClaims
+ }
+
if !allocOk || allocation < 0 {
if allocOk && allocation < 0 {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index db68fb8b60a..0e393939c5d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2501,6 +2501,55 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
require.NotNil(t, feature.UsagePeriod)
})
+ // An unlimited (-1) allocation grants the feature enabled with no Limit,
+ // which the API serves as an omitted "limit" field, the shape the UI
+ // already renders as "Unlimited".
+ t.Run("UnlimitedAllocation", func(t *testing.T) {
+ t.Parallel()
+
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ )
+ require.NoError(t, err)
+ require.Empty(t, entitlements.Errors)
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+
+ // The entitlements JSON served by GET /api/v2/entitlements omits
+ // "limit" entirely for the unlimited feature.
+ data, err := json.Marshal(entitlements)
+ require.NoError(t, err)
+ var raw struct {
+ Features map[codersdk.FeatureName]map[string]any `json:"features"`
+ }
+ require.NoError(t, json.Unmarshal(data, &raw))
+ rawFeature := raw.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, true, rawFeature["enabled"])
+ require.NotContains(t, rawFeature, "limit")
+ require.Contains(t, rawFeature, "usage_period")
+ })
+
// The license with the newest issued-at claim wins, even if another
// license was loaded first or has a larger allocation. The soft and hard
// limits come from the winning license.
@@ -2853,9 +2902,48 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
expectClaimsIgnored: true,
},
{
+ // An unlimited allocation grants the feature with no Limit and
+ // no warning: -1 is the canonical unlimited encoding, not an
+ // issuance mistake.
+ name: "UnlimitedAllocation",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ },
+ {
+ // Threshold claims alongside an unlimited allocation have
+ // nothing to threshold against; the grant survives but the
+ // issuance mistake must stay visible via the warning.
+ name: "UnlimitedWithSoft",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ name: "UnlimitedWithHard",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ // Only exactly -1 is the unlimited sentinel; any other negative
+ // allocation stays unusable.
name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -2,
},
expectClaimsIgnored: true,
},
From c865ff2d3a02e30925005edf83843dcba2d90c55 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 05:53:59 +0000
Subject: [PATCH 009/116] feat: support unlimited agent runtime hours in
entitlements
An unlimited (-1) allocation decodes to an enabled agent_runtime_hours
feature with a nil Limit, so the warning write-back now guards the
allocation dereference: no thresholds can exist for an unlimited
license, so no runtime hours warning is ever emitted. Actual usage is
still measured and published. Feature.Compare is unchanged; the
metered-beats-unlimited-on-exact-tie edge is pinned by a test and
documented on decodeAgentRuntimeHours.
---
coderd/apidoc/docs.go | 3 +-
coderd/apidoc/swagger.json | 3 +-
codersdk/deployment.go | 10 +++--
codersdk/deployment_test.go | 29 +++++++++++++
docs/reference/api/schemas.md | 4 +-
enterprise/coderd/license/license.go | 10 +++--
enterprise/coderd/license/license_test.go | 53 ++++++++++++++++++++++-
site/src/api/typesGenerated.ts | 10 ++++-
8 files changed, 108 insertions(+), 14 deletions(-)
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 781add1362b..140d2981b16 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -20658,10 +20658,11 @@ const docTemplate = `{
"type": "integer"
},
"limit": {
+ "description": "Limit is the maximum value the license grants for the feature, in the\nfeature's own unit. For FeatureAgentRuntimeHours, an enabled feature\nwith Limit omitted means the license grants unlimited runtime hours.",
"type": "integer"
},
"soft_limit": {
- "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation. Only FeatureAgentRuntimeHours sets this\nfield.",
+ "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation; an unlimited allocation has no thresholds,\nso SoftLimit is omitted alongside the omitted Limit. Only\nFeatureAgentRuntimeHours sets this field.",
"type": "integer"
},
"usage_period": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 5a03174e986..3304101a10b 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -18792,10 +18792,11 @@
"type": "integer"
},
"limit": {
+ "description": "Limit is the maximum value the license grants for the feature, in the\nfeature's own unit. For FeatureAgentRuntimeHours, an enabled feature\nwith Limit omitted means the license grants unlimited runtime hours.",
"type": "integer"
},
"soft_limit": {
- "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation. Only FeatureAgentRuntimeHours sets this\nfield.",
+ "description": "SoftLimit is the advisory warning threshold that accompanies Limit for\nfeatures whose license carries it. For these features, Limit carries\nthe purchased allocation; an unlimited allocation has no thresholds,\nso SoftLimit is omitted alongside the omitted Limit. Only\nFeatureAgentRuntimeHours sets this field.",
"type": "integer"
},
"usage_period": {
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 95f2708b5ae..5d3604af944 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -380,11 +380,15 @@ func (set FeatureSet) Features() []FeatureName {
type Feature struct {
Entitlement Entitlement `json:"entitlement"`
Enabled bool `json:"enabled"`
- Limit *int64 `json:"limit,omitempty"`
+ // Limit is the maximum value the license grants for the feature, in the
+ // feature's own unit. For FeatureAgentRuntimeHours, an enabled feature
+ // with Limit omitted means the license grants unlimited runtime hours.
+ Limit *int64 `json:"limit,omitempty"`
// SoftLimit is the advisory warning threshold that accompanies Limit for
// features whose license carries it. For these features, Limit carries
- // the purchased allocation. Only FeatureAgentRuntimeHours sets this
- // field.
+ // the purchased allocation; an unlimited allocation has no thresholds,
+ // so SoftLimit is omitted alongside the omitted Limit. Only
+ // FeatureAgentRuntimeHours sets this field.
SoftLimit *int64 `json:"soft_limit,omitempty"`
// HardLimit is the enforcement threshold that accompanies Limit for
// features whose license carries it. See SoftLimit for the set of
diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go
index de3395aac84..eb0743804e5 100644
--- a/codersdk/deployment_test.go
+++ b/codersdk/deployment_test.go
@@ -1199,6 +1199,35 @@ func TestFeatureComparison(t *testing.T) {
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100))},
Expected: 0,
},
+ {
+ // An unlimited agent runtime hours feature (nil Limit) loses to
+ // a metered one when both licenses share the exact same
+ // issued-at and end dates: rule 5 ranks a nil Limit lower.
+ // Separately issued licenses never tie on both timestamps, so
+ // the edge is pinned here instead of special-casing Compare.
+ Name: "MeteredBeatsUnlimitedOnExactTie",
+ A: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ Limit: ptr.Ref(int64(100)),
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ B: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ Limit: nil,
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ Expected: 1,
+ },
{
Name: "NewerIssuedAtWinsOverSoftHardLimits",
A: codersdk.Feature{
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index f0541373c1f..4ad9305da59 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -7801,8 +7801,8 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
| `enabled` | boolean | false | | |
| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | |
| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. |
-| `limit` | integer | false | | |
-| `soft_limit` | integer | false | | Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation. Only FeatureAgentRuntimeHours sets this field. |
+| `limit` | integer | false | | Limit is the maximum value the license grants for the feature, in the feature's own unit. For FeatureAgentRuntimeHours, an enabled feature with Limit omitted means the license grants unlimited runtime hours. |
+| `soft_limit` | integer | false | | Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation; an unlimited allocation has no thresholds, so SoftLimit is omitted alongside the omitted Limit. Only FeatureAgentRuntimeHours sets this field. |
| `usage_period` | [codersdk.UsagePeriod](#codersdkusageperiod) | false | | Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this field. |
## codersdk.FriendlyDiagnostic
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 445a84978bc..ccf6d038f6c 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -769,10 +769,12 @@ func LicensesEntitlements(
// the managed-agent write-back above for why.
entitlements.Features[codersdk.FeatureAgentRuntimeHours] = runtimeHours
- // The allocation is dereferenced without a nil check because
- // decodeAgentRuntimeHours always sets Limit for this feature.
- entitlements.Warnings = appendAgentRuntimeHoursWarning(
- entitlements.Warnings, actualHours, *runtimeHours.Limit, runtimeHours.SoftLimit)
+ // A nil Limit means the license grants unlimited runtime
+ // hours: no thresholds can exist, so no warnings.
+ if runtimeHours.Limit != nil {
+ entitlements.Warnings = appendAgentRuntimeHoursWarning(
+ entitlements.Warnings, actualHours, *runtimeHours.Limit, runtimeHours.SoftLimit)
+ }
}
}
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 18bc98eb26c..fb2a26c5079 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -1012,6 +1012,57 @@ func TestEntitlements(t *testing.T) {
entitlements.Warnings[0])
})
+ // An unlimited (-1) allocation still measures and publishes Actual, but
+ // never emits a runtime hours warning regardless of usage.
+ t.Run("AgentRuntimeHoursUnlimited", func(t *testing.T) {
+ t.Parallel()
+
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+
+ licenseOpts := (&coderdenttest.LicenseOptions{
+ FeatureSet: codersdk.FeatureSetPremium,
+ IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
+ NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
+ GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
+ ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
+ }).UserLimit(100).AIGovernanceAddon(100).
+ AgentRuntimeHours(license.AgentRuntimeHoursUnlimitedAllocation, nil, nil)
+
+ lic := database.License{
+ ID: 1,
+ JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
+ Exp: licenseOpts.ExpiresAt,
+ }
+
+ mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
+ mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
+ mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
+ mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
+ mDB.EXPECT().GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).Return(int64(0), nil)
+ mDB.EXPECT().
+ GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()).
+ // Usage far beyond any plausible metered allocation.
+ Return((1_000_000 * time.Hour).Milliseconds(), nil)
+
+ entitlements, err := license.Entitlements(context.Background(), testutil.Logger(t), mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+ require.Empty(t, entitlements.Errors)
+
+ runtimeHours, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.True(t, ok)
+ require.True(t, runtimeHours.Enabled)
+ require.Nil(t, runtimeHours.Limit)
+ require.Nil(t, runtimeHours.SoftLimit)
+ require.Nil(t, runtimeHours.HardLimit)
+ require.NotNil(t, runtimeHours.UsagePeriod)
+ require.NotNil(t, runtimeHours.Actual)
+ require.EqualValues(t, 1_000_000, *runtimeHours.Actual)
+
+ require.Empty(t, entitlements.Warnings)
+ })
+
t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
t.Parallel()
@@ -2853,7 +2904,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
entitlements, err := license.LicensesEntitlements(
context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime(),
)
require.NoError(t, err)
require.Empty(t, entitlements.Errors)
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 00a3bdc848b..52e89873483 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5146,12 +5146,18 @@ export interface ExternalAuthUser {
export interface Feature {
readonly entitlement: Entitlement;
readonly enabled: boolean;
+ /**
+ * Limit is the maximum value the license grants for the feature, in the
+ * feature's own unit. For FeatureAgentRuntimeHours, an enabled feature
+ * with Limit omitted means the license grants unlimited runtime hours.
+ */
readonly limit?: number;
/**
* SoftLimit is the advisory warning threshold that accompanies Limit for
* features whose license carries it. For these features, Limit carries
- * the purchased allocation. Only FeatureAgentRuntimeHours sets this
- * field.
+ * the purchased allocation; an unlimited allocation has no thresholds,
+ * so SoftLimit is omitted alongside the omitted Limit. Only
+ * FeatureAgentRuntimeHours sets this field.
*/
readonly soft_limit?: number;
/**
From 00d3249ecfbf3a12cd5a8880c1c08bce1634a41a Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:07:05 +0000
Subject: [PATCH 010/116] chore: remove data-variant
---
.../LicenseBannerView.stories.tsx | 21 +++++++------------
.../LicenseBanner/LicenseBannerView.tsx | 3 ---
2 files changed, 8 insertions(+), 16 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 6c3dd4e662b..4f2030a3080 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -59,6 +59,10 @@ export const TwoWarnings: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
+ await expect(
+ canvas.getByText("Your license limits have been exceeded"),
+ ).toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: "Show more" }),
).not.toBeInTheDocument();
@@ -241,10 +245,6 @@ const renderLicenseBannerWithAIGovernance = ({
},
});
-// Without the data-variant assertions, every story would keep passing with
-// the muted/prominent classifier disabled.
-const mutedVariant = "warning";
-
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -258,9 +258,6 @@ export const AIGovernanceNearLimit: Story = {
await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
- // Pins the AI Governance near-limit branch of isMutedWarning,
- // independently of the runtime soft-limit branch below.
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -296,17 +293,16 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
},
};
-// Each entry of the frontend's diagnosticMessages set is pinned on both
-// properties the set drives: the muted variant and the suppressed sales
+// Each diagnostic pins role=status (not alert) and a suppressed sales
// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
+// mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
const canvas = within(canvasElement);
const banner = canvas.getByRole("status");
await expect(banner).toHaveTextContent(message);
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
).not.toBeInTheDocument();
@@ -337,8 +333,7 @@ export const UsageDiagnosticsOnlyHeading: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const banner = canvas.getByRole("status");
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(canvas.getByText("License notices")).toBeInTheDocument();
await expect(
canvas.queryByText("Your license limits have been exceeded"),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index bd278dc591c..453b925fe6d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -151,9 +151,6 @@ export const LicenseBannerView: React.FC = ({
return (
From 3d8940c9f6c2204e98d48d77e3b4b57b62f306a3 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:16:52 +0000
Subject: [PATCH 011/116] chore(coderd/database): restore original
InsertUsageEvent comment
The expanded arbiter-scoping commentary is not needed on the insert
query; the duplicate-bucket behavior is documented on the migration and
in generateBucket where it is handled.
---
coderd/database/querier.go | 8 ++------
coderd/database/queries.sql.go | 8 ++------
coderd/database/queries/usageevents.sql | 8 ++------
3 files changed, 6 insertions(+), 18 deletions(-)
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 05147dad109..882e4cb9d58 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -1168,12 +1168,8 @@ type sqlcQuerier interface {
InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error
InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error)
InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error)
- // Duplicate events are ignored intentionally to allow for multiple replicas
- // to publish heartbeat events. The (id) arbiter scopes that tolerance to
- // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
- // bucket under a different id raises on idx_usage_events_agent_runtime
- // instead, which generateBucket in enterprise/coderd/usage/generator.go
- // handles.
+ // Duplicate events are ignored intentionally to allow for multiple replicas to
+ // publish heartbeat events.
InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
// InsertUserGroupsByID adds a user to all provided groups, if they exist.
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 20b5f33bea5..e499234f558 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28462,12 +28462,8 @@ type InsertUsageEventParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
-// Duplicate events are ignored intentionally to allow for multiple replicas
-// to publish heartbeat events. The (id) arbiter scopes that tolerance to
-// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
-// bucket under a different id raises on idx_usage_events_agent_runtime
-// instead, which generateBucket in enterprise/coderd/usage/generator.go
-// handles.
+// Duplicate events are ignored intentionally to allow for multiple replicas to
+// publish heartbeat events.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 03f435c5af7..8ba706b0c8a 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -1,10 +1,6 @@
-- name: InsertUsageEvent :exec
--- Duplicate events are ignored intentionally to allow for multiple replicas
--- to publish heartbeat events. The (id) arbiter scopes that tolerance to
--- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
--- bucket under a different id raises on idx_usage_events_agent_runtime
--- instead, which generateBucket in enterprise/coderd/usage/generator.go
--- handles.
+-- Duplicate events are ignored intentionally to allow for multiple replicas to
+-- publish heartbeat events.
INSERT INTO
usage_events (
id,
From 8013af1b757d4859feeade03c87c84b4549aa52a Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:42:51 +0000
Subject: [PATCH 012/116] chore: remove and simplify explanatory comments
---
codersdk/licenses.go | 23 ++------
.../coderd/coderdenttest/coderdenttest.go | 4 --
enterprise/coderd/license/license.go | 57 +++++++------------
enterprise/coderd/license/license_test.go | 6 +-
site/src/api/typesGenerated.ts | 15 -----
.../dashboard/LicenseBanner/LicenseBanner.tsx | 8 +--
.../LicenseBannerView.stories.tsx | 4 +-
.../LicenseBanner/LicenseBannerView.tsx | 5 +-
8 files changed, 33 insertions(+), 89 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 9cb0dbc88b1..ee891740e29 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -12,23 +12,12 @@ import (
)
const (
- LicenseExpiryClaim = "license_expires"
- LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
- LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
- LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
- LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- // managed agent usage query fails while computing entitlements; the
- // cause is logged server-side. It travels in the entitlements Errors
- // channel so the alertable coderd_license_errors gauge counts
- // measurement failures, but the dashboard recognizes the exact text and
- // renders it as a muted diagnostic rather than a license error.
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
- // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- // license carries unusable Coder Agent runtime hour claims (see
- // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- // the license and the dropped claims. The dashboard renders the exact
- // text as a muted diagnostic without a sales link.
+ LicenseExpiryClaim = "license_expires"
+ LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
+ LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
+ LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
+ LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 8f5f1e4a708..1d2678fd787 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,10 +237,6 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
-// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
-// or hardLimit omits that claim, which a license is allowed to do; a non-nil
-// value is emitted verbatim, so tests can express explicit zero or negative
-// claims.
func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
if softLimit != nil {
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8911f695d3d..66b55f1feea 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -130,8 +130,6 @@ func Entitlements(
}
type FeatureArguments struct {
- // Logger receives the causes behind operator-facing diagnostics whose
- // published message is a stable text. The zero value discards them.
Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
@@ -587,8 +585,6 @@ func LicensesEntitlements(
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
}
if len(ignoredClaims) > 0 {
- // The published warning is a stable text, so the details a
- // support case needs go to the log.
featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
slog.F("license_id", license.UUID),
slog.F("ignored_claims", ignoredClaims),
@@ -721,9 +717,8 @@ func LicensesEntitlements(
}
if ok {
agentLimit.Actual = &managedAgentCount
- // Written back directly: the feature contest is already
- // settled, so AddFeature's Compare must not get a chance to
- // drop the write.
+ // Write directly rather than via AddFeature so its Compare
+ // cannot drop the update.
entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
@@ -863,12 +858,10 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs one usage query over the feature's usage period and owns
-// the shared failure policy: a nil fn is a wiring bug and fails the whole
-// LicensesEntitlements call; a failure with a dead context fails the call
-// without logging; any other failure logs the cause and publishes the stable
-// unavailableText instead. It returns the measured value and true only on
-// success.
+// measureUsage runs fn over the feature's usage period. A nil fn or a
+// failure with a dead context fails the whole call; any other failure logs
+// the cause and publishes unavailableText instead. It returns the measured
+// value and true only on success.
func measureUsage(
ctx context.Context,
entitlements *codersdk.Entitlements,
@@ -884,12 +877,11 @@ func measureUsage(
value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
switch {
case err != nil && ctx.Err() != nil:
- // The computation's own context is dead, so abort the whole call
- // without logging. Do not classify by error shape instead: Postgres
- // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
- // as well as client cancels, and aborting on those would fail every
- // entitlements refresh on a deployment whose statement_timeout is
- // shorter than a usage query.
+ // Do not classify cancellation by error shape instead of ctx.Err():
+ // Postgres raises SQLSTATE 57014 (query_canceled) for
+ // statement_timeout kills as well as client cancels, and aborting on
+ // those would fail every entitlements refresh on a deployment whose
+ // statement_timeout is shorter than a usage query.
return 0, false, xerrors.Errorf("get %s: %w", what, err)
case err != nil:
logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
@@ -987,26 +979,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// allocation claim; per-claim validity rules live on the Claim* constants
// above.
//
-// Unusable claims are dropped, never license-invalidating: rejecting a
-// signed license over a cosmetic threshold claim would drop the deployment
-// to unlicensed. ignoredClaims names each dropped claim (including the
-// feature name itself minted as a claim, the most plausible issuer mistake)
-// so the caller can warn and log instead of letting an incorrectly issued
-// license look healthy.
+// Unusable claims are dropped rather than invalidating the license, since
+// rejecting a signed license over a cosmetic claim would drop the deployment
+// to unlicensed. Each dropped claim is returned in ignoredClaims so the
+// caller can warn and log instead of letting an incorrectly issued license
+// look healthy.
//
-// A zero allocation grants the feature disabled and drops both threshold
-// claims, but Actual is still measured and published. CODAGT-856 will make a
-// zero allocation force a concurrency-limited mode; that mode does not exist
-// yet.
-//
-// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
-// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
-// have nothing to threshold against, so they are dropped with the warning,
-// keeping an incorrectly issued license visible. Note that
-// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
-// issued-at and expiry tie a metered license outranks an unlimited one; ties
-// never happen for separately issued licenses, so this edge is documented
-// rather than special-cased.
+// A zero allocation grants the feature disabled, but Actual is still
+// measured and published. CODAGT-856 will make a zero allocation force a
+// concurrency-limited mode; that mode does not exist yet.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 0e393939c5d..ec8e06b2f8d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -986,8 +986,7 @@ func TestEntitlements(t *testing.T) {
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
- // The failure surfaces its stable text without the raw cause,
- // on the channel the codersdk constant docs prescribe.
+ // The failure surfaces its stable text without the raw cause.
require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
require.NotContains(t, entry, "kaboom")
@@ -1657,8 +1656,7 @@ func TestLicenseEntitlements(t *testing.T) {
},
},
{
- // A query failure is surfaced as a stable text in Errors (see
- // the codersdk constant docs for the channel choice) and
+ // A query failure is surfaced as a stable text in Errors and
// leaves Actual unset without aborting the rest of the
// entitlements.
Name: "ManagedAgentLimit/QueryError",
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 3f7d38b761f..59e05c93514 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5723,13 +5723,6 @@ export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
// From codersdk/licenses.go
-/**
- * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- * license carries unusable Coder Agent runtime hour claims (see
- * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- * the license and the dropped claims. The dashboard renders the exact
- * text as a muted diagnostic without a sales link.
- */
export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
"A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
@@ -5741,14 +5734,6 @@ export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
// From codersdk/licenses.go
-/**
- * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- * managed agent usage query fails while computing entitlements; the
- * cause is logged server-side. It travels in the entitlements Errors
- * channel so the alertable coderd_license_errors gauge counts
- * measurement failures, but the dashboard recognizes the exact text and
- * renders it as a muted diagnostic rather than a license error.
- */
export const LicenseManagedAgentUsageUnavailableErrorText =
"Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 9ef4dda8bb1..74ce21b89fa 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -36,8 +36,7 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// Diagnostics about the license or the usage measurement rather than about
// usage itself. They render muted, without the exceedance heading or a sales
-// link. The "unavailable" pair arrives via entitlements.errors but must not
-// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
@@ -46,9 +45,8 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render in the muted variant: nothing is wrong
-// yet, so they must be visually distinct from warnings that demand action,
-// such as exceeding a license limit.
+// Advisories and diagnostics render muted to stay visually distinct from
+// warnings that demand action, such as exceeding a license limit.
const isMutedWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
isDiagnosticMessage(message);
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 4f2030a3080..7c2e67c9a71 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -294,9 +294,7 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
};
// Each diagnostic pins role=status (not alert) and a suppressed sales
-// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
-// mutedness is covered by the visual snapshot.
+// link. Background mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 453b925fe6d..2cef6164395 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,9 +70,8 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-// The muted variant only wins when every message is muted (see
-// getBannerVariant), which means advisories and diagnostics: nothing has
-// been exceeded, so the heading must not assert exceedance.
+// The muted "warning" variant means every message is an advisory or
+// diagnostic, so the heading must not assert exceedance.
const bannerTitle = (variant: LicenseBannerVariant): string => {
switch (variant) {
case "error":
From 68b02956361ec553037c1a3bb2d8c4da53fce6db Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:46:36 +0000
Subject: [PATCH 013/116] fix(coderd/database): renumber agent runtime
invariants migration to 000567
Main landed its own 000565 and 000566 (oauth2 client type constraint and
auth method backfill) after this branch was cut, making the migration
number a duplicate that fails migrate setup.
---
....sql => 000567_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.down.sql => 000567_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
From 8d5606b79f6235257f16e5fa07f5f4caf22110e2 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 07:12:28 +0000
Subject: [PATCH 014/116] chore(enterprise/coderd/usage): restore agent runtime
requeue guidance comment
---
enterprise/coderd/usage/generator.go | 24 ++++++++++++++++++------
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index a5b6788a214..af7fcd713ee 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -158,12 +158,24 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error {
return xerrors.Errorf("list existing agent runtime events: %w", err)
}
// A row marks its bucket complete regardless of publish outcome, so a
- // bucket whose event Tallyman permanently rejected is never regenerated
- // (re-inserting under the deterministic ID is a no-op via the insert's
- // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row
- // keeps it, and clearing the row's publish columns re-queues it while
- // the bucket is within SelectUsageEventsForPublishing's 30-day
- // created_at cutoff.
+ // bucket whose event Tallyman permanently rejected is never
+ // regenerated (re-inserting under the deterministic ID is a no-op via
+ // the insert's ON CONFLICT (id) arbiter).
+ //
+ // The runtime is not lost locally: the row still holds it, and the
+ // event can be re-queued for publishing with
+ //
+ // UPDATE usage_events
+ // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL
+ // WHERE id = 'hb_agent_runtime_v1:';
+ //
+ // That re-arm only has an effect while the bucket is inside the
+ // publisher's 30-day cutoff: SelectUsageEventsForPublishing also
+ // filters created_at > now - INTERVAL '30 days', and created_at is the
+ // bucket start, so past that the UPDATE reports success but the row is
+ // never picked up again. The release gate (Tallyman must accept this
+ // event type before coderd ships it) is what keeps permanent
+ // rejections exceptional.
existing := make(map[time.Time]struct{}, len(existingTimes))
for _, ts := range existingTimes {
// created_at is always the exact bucket start for this event type;
From 27584cd4d993b973a0b0d53850788db1554991f6 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 08:08:12 +0000
Subject: [PATCH 015/116] chore(site/src/modules/dashboard/LicenseBanner):
remove comments narrating story assertions
---
.../dashboard/LicenseBanner/LicenseBannerView.stories.tsx | 5 -----
1 file changed, 5 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 241945c7c1c..5c7fd4d67d7 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -314,9 +314,6 @@ export const AgentRuntimeHoursSoftLimit: Story = {
await expect(banner).toHaveTextContent(
"Your deployment is approaching its Coder Agent runtime hours allocation: 90 of the 100 hours included in the current license term are used, at or above the advisory soft limit of 80 hours.",
);
- // The operator is inside their allocation with nothing owed, so no
- // sales call-to-action is rendered. The advisory renders in the
- // muted variant, which the visual snapshot covers.
await expect(
canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
).not.toBeInTheDocument();
@@ -346,8 +343,6 @@ export const AgentRuntimeHoursAllocationReached: Story = {
},
};
-// Each diagnostic pins role=status (not alert) and a suppressed sales
-// link. Background mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
From f7447af48293a45895b21f794bffe04eb1a110e5 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 09:40:53 +0000
Subject: [PATCH 016/116] fix: make unlimited usage period features outrank
metered ones on ties
A nil Limit on a usage period feature means unlimited, so Feature.Compare now ranks it above any set limit when issued-at and end dates tie. Also types the LicenseBanner map callbacks as LicenseBannerMessage instead of using as const assertions.
---
codersdk/deployment.go | 12 +++-
codersdk/deployment_test.go | 25 ++++++++
enterprise/coderd/license/license_test.go | 60 +++++++++++++++++++
.../dashboard/LicenseBanner/LicenseBanner.tsx | 32 +++++-----
4 files changed, 111 insertions(+), 18 deletions(-)
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 14eca1624ec..7f22eaf2512 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -425,7 +425,7 @@ type UsagePeriod struct {
// 2. The usage period has a greater end date (note: only certain features use usage periods)
// 3. Graceful & capable > Entitled & not capable (only if both have "Actual" values)
// 4. The entitlement is greater
-// 5. The limit is greater
+// 5. The limit is greater (except a nil limit on a usage period feature means unlimited, outranking any set limit)
// 6. Enabled is greater than disabled
// 7. The actual is greater
//
@@ -469,11 +469,19 @@ func (f Feature) Compare(b Feature) int {
return entitlementDifference
}
- // If the entitlement is the same, then we can compare the limits.
+ // If the entitlement is the same, then we can compare the limits. A nil
+ // limit on a usage period feature means unlimited, so it outranks any set
+ // limit; on other features a nil limit loses to a set one.
if f.Limit == nil && b.Limit != nil {
+ if bothHaveUsagePeriod {
+ return 1
+ }
return -1
}
if f.Limit != nil && b.Limit == nil {
+ if bothHaveUsagePeriod {
+ return -1
+ }
return 1
}
if f.Limit != nil && b.Limit != nil {
diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go
index de3395aac84..2abdff578e9 100644
--- a/codersdk/deployment_test.go
+++ b/codersdk/deployment_test.go
@@ -1225,6 +1225,31 @@ func TestFeatureComparison(t *testing.T) {
},
Expected: 1,
},
+ {
+ // A nil limit on a usage period feature means unlimited, so it
+ // outranks a set limit on an exact usage period tie.
+ Name: "UnlimitedUsagePeriodOutranksMeteredOnTie",
+ A: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ B: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ Limit: ptr.Ref(int64(100)),
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ Expected: 1,
+ },
}
for _, tc := range testCases {
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index ec8e06b2f8d..68bff19da49 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2616,6 +2616,66 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
}
})
+ // When an unlimited and a metered license are minted with identical
+ // issued-at and expiry claims, the unlimited grant must win the tie,
+ // regardless of load order.
+ t.Run("UnlimitedOutranksMeteredOnTie", func(t *testing.T) {
+ t.Parallel()
+
+ // JWT NumericDate claims have second granularity, so truncate to
+ // keep the round-tripped issued-at values identical.
+ iat := time.Now().Add(-time.Minute).Truncate(time.Second)
+ nbf := iat
+ exp := iat.Add(time.Hour).Truncate(time.Second)
+ unlimited := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+ metered := database.License{
+ ID: 2,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ }),
+ }
+
+ for _, order := range [][]database.License{
+ {unlimited, metered},
+ {metered, unlimited},
+ } {
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
+ require.NoError(t, err)
+
+ feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+ }
+ })
+
// A newer license without soft/hard limits must fully replace an older
// license that carried them; the limits must not merge across licenses.
t.Run("SoftHardRideAlongWithWinner", func(t *testing.T) {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 74ce21b89fa..2218aa5cf35 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -176,22 +176,22 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map((message) => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message)
- ? ("warning" as const)
- : ("error" as const),
- link: messageLink(message),
- })),
- ...normalizedWarnings.map((message) => ({
- message,
- variant: isMutedWarning(message)
- ? ("warning" as const)
- : ("warningProminent" as const),
- link: messageLink(message),
- })),
+ ...errors.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message) ? "warning" : "error",
+ link: messageLink(message),
+ }),
+ ),
+ ...normalizedWarnings.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ variant: isMutedWarning(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ }),
+ ),
];
if (messages.length === 0) {
From 78e4477090c9d10791df7d03f7043a88fce30668 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:10:13 +0000
Subject: [PATCH 017/116] fix(enterprise/coderd/license): accept issuer-valid
zero soft limit for agent runtime hours
The license issuer validates the soft limit as 0 <= soft < allocation,
but this decoder dropped soft=0 as an unusable claim and warned. Accept
zero so every license the issuer mints decodes cleanly; omitting the
claim remains the way to express no soft limit.
---
enterprise/coderd/license/license.go | 4 ++--
enterprise/coderd/license/license_test.go | 13 ++++++-------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 66b55f1feea..f2a6924bf57 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -930,7 +930,7 @@ const (
// the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // becomes the feature's SoftLimit when 0 <= soft < allocation and is
// ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
// ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
@@ -1031,7 +1031,7 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
UsagePeriod: &usagePeriod,
}
if softOk {
- if soft > 0 && soft < allocation {
+ if soft >= 0 && soft < allocation {
feature.SoftLimit = &soft
} else {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 68bff19da49..66c46b3b691 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2857,20 +2857,19 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
},
},
{
- // A zero soft limit would warn at zero usage forever, so it is
- // dropped rather than rejecting the license. The canonical way
- // to express "no soft limit" is omitting the claim, so a
- // present-but-dropped zero still warns.
+ // A zero soft limit is valid (0 <= soft < allocation) and warns
+ // from the start of the usage period. Omitting the claim is the
+ // way to express "no soft limit".
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
expectFeature: &codersdk.Feature{
- Enabled: true,
- Limit: ptr.Ref[int64](100),
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](0),
},
- expectClaimsIgnored: true,
},
{
name: "NegativeSoft",
From 4661dddb21336c89556e7a8b04db9fb11d0766c6 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:18:17 +0000
Subject: [PATCH 018/116] fix: address restack test failures and banner
equality semantics
Restacking onto the parent's tie-break fix requires two test updates: the
stale MeteredBeatsUnlimitedOnExactTie comparison case contradicts the parent's
new contract that unlimited usage-period features outrank metered ones, so it
is removed, and the inherited UnlimitedOutranksMeteredOnTie license test now
supplies a zero-result AgentRuntimeMsFn since this branch makes the callback
required whenever the agent runtime hours feature is present.
The prominent banner heading now reads 'reached' instead of 'exceeded'
because the allocation warning fires at exact equality (actual >= allocation),
and a new story pins the multi-message banner heading for that equality case
alongside a muted diagnostic.
---
codersdk/deployment_test.go | 29 ----------------
enterprise/coderd/license/license_test.go | 2 +-
.../LicenseBannerView.stories.tsx | 34 +++++++++++++++++--
.../LicenseBanner/LicenseBannerView.tsx | 6 ++--
4 files changed, 37 insertions(+), 34 deletions(-)
diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go
index db79f935304..2abdff578e9 100644
--- a/codersdk/deployment_test.go
+++ b/codersdk/deployment_test.go
@@ -1199,35 +1199,6 @@ func TestFeatureComparison(t *testing.T) {
B: codersdk.Feature{Entitlement: codersdk.EntitlementEntitled, Limit: ptr.Ref(int64(100))},
Expected: 0,
},
- {
- // An unlimited agent runtime hours feature (nil Limit) loses to
- // a metered one when both licenses share the exact same
- // issued-at and end dates: rule 5 ranks a nil Limit lower.
- // Separately issued licenses never tie on both timestamps, so
- // the edge is pinned here instead of special-casing Compare.
- Name: "MeteredBeatsUnlimitedOnExactTie",
- A: codersdk.Feature{
- Entitlement: codersdk.EntitlementEntitled,
- Enabled: true,
- Limit: ptr.Ref(int64(100)),
- UsagePeriod: &codersdk.UsagePeriod{
- IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
- Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
- End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
- },
- },
- B: codersdk.Feature{
- Entitlement: codersdk.EntitlementEntitled,
- Enabled: true,
- Limit: nil,
- UsagePeriod: &codersdk.UsagePeriod{
- IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
- Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
- End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
- },
- },
- Expected: 1,
- },
{
Name: "NewerIssuedAtWinsOverSoftHardLimits",
A: codersdk.Feature{
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 6535b392af7..83d54ec9e53 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -3045,7 +3045,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
{unlimited, metered},
{metered, unlimited},
} {
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, noRuntime())
require.NoError(t, err)
feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 5c7fd4d67d7..cba412fb5fa 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -64,7 +64,7 @@ export const TwoWarnings: Story = {
const canvas = within(canvasElement);
await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(
- canvas.getByText("Your license limits have been exceeded"),
+ canvas.getByText("Your license limits have been reached"),
).toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: "Show more" }),
@@ -343,6 +343,36 @@ export const AgentRuntimeHoursAllocationReached: Story = {
},
};
+// The allocation warning fires at exact equality (actual >= allocation), so
+// a multi-message banner containing it must use a heading that stays
+// accurate when the allocation is reached but not exceeded.
+export const AgentRuntimeHoursAllocationReachedWithDiagnostic: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ warnings: [
+ formatLicenseMessage(
+ LicenseAgentRuntimeHoursAllocationReachedWarningText,
+ 100,
+ 100,
+ ),
+ ],
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(
+ canvas.getByText("Your license limits have been reached"),
+ ).toBeInTheDocument();
+ await expect(banner).toHaveTextContent(
+ "Your deployment has used 100 of the 100 Coder Agent runtime hours included in the current license term.",
+ );
+ await expect(banner).toHaveTextContent(
+ LicenseManagedAgentUsageUnavailableErrorText,
+ );
+ },
+};
+
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
@@ -393,7 +423,7 @@ export const UsageDiagnosticsOnlyHeading: Story = {
await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(canvas.getByText("License notices")).toBeInTheDocument();
await expect(
- canvas.queryByText("Your license limits have been exceeded"),
+ canvas.queryByText("Your license limits have been reached"),
).not.toBeInTheDocument();
await expect(
canvas.queryByText("License errors require attention"),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 2cef6164395..223dae024d6 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -71,13 +71,15 @@ const getBannerVariant = (
};
// The muted "warning" variant means every message is an advisory or
-// diagnostic, so the heading must not assert exceedance.
+// diagnostic, so the heading must not assert a limit was hit. The prominent
+// heading says "reached" rather than "exceeded" because some limit warnings
+// fire at exact equality, which "reached" covers in both cases.
const bannerTitle = (variant: LicenseBannerVariant): string => {
switch (variant) {
case "error":
return "License errors require attention";
case "warningProminent":
- return "Your license limits have been exceeded";
+ return "Your license limits have been reached";
case "warning":
return "License notices";
}
From c8161e1a47d7d91fa67357c0d6e60ee61397797e Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:32:48 +0000
Subject: [PATCH 019/116] fix(coderd/database): renumber agent runtime
invariants migration to 000569
Main landed its own 000567 and 000568 (chat file purge indexes and
service account notifications) after this branch was renumbered to
000567, making the migration number a duplicate that fails migrate
setup in the test merge.
---
....sql => 000569_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.down.sql => 000569_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
From 96fc0bca6d9c9b6d72c313ab7fe28eef613de195 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:37:13 +0000
Subject: [PATCH 020/116] chore(coderd/notifications): sync formatting and
rendered-template goldens with generators
make fmt/go (gci) regroups the imports in smtp_internal_test.go, and
make gen/golden-files re-renders the AI budget notification goldens with
HTML-escaped apostrophes. Both were committed out of sync with the
repo's pinned tools, so every local pre-commit run failed its unstaged
check. No behavior change.
---
coderd/notifications/dispatch/smtp_internal_test.go | 1 +
.../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ----
.../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++--
.../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++--
.../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++--
5 files changed, 7 insertions(+), 10 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 2e7dff8cbec..3557a42a0e3 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -9,6 +9,7 @@ import (
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
+ markdown "github.com/coder/coder/v2/coderd/render"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
index 550fd9b56fd..f64981add03 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
@@ -17,8 +17,6 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
Effective group: Engineering
-This limit is a per-user override.
-
AI budget period: July 1, 2026 - August 1, 2026
@@ -56,8 +54,6 @@ limit ($1000.00). Subsequent requests will be blocked.
Effective group: Engineering
-This limit is a per-user override.
-
AI budget period: July 1, 2026 - August 1, 2026
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
index 37db6f733cc..94d00686923 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+ Codestin Search App
- You've reached your monthly AI budget limit
+ You've reached your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
index 3927ab28e31..4d5ffdf4744 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+
Codestin Search App
- You're approaching your monthly AI budget limit
+ You're approaching your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
index 4315def7665..1523ba12285 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
@@ -24,6 +24,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
- "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
- "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
+ "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
+ "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
}
\ No newline at end of file
From 5c010c6e4cec187f2a2265cb02a2d31ae2e4fdaf Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:38:12 +0000
Subject: [PATCH 021/116] fix: enforce uniqueness and hour alignment for agent
runtime usage events
The usage generator writes hb_agent_runtime_v1 rows with created_at at
the UTC hourly bucket start and exactly one row per bucket, but nothing
in the schema enforced either invariant. A duplicate bucket row under a
different id would be double-counted by any consumer summing runtime_ms,
and a misaligned created_at would skew which usage period a bucket is
attributed to.
Replace the non-unique partial index idx_usage_events_agent_runtime with
a unique index of the same shape and add an hour-alignment CHECK
constraint. generateBucket treats a unique violation on the bucket index
as another replica having won the race, mirroring the existing
ON CONFLICT (id) no-op for committed rows.
---
coderd/database/check_constraint.go | 1 +
coderd/database/dump.sql | 5 ++-
...e_events_agent_runtime_invariants.down.sql | 9 ++++
...age_events_agent_runtime_invariants.up.sql | 26 +++++++++++
coderd/database/querier.go | 8 +++-
coderd/database/querier_test.go | 41 +++++++++++++++---
coderd/database/queries.sql.go | 8 +++-
coderd/database/queries/usageevents.sql | 8 +++-
coderd/database/unique_constraint.go | 1 +
enterprise/coderd/usage/generator.go | 38 ++++++++--------
.../coderd/usage/generator_internal_test.go | 43 +++++++++++++++++++
11 files changed, 155 insertions(+), 33 deletions(-)
create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
create mode 100644 enterprise/coderd/usage/generator_internal_test.go
diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go
index 268009cd29b..b07a16a5178 100644
--- a/coderd/database/check_constraint.go
+++ b/coderd/database/check_constraint.go
@@ -55,6 +55,7 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
+ CheckUsageEventsAgentRuntimeHourAligned CheckConstraint = "usage_events_agent_runtime_hour_aligned" // usage_events
CheckUserAIBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
CheckUserAIProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
CheckUserSecretsEnabledRequiresTarget CheckConstraint = "user_secrets_enabled_requires_target" // user_secrets
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 79e987cc084..f869088245f 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -3544,7 +3544,8 @@ CREATE TABLE usage_events (
publish_started_at timestamp with time zone,
published_at timestamp with time zone,
failure_message text,
- CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text])))
+ CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text]))),
+ CONSTRAINT usage_events_agent_runtime_hour_aligned CHECK (((event_type <> 'hb_agent_runtime_v1'::text) OR (date_trunc('hour'::text, timezone('UTC'::text, created_at)) = timezone('UTC'::text, created_at))))
);
COMMENT ON TABLE usage_events IS 'usage_events contains usage data that is collected from the product and potentially shipped to the usage collector service.';
@@ -4891,7 +4892,7 @@ CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree
CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
-CREATE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
+CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
CREATE INDEX idx_usage_events_ai_seats ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_ai_seats_v1'::text);
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
new file mode 100644
index 00000000000..ed8b4ae78b8
--- /dev/null
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
@@ -0,0 +1,9 @@
+-- IF EXISTS matches 000561's down migration, so rolling back out of order
+-- (e.g. during an incident) is a no-op instead of a failure.
+DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
+CREATE INDEX idx_usage_events_agent_runtime
+ ON usage_events (event_type, created_at)
+ WHERE event_type = 'hb_agent_runtime_v1';
+
+ALTER TABLE usage_events
+ DROP CONSTRAINT IF EXISTS usage_events_agent_runtime_hour_aligned;
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
new file mode 100644
index 00000000000..33c0c28f1a7
--- /dev/null
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
@@ -0,0 +1,26 @@
+-- The usage generator writes hb_agent_runtime_v1 rows with created_at at
+-- the UTC hourly bucket start and exactly one row per bucket. Uniqueness
+-- keeps any consumer that sums runtime_ms from counting a bucket twice;
+-- the alignment CHECK protects the attribution model, which charges a
+-- bucket to the usage period containing its start.
+--
+-- Both statements validate existing rows. Every supported writer has always
+-- produced conforming data, so a pre-existing violator is anomalous and
+-- failing the migration loudly beats silently rewriting usage rows.
+ALTER TABLE usage_events
+ ADD CONSTRAINT usage_events_agent_runtime_hour_aligned
+ CHECK (
+ event_type <> 'hb_agent_runtime_v1'
+ OR date_trunc('hour', (created_at AT TIME ZONE 'UTC')) = (created_at AT TIME ZONE 'UTC')
+ );
+
+-- Replace the non-unique partial index with a unique one of the same shape,
+-- so reads are served identically. Inserts keep their (id) arbiter:
+-- re-inserting a bucket under its deterministic id stays a silent no-op,
+-- while a duplicate bucket row under a different id raises instead of being
+-- counted twice (generateBucket in enterprise/coderd/usage/generator.go
+-- handles the violation).
+DROP INDEX idx_usage_events_agent_runtime;
+CREATE UNIQUE INDEX idx_usage_events_agent_runtime
+ ON usage_events (event_type, created_at)
+ WHERE event_type = 'hb_agent_runtime_v1';
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 34e74dedc4f..cf563ec624c 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -1159,8 +1159,12 @@ type sqlcQuerier interface {
InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error
InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error)
InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error)
- // Duplicate events are ignored intentionally to allow for multiple replicas to
- // publish heartbeat events.
+ // Duplicate events are ignored intentionally to allow for multiple replicas
+ // to publish heartbeat events. The (id) arbiter scopes that tolerance to
+ // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+ // bucket under a different id raises on idx_usage_events_agent_runtime
+ // instead, which generateBucket in enterprise/coderd/usage/generator.go
+ // handles.
InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
// InsertUserGroupsByID adds a user to all provided groups, if they exist.
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index d416a3f6880..a873b84990c 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -10911,11 +10911,10 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb_agent_runtime_v1:2025-01-02_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 250}`, day2)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
- // Re-inserting a bucket must not double-count it. The daily rollup
- // sums runtime_ms, so idempotency rests on the aggregate trigger
- // being AFTER INSERT: Postgres does not fire it for rows suppressed
- // by ON CONFLICT (id) DO NOTHING. Concurrent replicas and backfill
- // re-runs both take this path.
+ // Re-inserting a bucket under its deterministic id must not
+ // double-count it: the daily rollup's AFTER INSERT trigger does not
+ // fire for rows suppressed by the insert's ON CONFLICT (id)
+ // arbiter.
insert("hb_agent_runtime_v1:2025-01-01_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 1000}`, day1)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
@@ -10923,6 +10922,38 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb-seats-1", "hb_ai_seats_v1", `{"count": 3}`, day2)
rows := getDailyRows(ctx, sqlDB)
require.Len(t, rows, 3)
+
+ // The same bucket under a different id is not an idempotent
+ // re-insert but a duplicate that would double any aggregate summing
+ // runtime_ms; the unique partial index
+ // idx_usage_events_agent_runtime rejects it loudly instead of the
+ // (id) arbiter silently dropping it.
+ err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "different-id-same-bucket",
+ EventType: "hb_agent_runtime_v1",
+ EventData: []byte(`{"runtime_ms": 9999}`),
+ CreatedAt: day1,
+ })
+ require.True(t, database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime),
+ "expected unique violation on idx_usage_events_agent_runtime, got %v", err)
+ // The rejected row must not have reached the daily rollup either.
+ rows = getDailyRows(ctx, sqlDB)
+ require.Len(t, rows, 3)
+ require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
+
+ // created_at must be the exact UTC hourly bucket start;
+ // usage_events_agent_runtime_hour_aligned rejects a misaligned row
+ // so it cannot skew the period a bucket is attributed to.
+ err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "hb_agent_runtime_v1:misaligned",
+ EventType: "hb_agent_runtime_v1",
+ EventData: []byte(`{"runtime_ms": 100}`),
+ CreatedAt: day1.Add(30 * time.Minute),
+ })
+ require.ErrorContains(t, err, string(database.CheckUsageEventsAgentRuntimeHourAligned))
+ rows = getDailyRows(ctx, sqlDB)
+ require.Len(t, rows, 3)
+ require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
})
t.Run("UnknownEventType", func(t *testing.T) {
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 5ada09d52b0..ebb6013162f 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28607,8 +28607,12 @@ type InsertUsageEventParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
-// Duplicate events are ignored intentionally to allow for multiple replicas to
-// publish heartbeat events.
+// Duplicate events are ignored intentionally to allow for multiple replicas
+// to publish heartbeat events. The (id) arbiter scopes that tolerance to
+// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+// bucket under a different id raises on idx_usage_events_agent_runtime
+// instead, which generateBucket in enterprise/coderd/usage/generator.go
+// handles.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 8ba706b0c8a..03f435c5af7 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -1,6 +1,10 @@
-- name: InsertUsageEvent :exec
--- Duplicate events are ignored intentionally to allow for multiple replicas to
--- publish heartbeat events.
+-- Duplicate events are ignored intentionally to allow for multiple replicas
+-- to publish heartbeat events. The (id) arbiter scopes that tolerance to
+-- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+-- bucket under a different id raises on idx_usage_events_agent_runtime
+-- instead, which generateBucket in enterprise/coderd/usage/generator.go
+-- handles.
INSERT INTO
usage_events (
id,
diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go
index 4b1a4376f2d..23256ed3b86 100644
--- a/coderd/database/unique_constraint.go
+++ b/coderd/database/unique_constraint.go
@@ -160,6 +160,7 @@ const (
UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text)));
UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true);
UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
+ UniqueIndexUsageEventsAgentRuntime UniqueConstraint = "idx_usage_events_agent_runtime" // CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE ((deleted = false) AND (email <> ''::text));
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index 91d911b57a7..a5b6788a214 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -48,8 +48,11 @@ const (
// Cron jobs, which sample live state when they fire, the Generator derives
// events from data already persisted in the database, so it can
// deterministically backfill hours missed while the deployment was down,
-// zero-filling idle hours. Deterministic event IDs plus the database's
-// ON CONFLICT (id) DO NOTHING make concurrent replicas safe without locking.
+// zero-filling idle hours. Deterministic event IDs make concurrent replicas
+// safe without locking: a re-insert of a committed bucket is a no-op via the
+// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted
+// bucket surface a unique violation that generateBucket recognizes as the
+// other replica winning.
//
// Events are generated unconditionally in enterprise builds; the
// publish_usage_data license flag only gates publishing to Tallyman.
@@ -155,24 +158,12 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error {
return xerrors.Errorf("list existing agent runtime events: %w", err)
}
// A row marks its bucket complete regardless of publish outcome, so a
- // bucket whose event Tallyman permanently rejected is never
- // regenerated (re-inserting under the deterministic ID is a no-op via
- // ON CONFLICT (id) DO NOTHING).
- //
- // The runtime is not lost locally: the row still holds it, and the
- // event can be re-queued for publishing with
- //
- // UPDATE usage_events
- // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL
- // WHERE id = 'hb_agent_runtime_v1:';
- //
- // That re-arm only has an effect while the bucket is inside the
- // publisher's 30-day cutoff: SelectUsageEventsForPublishing also
- // filters created_at > now - INTERVAL '30 days', and created_at is the
- // bucket start, so past that the UPDATE reports success but the row is
- // never picked up again. The release gate (Tallyman must accept this
- // event type before coderd ships it) is what keeps permanent
- // rejections exceptional.
+ // bucket whose event Tallyman permanently rejected is never regenerated
+ // (re-inserting under the deterministic ID is a no-op via the insert's
+ // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row
+ // keeps it, and clearing the row's publish columns re-queues it while
+ // the bucket is within SelectUsageEventsForPublishing's 30-day
+ // created_at cutoff.
existing := make(map[time.Time]struct{}, len(existingTimes))
for _, ts := range existingTimes {
// created_at is always the exact bucket start for this event type;
@@ -235,6 +226,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
// time) so daily rollups attribute backfilled hours to the correct day.
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
+ if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
+ // The insert's ON CONFLICT (id) arbiter only sees committed rows, so
+ // a concurrent replica inserting the same bucket can trip the bucket
+ // unique index instead. Either way a row for this bucket already
+ // exists, which is all generateBucket needs.
+ return nil
+ }
if err != nil {
return xerrors.Errorf("insert usage event: %w", err)
}
diff --git a/enterprise/coderd/usage/generator_internal_test.go b/enterprise/coderd/usage/generator_internal_test.go
new file mode 100644
index 00000000000..fa6aeb63440
--- /dev/null
+++ b/enterprise/coderd/usage/generator_internal_test.go
@@ -0,0 +1,43 @@
+package usage
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ "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/testutil"
+ "github.com/coder/quartz"
+)
+
+// TestGenerateBucketUniqueViolation pins that a unique violation on the
+// bucket index resolves the bucket as complete: another writer already
+// recorded it. TestGeneratorConcurrentReplicas also reaches this path, but
+// only when its goroutines actually interleave; this case cannot pass by
+// scheduling accident.
+func TestGenerateBucketUniqueViolation(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitShort)
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+ gen := NewGenerator(quartz.NewMock(t), slogtest.Make(t, nil), mDB, NewDBInserter())
+
+ mDB.EXPECT().
+ GetTotalChatMessageRuntimeMsInRange(gomock.Any(), gomock.Any()).
+ Return(int64(1000), nil)
+ mDB.EXPECT().
+ InsertUsageEvent(gomock.Any(), gomock.Any()).
+ Return(&pq.Error{
+ Code: "23505", // unique_violation
+ Constraint: string(database.UniqueIndexUsageEventsAgentRuntime),
+ })
+
+ bucket := time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC)
+ require.NoError(t, gen.generateBucket(ctx, bucket))
+}
From cb36cfae2c5f624333537c1d93ba0d7b7ecbcf13 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 12:04:06 +0000
Subject: [PATCH 022/116] revert: "chore(coderd/notifications): sync formatting
and rendered-template goldens with generators"
This reverts commit ed531d9d365720b4e394f4b422ea9776886c56a3.
The gen/fmt drift it patched over came from main commit 07f79af65b2
(fix: markdown rendering improvements) having landed out of sync with
its generators; main has since reverted that commit entirely in
8c2f7adeb1f (#27979), so the sync is obsolete and conflicts with the
regenerated goldens on latest main.
---
coderd/notifications/dispatch/smtp_internal_test.go | 3 ++-
.../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ++++
.../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++--
.../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++--
.../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++--
5 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 3557a42a0e3..03324f78573 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -7,9 +7,10 @@ import (
"github.com/stretchr/testify/require"
+ markdown "github.com/coder/coder/v2/coderd/render"
+
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
- markdown "github.com/coder/coder/v2/coderd/render"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
index f64981add03..550fd9b56fd 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
@@ -17,6 +17,8 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
Effective group: Engineering
+This limit is a per-user override.
+
AI budget period: July 1, 2026 - August 1, 2026
@@ -54,6 +56,8 @@ limit ($1000.00). Subsequent requests will be blocked.
Effective group: Engineering
+This limit is a per-user override.
+
AI budget period: July 1, 2026 - August 1, 2026
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
index 94d00686923..37db6f733cc 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+ Codestin Search App
- You've reached your monthly AI budget limit
+ You've reached your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
index 4d5ffdf4744..3927ab28e31 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+
Codestin Search App
- You're approaching your monthly AI budget limit
+ You're approaching your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
index 1523ba12285..4315def7665 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
@@ -24,6 +24,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
- "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
- "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
+ "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
+ "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
}
\ No newline at end of file
From e127980c29f28b46b79491914349dbfab4d51c44 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 13:21:33 +0000
Subject: [PATCH 023/116] updates for PR review
---
.../000565_usage_events_agent_runtime_invariants.down.sql | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
index ed8b4ae78b8..8a6a0240988 100644
--- a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
@@ -1,5 +1,6 @@
--- IF EXISTS matches 000561's down migration, so rolling back out of order
--- (e.g. during an incident) is a no-op instead of a failure.
+-- IF EXISTS matches 000561's down migration: if the index is already gone
+-- (e.g. rolling back out of order during an incident), tolerate the miss
+-- instead of failing.
DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
CREATE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
From d413cd914f05a78fa3aecae33d8533ac6bd367b8 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:16:52 +0000
Subject: [PATCH 024/116] chore(coderd/database): restore original
InsertUsageEvent comment
The expanded arbiter-scoping commentary is not needed on the insert
query; the duplicate-bucket behavior is documented on the migration and
in generateBucket where it is handled.
---
coderd/database/querier.go | 8 ++------
coderd/database/queries.sql.go | 8 ++------
coderd/database/queries/usageevents.sql | 8 ++------
3 files changed, 6 insertions(+), 18 deletions(-)
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index cf563ec624c..34e74dedc4f 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -1159,12 +1159,8 @@ type sqlcQuerier interface {
InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error
InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error)
InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error)
- // Duplicate events are ignored intentionally to allow for multiple replicas
- // to publish heartbeat events. The (id) arbiter scopes that tolerance to
- // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
- // bucket under a different id raises on idx_usage_events_agent_runtime
- // instead, which generateBucket in enterprise/coderd/usage/generator.go
- // handles.
+ // Duplicate events are ignored intentionally to allow for multiple replicas to
+ // publish heartbeat events.
InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
// InsertUserGroupsByID adds a user to all provided groups, if they exist.
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index ebb6013162f..5ada09d52b0 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28607,12 +28607,8 @@ type InsertUsageEventParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
-// Duplicate events are ignored intentionally to allow for multiple replicas
-// to publish heartbeat events. The (id) arbiter scopes that tolerance to
-// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
-// bucket under a different id raises on idx_usage_events_agent_runtime
-// instead, which generateBucket in enterprise/coderd/usage/generator.go
-// handles.
+// Duplicate events are ignored intentionally to allow for multiple replicas to
+// publish heartbeat events.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 03f435c5af7..8ba706b0c8a 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -1,10 +1,6 @@
-- name: InsertUsageEvent :exec
--- Duplicate events are ignored intentionally to allow for multiple replicas
--- to publish heartbeat events. The (id) arbiter scopes that tolerance to
--- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
--- bucket under a different id raises on idx_usage_events_agent_runtime
--- instead, which generateBucket in enterprise/coderd/usage/generator.go
--- handles.
+-- Duplicate events are ignored intentionally to allow for multiple replicas to
+-- publish heartbeat events.
INSERT INTO
usage_events (
id,
From 273f0680ee425c49a9bb9e04a540b19fbebb7575 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:46:36 +0000
Subject: [PATCH 025/116] fix(coderd/database): renumber agent runtime
invariants migration to 000567
Main landed its own 000565 and 000566 (oauth2 client type constraint and
auth method backfill) after this branch was cut, making the migration
number a duplicate that fails migrate setup.
---
....sql => 000567_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.down.sql => 000567_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
From 5e2f179710168ae1fbe5354f2bfec4c4eb7af4cf Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 07:12:28 +0000
Subject: [PATCH 026/116] chore(enterprise/coderd/usage): restore agent runtime
requeue guidance comment
---
enterprise/coderd/usage/generator.go | 24 ++++++++++++++++++------
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index a5b6788a214..af7fcd713ee 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -158,12 +158,24 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error {
return xerrors.Errorf("list existing agent runtime events: %w", err)
}
// A row marks its bucket complete regardless of publish outcome, so a
- // bucket whose event Tallyman permanently rejected is never regenerated
- // (re-inserting under the deterministic ID is a no-op via the insert's
- // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row
- // keeps it, and clearing the row's publish columns re-queues it while
- // the bucket is within SelectUsageEventsForPublishing's 30-day
- // created_at cutoff.
+ // bucket whose event Tallyman permanently rejected is never
+ // regenerated (re-inserting under the deterministic ID is a no-op via
+ // the insert's ON CONFLICT (id) arbiter).
+ //
+ // The runtime is not lost locally: the row still holds it, and the
+ // event can be re-queued for publishing with
+ //
+ // UPDATE usage_events
+ // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL
+ // WHERE id = 'hb_agent_runtime_v1:';
+ //
+ // That re-arm only has an effect while the bucket is inside the
+ // publisher's 30-day cutoff: SelectUsageEventsForPublishing also
+ // filters created_at > now - INTERVAL '30 days', and created_at is the
+ // bucket start, so past that the UPDATE reports success but the row is
+ // never picked up again. The release gate (Tallyman must accept this
+ // event type before coderd ships it) is what keeps permanent
+ // rejections exceptional.
existing := make(map[time.Time]struct{}, len(existingTimes))
for _, ts := range existingTimes {
// created_at is always the exact bucket start for this event type;
From c3ee285f96f5f0352ebba220aec62700835dd303 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:32:48 +0000
Subject: [PATCH 027/116] fix(coderd/database): renumber agent runtime
invariants migration to 000569
Main landed its own 000567 and 000568 (chat file purge indexes and
service account notifications) after this branch was renumbered to
000567, making the migration number a duplicate that fails migrate
setup in the test merge.
---
....sql => 000569_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.down.sql => 000569_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
From 5779fcf9e8e8b903827ef24a7ff76bd58e91c9a3 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:45:24 +0000
Subject: [PATCH 028/116] chore(enterprise/coderd/usage): correct ON CONFLICT
concurrency comments
The (id) arbiter is not limited to committed rows: when a competing
row's arbiter index entry is visible, PostgreSQL waits on that
transaction and takes the DO NOTHING path if it commits. Only the
narrower speculative-insertion race, before the arbiter entry exists,
trips the bucket unique index.
---
enterprise/coderd/usage/generator.go | 22 ++++++++++++++--------
1 file changed, 14 insertions(+), 8 deletions(-)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index af7fcd713ee..cec94a1a511 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -49,10 +49,13 @@ const (
// events from data already persisted in the database, so it can
// deterministically backfill hours missed while the deployment was down,
// zero-filling idle hours. Deterministic event IDs make concurrent replicas
-// safe without locking: a re-insert of a committed bucket is a no-op via the
-// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted
-// bucket surface a unique violation that generateBucket recognizes as the
-// other replica winning.
+// safe without locking: the insert's ON CONFLICT (id) arbiter turns a
+// re-insert of a bucket into a no-op, even when the competing insert is
+// still in flight (once its arbiter index entry is visible, PostgreSQL
+// waits on that transaction and takes the DO NOTHING path if it commits).
+// Only the narrow speculative-insertion race, before the competing row's
+// arbiter entry exists, surfaces a bucket unique violation instead, which
+// generateBucket recognizes as the other replica winning.
//
// Events are generated unconditionally in enterprise builds; the
// publish_usage_data license flag only gates publishing to Tallyman.
@@ -239,10 +242,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
- // The insert's ON CONFLICT (id) arbiter only sees committed rows, so
- // a concurrent replica inserting the same bucket can trip the bucket
- // unique index instead. Either way a row for this bucket already
- // exists, which is all generateBucket needs.
+ // The insert's ON CONFLICT (id) arbiter absorbs most duplicate
+ // inserts, including in-flight ones: once a competing row's arbiter
+ // index entry is visible, PostgreSQL waits on that transaction and
+ // takes the DO NOTHING path if it commits. Only the narrow
+ // speculative-insertion race, before that entry exists, trips the
+ // bucket unique index instead. Either way a row for this bucket
+ // already exists, which is all generateBucket needs.
return nil
}
if err != nil {
From 29bcb1c2adf864895c61faa4d627c71f9f5781be Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:58:22 +0000
Subject: [PATCH 029/116] feat: report stable license measurement diagnostics
and tolerate unusable runtime hours claims
Two coupled changes to the license/entitlements layer.
Measurement diagnostics: extract a measureUsage helper that owns the
usage-query failure policy. A query failure now publishes the stable
codersdk.LicenseManagedAgentUsageUnavailableErrorText on the
entitlements Errors channel (keeping the alertable coderd_license_errors
gauge counting) while the raw cause goes to the coderd log through the
new FeatureArguments.Logger. A nil usage closure becomes a hard
developer error, and a failure with a dead context still aborts the
whole refresh. The managed agent count closure switches from
AsSystemRestricted to the narrower AsUsagePublisher subject. The
dashboard recognizes diagnostic texts and renders them muted, without
the exceedance heading or a sales link.
Claim tolerance: unusable agent_runtime_hours_* claim combinations no
longer reject the whole license. decodeAgentRuntimeHours drops the
unusable claims, reports them in a stable
LicenseAgentRuntimeHoursClaimsIgnoredWarningText warning (deduplicated
across licenses), and logs the affected license and claims;
validateAgentRuntimeHours and its license-invalidating errors are
removed. Rejecting a signed license over a cosmetic threshold claim
would drop the deployment to unlicensed.
Part of the stack for coder/coder#27796.
---
codersdk/licenses.go | 13 +
enterprise/coderd/coderd_test.go | 6 +
.../coderd/coderdenttest/coderdenttest.go | 15 +
enterprise/coderd/license/license.go | 228 +++++----
enterprise/coderd/license/license_test.go | 476 +++++++++++++++---
enterprise/coderd/licenses_test.go | 35 +-
site/src/api/typesGenerated.ts | 23 +
.../dashboard/LicenseBanner/LicenseBanner.tsx | 54 +-
.../LicenseBannerView.stories.tsx | 113 ++++-
.../LicenseBanner/LicenseBannerView.tsx | 20 +-
10 files changed, 762 insertions(+), 221 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index f2ccf009842..7aecbaa3089 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,6 +17,19 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ // managed agent usage query fails while computing entitlements; the
+ // cause is logged server-side. It travels in the entitlements Errors
+ // channel so the alertable coderd_license_errors gauge counts
+ // measurement failures, but the dashboard recognizes the exact text and
+ // renders it as a muted diagnostic rather than a license error.
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
+ // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ // license carries unusable Coder Agent runtime hour claims (see
+ // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ // the license and the dropped claims. The dashboard renders the exact
+ // text as a muted diagnostic without a sales link.
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
type AddLicenseRequest struct {
diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go
index e023da7b1df..7be01405353 100644
--- a/enterprise/coderd/coderd_test.go
+++ b/enterprise/coderd/coderd_test.go
@@ -92,6 +92,12 @@ func TestEntitlements(t *testing.T) {
// Enable all features
features := make(license.Features)
for _, feature := range codersdk.FeatureNames {
+ if feature == codersdk.FeatureAgentRuntimeHours {
+ // The feature name is not a valid license claim; the
+ // feature is encoded as its allocation claim.
+ features[license.ClaimAgentRuntimeHoursAllocation] = 1
+ continue
+ }
features[feature] = 1
}
features[codersdk.FeatureUserLimit] = 100
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 1115ba12118..8f5f1e4a708 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,6 +237,21 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
+// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
+// or hardLimit omits that claim, which a license is allowed to do; a non-nil
+// value is emitted verbatim, so tests can express explicit zero or negative
+// claims.
+func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
+ opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
+ if softLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitSoft, *softLimit)
+ }
+ if hardLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitHard, *hardLimit)
+ }
+ return opts
+}
+
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
if opts.Features == nil {
opts.Features = license.Features{}
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8cce657de65..e6f3598475e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -93,6 +93,7 @@ func Entitlements(
}
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
+ Logger: logger,
ActiveUserCount: activeUserCount,
ActiveAISeatCount: activeAISeatCount,
ReplicaCount: replicaCount,
@@ -114,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Reading usage events requires the usage publisher subject.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -129,6 +130,9 @@ func Entitlements(
}
type FeatureArguments struct {
+ // Logger receives the causes behind operator-facing diagnostics whose
+ // published message is a stable text. The zero value discards them.
+ Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
ReplicaCount int
@@ -508,11 +512,8 @@ func LicensesEntitlements(
continue
}
- // Agent runtime hours are encoded as up to three claims and are
- // decoded together after this loop, see
- // decodeAgentRuntimeHours. The feature name itself is never a
- // valid claim. The allocation must come from the dedicated claim
- // so it is validated against the soft and hard limits.
+ // Agent runtime hour claims are decoded together after this
+ // loop; see decodeAgentRuntimeHours.
if featureName == codersdk.FeatureAgentRuntimeHours ||
isAgentRuntimeHoursClaim(featureName) {
continue
@@ -577,14 +578,25 @@ func LicensesEntitlements(
}
}
- // The loop above skips Agent runtime hours because the
- // three claims that encode them decode into a single feature.
- if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
+ runtimeFeature, granted, ignoredClaims := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
- }); ok {
- entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature)
+ })
+ if granted {
+ entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
+ }
+ if len(ignoredClaims) > 0 {
+ // The published warning is a stable text, so the details a
+ // support case needs go to the log.
+ featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
+ slog.F("license_id", license.UUID),
+ slog.F("ignored_claims", ignoredClaims),
+ )
+ if !slices.Contains(entitlements.Warnings, codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) {
+ entitlements.Warnings = append(entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ }
}
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
@@ -701,24 +713,18 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- var (
- managedAgentCount int64
- err = xerrors.New("dev error: managed agent count function is not set")
- )
- if featureArguments.ManagedAgentCountFn != nil {
- managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
- }
- if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
- // If the context is canceled, we want to bail the entire
- // LicensesEntitlements call.
- return entitlements, xerrors.Errorf("get managed agent count: %w", err)
- }
+ managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
+ featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
+ "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
if err != nil {
- entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
- // no return
- } else {
+ return entitlements, err
+ }
+ if ok {
agentLimit.Actual = &managedAgentCount
- entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
+ // Written back directly: the feature contest is already
+ // settled, so AddFeature's Compare must not get a chance to
+ // drop the write.
+ entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -857,6 +863,42 @@ func LicensesEntitlements(
return entitlements, nil
}
+// measureUsage runs one usage query over the feature's usage period and owns
+// the shared failure policy: a nil fn is a wiring bug and fails the whole
+// LicensesEntitlements call; a failure with a dead context fails the call
+// without logging; any other failure logs the cause and publishes the stable
+// unavailableText instead. It returns the measured value and true only on
+// success.
+func measureUsage(
+ ctx context.Context,
+ entitlements *codersdk.Entitlements,
+ logger slog.Logger,
+ fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
+ usagePeriod codersdk.UsagePeriod,
+ what string,
+ unavailableText string,
+) (int64, bool, error) {
+ if fn == nil {
+ return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
+ }
+ value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
+ switch {
+ case err != nil && ctx.Err() != nil:
+ // The computation's own context is dead, so abort the whole call
+ // without logging. Do not classify by error shape instead: Postgres
+ // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
+ // as well as client cancels, and aborting on those would fail every
+ // entitlements refresh on a deployment whose statement_timeout is
+ // shorter than a usage query.
+ return 0, false, xerrors.Errorf("get %s: %w", what, err)
+ case err != nil:
+ logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
+ entitlements.Errors = append(entitlements.Errors, unavailableText)
+ return 0, false, nil
+ }
+ return value, true, nil
+}
+
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
@@ -885,23 +927,22 @@ const (
VersionClaim = "version"
)
-// Agent runtime hour license claims. These are the canonical claim names
-// minted by github.com/coder/license. All three claims map to the single
-// codersdk.FeatureAgentRuntimeHours feature and are validated together when
-// the license is parsed, see validateClaims.
-//
-// The unit for all three claims is hours.
+// Agent runtime hour license claims, minted by github.com/coder/license.
+// All three are in hours and decode together into the single
+// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit.
+ // allocation for the license term. It becomes the feature's Limit. A
+ // negative allocation is ignored, in which case the license does not
+ // grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // must satisfy 0 <= soft < allocation, so it may only be set when the
- // allocation is greater than 0. It becomes the feature's SoftLimit.
+ // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
- // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be
- // absent or >= allocation, and may only be set when the allocation is
- // greater than 0. It becomes the feature's HardLimit.
+ // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
+ // the feature's HardLimit when the allocation is greater than 0 and
+ // hard >= allocation, and is ignored otherwise.
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
@@ -917,19 +958,12 @@ var (
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
ErrMissingAccountType = xerrors.New("license must contain valid account type")
ErrMissingAccountID = xerrors.New("license must contain valid account ID")
-
- ErrMissingAgentRuntimeHoursAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation)
- ErrAgentRuntimeHoursLimitsWithZeroAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but the %s claim is 0", ClaimAgentRuntimeHoursAllocation)
)
type Features map[codersdk.FeatureName]int64
-// isAgentRuntimeHoursClaim reports whether the claim name is one of the three
-// claims that encode the codersdk.FeatureAgentRuntimeHours feature. These
-// claims are decoded together, see decodeAgentRuntimeHours.
+// isAgentRuntimeHoursClaim reports whether name is one of the three claims
+// decoded by decodeAgentRuntimeHours.
func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
switch name {
case ClaimAgentRuntimeHoursAllocation,
@@ -941,62 +975,65 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
}
}
-// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours feature
-// from the claims that encode it. It reports false when the license carries no
-// allocation claim, in which case the license does not grant the feature.
+// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours
+// feature from its claims. granted is false when there is no usable
+// allocation claim; per-claim validity rules live on the Claim* constants
+// above.
+//
+// Unusable claims are dropped, never license-invalidating: rejecting a
+// signed license over a cosmetic threshold claim would drop the deployment
+// to unlicensed. ignoredClaims names each dropped claim (including the
+// feature name itself minted as a claim, the most plausible issuer mistake)
+// so the caller can warn and log instead of letting an incorrectly issued
+// license look healthy.
//
-// The claim combination is validated when the license is parsed, see
-// Features.validateAgentRuntimeHours. The allocation is never negative here
-// and the soft and hard limits are only present alongside a positive
-// allocation.
-func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (codersdk.Feature, bool) {
- allocation, ok := features[ClaimAgentRuntimeHoursAllocation]
- if !ok {
- return codersdk.Feature{}, false
+// A zero allocation grants the feature disabled and drops both threshold
+// claims, but Actual is still measured and published. CODAGT-856 will make a
+// zero allocation force a concurrency-limited mode; that mode does not exist
+// yet.
+func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
+ if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
+ ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
}
- feature := codersdk.Feature{
+ allocation, allocOk := features[ClaimAgentRuntimeHoursAllocation]
+ soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
+ hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+
+ if !allocOk || allocation < 0 {
+ if allocOk && allocation < 0 {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
+ }
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{}, false, ignoredClaims
+ }
+
+ feature = codersdk.Feature{
Enabled: allocation > 0,
Entitlement: entitlement,
Limit: &allocation,
UsagePeriod: &usagePeriod,
}
- if soft, ok := features[ClaimAgentRuntimeHoursLimitSoft]; ok {
- feature.SoftLimit = &soft
- }
- if hard, ok := features[ClaimAgentRuntimeHoursLimitHard]; ok {
- feature.HardLimit = &hard
- }
- return feature, true
-}
-
-// validateAgentRuntimeHours validates the relationship between the agent
-// runtime hour claims. Invalid combinations reject the entire license.
-func (f Features) validateAgentRuntimeHours() error {
- allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation]
- soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft]
- hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard]
- if !hasAllocation {
- if hasSoft || hasHard {
- return ErrMissingAgentRuntimeHoursAllocation
+ if softOk {
+ if soft > 0 && soft < allocation {
+ feature.SoftLimit = &soft
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
}
- return nil
- }
- if allocation < 0 {
- return ErrInvalidAgentRuntimeHoursAllocation
- }
- // A zero allocation disables the feature.
- // A zero hard limit is not permitted.
- if allocation == 0 && (hasSoft || hasHard) {
- return ErrAgentRuntimeHoursLimitsWithZeroAllocation
}
- if hasSoft && (soft < 0 || soft >= allocation) {
- return ErrInvalidAgentRuntimeHoursSoftLimit
- }
- if hasHard && hard < allocation {
- return ErrInvalidAgentRuntimeHoursHardLimit
+ if hardOk {
+ if allocation > 0 && hard >= allocation {
+ feature.HardLimit = &hard
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
}
- return nil
+ return feature, true, ignoredClaims
}
// Claims is the full set of claims in a license.
@@ -1089,9 +1126,6 @@ func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims.AccountID == "" {
return nil, ErrMissingAccountID
}
- if err := claims.Features.validateAgentRuntimeHours(); err != nil {
- return nil, err
- }
return claims, nil
}
return nil, xerrors.New("unable to parse Claims")
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 6fe50902850..db68fb8b60a 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -1,9 +1,11 @@
package license_test
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "maps"
"slices"
"testing"
"time"
@@ -13,12 +15,17 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
+ "golang.org/x/xerrors"
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "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/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -30,6 +37,42 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
+// premiumRuntimeHoursFixture returns a mock store primed with a Premium
+// license carrying runtime hour claims (allocation 100, soft limit 80, hard
+// limit 120) plus the store expectations every entitlements refresh consumes
+// before usage is measured. Callers add expectations for the usage queries
+// under test.
+func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
+ t.Helper()
+
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+
+ licenseOpts := (&coderdenttest.LicenseOptions{
+ FeatureSet: codersdk.FeatureSetPremium,
+ IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
+ NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
+ GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
+ ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
+ // The addon marks AI Bridge as explicitly entitled, suppressing
+ // the unrelated "AI Governance add-on is required to use AI
+ // Gateway" warning that Premium would otherwise produce.
+ }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
+
+ lic := database.License{
+ ID: 1,
+ JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
+ Exp: licenseOpts.ExpiresAt,
+ }
+
+ mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
+ mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
+ mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
+ mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
+
+ return mDB, licenseOpts
+}
+
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -920,6 +963,63 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
+ t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
+ t.Parallel()
+
+ // Drive the real Entitlements closure with a mock database so
+ // measureUsage's failure path is exercised end to end: the cause
+ // must land in the coderd log, which the stable payload text points
+ // at, and must not land on the unauthenticated entitlements payload.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), xerrors.New("kaboom managed"))
+
+ // The error-level logs are the behavior under test, so the default
+ // failing test logger cannot be used.
+ var logBuf bytes.Buffer
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
+ AppendSinks(sloghuman.Sink(&logBuf))
+
+ entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+
+ // The failure surfaces its stable text without the raw cause,
+ // on the channel the codersdk constant docs prescribe.
+ require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
+ for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
+ require.NotContains(t, entry, "kaboom")
+ }
+
+ logs := logBuf.String()
+ require.Contains(t, logs, "get managed agent count for entitlements")
+ require.Contains(t, logs, "kaboom managed")
+ })
+
+ t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
+ t.Parallel()
+
+ // A query failing while the refresh's own context is canceled,
+ // e.g. during shutdown, aborts the whole entitlements refresh and
+ // must not log a false query-failure alarm at error level.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), context.Canceled)
+
+ var logBuf bytes.Buffer
+ logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.ErrorContains(t, err, "get managed agent count")
+ require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
+ })
+
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1299,6 +1399,12 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
+ // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
+ // injection below so the nil dev-error path can be exercised.
+ KeepNilManagedAgentCountFn bool
+ // CancelContext cancels the context passed to LicensesEntitlements
+ // before the call, exercising the usage-measurement abort policy.
+ CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1550,6 +1656,59 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
+ {
+ // A query failure is surfaced as a stable text in Errors (see
+ // the codersdk constant docs for the channel choice) and
+ // leaves Actual unset without aborting the rest of the
+ // entitlements.
+ Name: "ManagedAgentLimit/QueryError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, xerrors.New("kaboom")
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoWarnings(t, entitlements)
+ require.Len(t, entitlements.Errors, 1)
+ assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
+ // The raw error is logged rather than exposed on the
+ // unauthenticated entitlements payload.
+ assert.NotContains(t, entitlements.Errors[0], "kaboom")
+ feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
+ assert.Nil(t, feature.Actual)
+ },
+ },
+ {
+ // Forgetting to wire ManagedAgentCountFn is a dev error:
+ // production always provides the closure, so it fails the whole
+ // call loudly instead of degrading into an operator-facing
+ // message.
+ Name: "ManagedAgentLimit/NilFnDevError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ KeepNilManagedAgentCountFn: true,
+ ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
+ },
+ {
+ // A failure while the computation's own context is canceled
+ // aborts the whole call rather than degrading to an
+ // entitlements error.
+ Name: "ManagedAgentLimit/ContextCanceled",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ CancelContext: true,
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, context.Canceled
+ },
+ },
+ ExpectedErrorContains: "get managed agent count",
+ },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1581,13 +1740,18 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil {
+ if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
-
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+ ctx := context.Background()
+ if tc.CancelContext {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithCancel(ctx)
+ cancel()
+ }
+ entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1612,6 +1776,15 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
+ // A Premium license grants a managed agent limit by default, and a nil
+ // usage closure is a hard developer error, so these subtests wire a
+ // zero-usage measurement closure.
+ zeroUsageArgs := license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
+ }
+
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1631,7 +1804,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1658,7 +1831,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1690,7 +1863,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1721,7 +1894,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1734,7 +1907,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -2535,15 +2708,22 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
})
}
-// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the
-// agent runtime hour claims reject the entire license.
-func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
+// TestAgentRuntimeHoursClaimTolerance pins decodeAgentRuntimeHours's
+// tolerate-and-warn contract; see that function's doc for the rationale.
+func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
t.Parallel()
testCases := []struct {
- name string
- features license.Features
- expectedErr error
+ name string
+ features license.Features
+
+ // expectFeature is nil when the feature must be absent.
+ expectFeature *codersdk.Feature
+ // expectClaimsIgnored is true when at least one present claim is
+ // dropped, which must surface the claims-ignored warning: tolerating
+ // a claim and signaling nothing would make an incorrectly issued license
+ // undetectable from the deployment.
+ expectClaimsIgnored bool
}{
{
name: "AllClaims",
@@ -2552,109 +2732,172 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](80),
+ HardLimit: ptr.Ref[int64](120),
+ },
},
{
name: "AllocationOnly",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
},
{
+ // A zero soft limit would warn at zero usage forever, so it is
+ // dropped rather than rejecting the license. The canonical way
+ // to express "no soft limit" is omitting the claim, so a
+ // present-but-dropped zero still warns.
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "HardEqualsAllocation",
+ name: "NegativeSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocation",
+ // A soft limit at or above the allocation could never fire
+ // before the allocation warning supersedes it.
+ name: "SoftEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithZeroHard",
+ name: "SoftAboveAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithPositiveHard",
+ name: "HardEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 100,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ HardLimit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
- name: "SoftWithoutAllocation",
+ name: "HardBelowAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 99,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardWithoutAllocation",
+ name: "ZeroAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitHard: 120,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
- name: "NegativeAllocation",
+ // A zero allocation has no hour budget, so threshold claims
+ // alongside it are dropped, with the warning.
+ name: "ZeroAllocationWithLimits",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "NegativeSoft",
+ name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -1,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftEqualsAllocation",
+ name: "SoftWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftAboveAllocation",
+ name: "HardWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftWithZeroAllocation",
+ // The feature name itself is never a valid claim: the
+ // allocation must come from the dedicated claim. It is the
+ // shape every other metered feature uses, so a license minting
+ // it is the most plausible issuer mistake and must warn
+ // rather than being dropped silently.
+ name: "FeatureNameAsClaim",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitSoft: 0,
+ codersdk.FeatureAgentRuntimeHours: 100,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardBelowAllocation",
+ // The feature name claim is dropped (with the warning) even
+ // when a usable allocation claim grants the feature.
+ name: "FeatureNameAlongsideAllocation",
features: license.Features{
+ codersdk.FeatureAgentRuntimeHours: 50,
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 99,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
}
@@ -2662,46 +2905,115 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
- jwt := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: tc.features,
- })
- _, err := license.ParseClaims(jwt, coderdenttest.Keys)
- if tc.expectedErr == nil {
- require.NoError(t, err)
+ features := license.Features{
+ codersdk.FeatureUserLimit: 100,
+ }
+ maps.Copy(features, tc.features)
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: features,
+ }),
+ }
+
+ var logBuf bytes.Buffer
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
+ )
+ require.NoError(t, err)
+
+ // The license as a whole survives: unrelated paid features are
+ // unaffected by an unusable runtime hour claim.
+ require.Empty(t, entitlements.Errors)
+ require.True(t, entitlements.HasLicense)
+ userLimit := entitlements.Features[codersdk.FeatureUserLimit]
+ require.NotNil(t, userLimit.Limit)
+ require.EqualValues(t, 100, *userLimit.Limit)
+
+ // Dropped claims are tolerated but never silent: the operator
+ // sees the stable warning, and the log names the license and
+ // the dropped claims for support.
+ if tc.expectClaimsIgnored {
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ logs := logBuf.String()
+ require.Contains(t, logs, "ignored unusable Coder Agent runtime hour claims in license")
+ require.Contains(t, logs, lic.UUID.String())
+ } else {
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ require.Empty(t, logBuf.String())
+ }
+
+ // Every known feature name has a default entry in the map, so
+ // "the license does not grant the feature" surfaces as the
+ // default: no limit, no usage period, not enabled.
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ if tc.expectFeature == nil {
+ require.Nil(t, feature.Limit, "feature must not be granted")
+ require.Nil(t, feature.UsagePeriod, "feature must not be granted")
+ require.False(t, feature.Enabled)
return
}
- require.ErrorIs(t, err, tc.expectedErr)
+ require.NotNil(t, feature.UsagePeriod, "feature must be granted")
+ require.Equal(t, tc.expectFeature.Enabled, feature.Enabled)
+ require.Equal(t, tc.expectFeature.Limit, feature.Limit)
+ require.Equal(t, tc.expectFeature.SoftLimit, feature.SoftLimit)
+ require.Equal(t, tc.expectFeature.HardLimit, feature.HardLimit)
})
}
- // An invalid license already stored in the database is rejected entirely
- // and produces an entitlements error.
- t.Run("EntitlementsError", func(t *testing.T) {
+ t.Run("WarningDeduplicatedAcrossLicenses", func(t *testing.T) {
t.Parallel()
- lic := database.License{
- ID: 1,
- UploadedAt: time.Now(),
- Exp: time.Now().Add(time.Hour),
- UUID: uuid.New(),
- JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
- },
- }),
+ // Two licenses with unusable claims must publish the stable warning
+ // once, or the banner would stack identical texts, while the log
+ // names each affected license so the operator can tell which ones
+ // need re-issuing.
+ newLicense := func(id int32) database.License {
+ return database.License{
+ ID: id,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
+ // A threshold without an allocation is unusable.
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ }),
+ }
}
+ licenses := []database.License{newLicense(1), newLicense(2)}
+ var logBuf bytes.Buffer
entitlements, err := license.LicensesEntitlements(
- context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ context.Background(), time.Now(), licenses,
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
)
require.NoError(t, err)
- require.Len(t, entitlements.Errors, 1)
- require.Contains(t, entitlements.Errors[0], fmt.Sprintf("Invalid license (%s) parsing claims", lic.UUID))
- require.False(t, entitlements.HasLicense)
- feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
- require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
+
+ warningCount := 0
+ for _, warning := range entitlements.Warnings {
+ if warning == codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText {
+ warningCount++
+ }
+ }
+ require.Equal(t, 1, warningCount, "the claims-ignored warning must appear exactly once")
+
+ logs := logBuf.String()
+ for _, lic := range licenses {
+ require.Contains(t, logs, lic.UUID.String())
+ }
})
}
diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go
index c88f975f2c0..811929f0939 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -105,35 +106,41 @@ func TestPostLicense(t *testing.T) {
require.Contains(t, errResp.Message, "Invalid license")
})
- t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) {
+ t.Run("UnusableAgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- // A soft limit claim without an allocation claim rejects the whole
- // license.
+ // A soft limit claim without an allocation claim is unusable, but it
+ // never rejects the whole license: the license stays valid, the
+ // runtime hours feature is simply not granted, and the dropped claim
+ // is surfaced as a warning. See decodeAgentRuntimeHours.
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: lic,
})
- errResp := &codersdk.Error{}
- require.ErrorAs(t, err, &errResp)
- require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
- require.Contains(t, errResp.Message, "Invalid license")
+ require.NoError(t, err)
+ // The claims round-trip through GET /api/v2/entitlements.
+ //nolint:gocritic // This test asserts license state, not authz behavior.
+ entitlements, err := client.Entitlements(context.Background())
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+ require.Empty(t, entitlements.Errors)
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.UsagePeriod)
})
t.Run("AgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
- license.ClaimAgentRuntimeHoursLimitHard: 120,
- },
- })
+ coderdenttest.AddLicense(t, client,
+ *(&coderdenttest.LicenseOptions{}).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120)))
// The claims round-trip through GET /api/v2/entitlements.
//nolint:gocritic // This test asserts license state, not authz behavior.
entitlements, err := client.Entitlements(context.Background())
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 0222095336d..c00449c8b51 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5722,6 +5722,17 @@ export const LicenseAIGovernance90PercentWarningText =
export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
+// From codersdk/licenses.go
+/**
+ * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ * license carries unusable Coder Agent runtime hour claims (see
+ * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ * the license and the dropped claims. The dashboard renders the exact
+ * text as a muted diagnostic without a sales link.
+ */
+export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
+ "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
+
// From codersdk/licenses.go
export const LicenseExpiryClaim = "license_expires";
@@ -5729,6 +5740,18 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
+// From codersdk/licenses.go
+/**
+ * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ * managed agent usage query fails while computing entitlements; the
+ * cause is logged server-side. It travels in the entitlements Errors
+ * channel so the alertable coderd_license_errors gauge counts
+ * measurement failures, but the dashboard recognizes the exact text and
+ * renders it as a muted diagnostic rather than a license error.
+ */
+export const LicenseManagedAgentUsageUnavailableErrorText =
+ "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
+
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index ba5f739d93d..9ef4dda8bb1 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -1,8 +1,10 @@
import type { FC } from "react";
import {
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -24,8 +26,32 @@ const isAIGovernanceWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
message.startsWith(aiGovernanceOverLimitWarningPrefix);
-const isAIGovernanceNearLimitWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix);
+// Substitutes the given values into the template's %d placeholders in order.
+// No other fmt verb, width, or flag is implemented.
+const formatLicenseMessage = (template: string, ...values: number[]): string =>
+ values.reduce(
+ (message, value) => message.replace("%d", `${value}`),
+ template,
+ );
+
+// Diagnostics about the license or the usage measurement rather than about
+// usage itself. They render muted, without the exceedance heading or a sales
+// link. The "unavailable" pair arrives via entitlements.errors but must not
+// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+const diagnosticMessages: readonly string[] = [
+ LicenseManagedAgentUsageUnavailableErrorText,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
+];
+
+const isDiagnosticMessage = (message: string): boolean =>
+ diagnosticMessages.includes(message);
+
+// Advisories and diagnostics render in the muted variant: nothing is wrong
+// yet, so they must be visually distinct from warnings that demand action,
+// such as exceeding a license limit.
+const isMutedWarning = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
+ isDiagnosticMessage(message);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -48,9 +74,12 @@ const aiGovernanceOverLimitMessage = (
}
const overLimitSeats = actual - limit;
- return LicenseAIGovernanceOverLimitWarningText.replace("%d", `${actual}`)
- .replace("%d", `${limit}`)
- .replace("%d", `${overLimitSeats}`);
+ return formatLicenseMessage(
+ LicenseAIGovernanceOverLimitWarningText,
+ actual,
+ limit,
+ overLimitSeats,
+ );
};
const aiGovernanceNearLimitMessage = (
@@ -99,7 +128,7 @@ const normalizeAIGovernanceWarning = (
);
};
-const messageLink = (message: string): LicenseBannerLink => {
+const messageLink = (message: string): LicenseBannerLink | undefined => {
if (message === LicenseManagedAgentLimitExceededWarningText) {
return {
href: docs("/ai-coder/ai-governance"),
@@ -115,6 +144,11 @@ const messageLink = (message: string): LicenseBannerLink => {
showExternalIcon: false,
};
}
+ // Diagnostics point the operator at the logs or support, so they do not
+ // get a sales link.
+ if (isDiagnosticMessage(message)) {
+ return undefined;
+ }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -146,12 +180,16 @@ export const LicenseBanner: FC = () => {
const messages: LicenseBannerMessage[] = [
...errors.map((message) => ({
message,
- variant: "error" as const,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message)
+ ? ("warning" as const)
+ : ("error" as const),
link: messageLink(message),
})),
...normalizedWarnings.map((message) => ({
message,
- variant: isAIGovernanceNearLimitWarning(message)
+ variant: isMutedWarning(message)
? ("warning" as const)
: ("warningProminent" as const),
link: messageLink(message),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 582493a4eff..6c3dd4e662b 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import {
+ type Entitlements,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -180,30 +183,24 @@ export const ManagedAgentLimitExceededWithOtherWarnings: Story = {
},
};
-const renderLicenseBannerWithAIGovernance = ({
- actual,
- entitlement = "entitled",
- limit,
+const renderLicenseBanner = ({
+ errors = [],
warnings = [],
+ features = {},
}: {
- actual: number;
- entitlement?: "entitled" | "grace_period" | "not_entitled";
- limit?: number;
+ errors?: string[];
warnings?: string[];
+ features?: Partial;
}) => {
const mockDashboardValue: DashboardValue = {
entitlements: {
...MockEntitlements,
has_license: true,
+ errors,
warnings,
features: {
...MockEntitlements.features,
- ai_governance_user_limit: {
- enabled: true,
- entitlement,
- actual,
- ...(limit !== undefined ? { limit } : {}),
- },
+ ...features,
},
},
experiments: MockExperiments,
@@ -215,12 +212,39 @@ const renderLicenseBannerWithAIGovernance = ({
};
return (
-
+
-
+
);
};
+const renderLicenseBannerWithAIGovernance = ({
+ actual,
+ entitlement = "entitled",
+ limit,
+ warnings = [],
+}: {
+ actual: number;
+ entitlement?: "entitled" | "grace_period" | "not_entitled";
+ limit?: number;
+ warnings?: string[];
+}) =>
+ renderLicenseBanner({
+ warnings,
+ features: {
+ ai_governance_user_limit: {
+ enabled: true,
+ entitlement,
+ actual,
+ ...(limit !== undefined ? { limit } : {}),
+ },
+ },
+ });
+
+// Without the data-variant assertions, every story would keep passing with
+// the muted/prominent classifier disabled.
+const mutedVariant = "warning";
+
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -230,9 +254,13 @@ export const AIGovernanceNearLimit: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByRole("status")).toHaveTextContent(
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
+ // Pins the AI Governance near-limit branch of isMutedWarning,
+ // independently of the runtime soft-limit branch below.
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -267,3 +295,56 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
);
},
};
+
+// Each entry of the frontend's diagnosticMessages set is pinned on both
+// properties the set drives: the muted variant and the suppressed sales
+// link. The "unavailable" message arrives on the errors channel; see the
+// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+const playMutedDiagnostic =
+ (message: string): Story["play"] =>
+ async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(message);
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(
+ canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
+ ).not.toBeInTheDocument();
+ };
+
+export const ManagedAgentUsageUnavailable: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ }),
+ play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
+};
+
+export const AgentRuntimeHoursClaimsIgnored: Story = {
+ render: () =>
+ renderLicenseBanner({
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText),
+};
+
+// An all-diagnostic banner must not claim license limits were exceeded.
+export const UsageDiagnosticsOnlyHeading: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByText("License notices")).toBeInTheDocument();
+ await expect(
+ canvas.queryByText("Your license limits have been exceeded"),
+ ).not.toBeInTheDocument();
+ await expect(
+ canvas.queryByText("License errors require attention"),
+ ).not.toBeInTheDocument();
+ },
+};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 80674fcbb7e..bd278dc591c 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,10 +70,19 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-const bannerTitle = (variant: LicenseBannerVariant): string =>
- variant === "error"
- ? "License errors require attention"
- : "Your license limits have been exceeded";
+// The muted variant only wins when every message is muted (see
+// getBannerVariant), which means advisories and diagnostics: nothing has
+// been exceeded, so the heading must not assert exceedance.
+const bannerTitle = (variant: LicenseBannerVariant): string => {
+ switch (variant) {
+ case "error":
+ return "License errors require attention";
+ case "warningProminent":
+ return "Your license limits have been exceeded";
+ case "warning":
+ return "License notices";
+ }
+};
const bannerRole = (variant: LicenseBannerVariant): "alert" | "status" =>
variant === "error" ? "alert" : "status";
@@ -142,6 +151,9 @@ export const LicenseBannerView: React.FC = ({
return (
From 5c5a92841d80daff2298d90e75afbb09a7c06c90 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 05:47:20 +0000
Subject: [PATCH 030/116] feat(enterprise/coderd/license): decode -1 runtime
hours allocation as unlimited
An agent_runtime_hours_allocation claim of exactly -1 now grants the
agent_runtime_hours feature enabled with a nil Limit, which the
entitlements API serves as an omitted "limit" field, the shape the UI
already renders as Unlimited. Threshold claims alongside it are dropped
with the existing claims-ignored warning, and any other negative
allocation remains unusable.
---
enterprise/coderd/license/license.go | 36 ++++++++-
enterprise/coderd/license/license_test.go | 90 ++++++++++++++++++++++-
2 files changed, 122 insertions(+), 4 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index e6f3598475e..8911f695d3d 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -932,9 +932,10 @@ const (
// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit. A
- // negative allocation is ignored, in which case the license does not
- // grant the feature.
+ // allocation for the license term. It becomes the feature's Limit.
+ // AgentRuntimeHoursUnlimitedAllocation (-1) is reserved to mean
+ // unlimited; any other negative allocation is ignored, in which case
+ // the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
// becomes the feature's SoftLimit when 0 < soft < allocation and is
@@ -946,6 +947,12 @@ const (
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
+// AgentRuntimeHoursUnlimitedAllocation is the reserved
+// ClaimAgentRuntimeHoursAllocation value meaning the license grants
+// unlimited runtime hours. It decodes to an enabled feature with a nil
+// Limit. Mirrored in github.com/coder/license.
+const AgentRuntimeHoursUnlimitedAllocation int64 = -1
+
var (
ValidMethods = []string{"EdDSA"}
@@ -991,6 +998,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// claims, but Actual is still measured and published. CODAGT-856 will make a
// zero allocation force a concurrency-limited mode; that mode does not exist
// yet.
+//
+// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
+// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
+// have nothing to threshold against, so they are dropped with the warning,
+// keeping an incorrectly issued license visible. Note that
+// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
+// issued-at and expiry tie a metered license outranks an unlimited one; ties
+// never happen for separately issued licenses, so this edge is documented
+// rather than special-cased.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
@@ -1000,6 +1016,20 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+ if allocOk && allocation == AgentRuntimeHoursUnlimitedAllocation {
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{
+ Enabled: true,
+ Entitlement: entitlement,
+ UsagePeriod: &usagePeriod,
+ }, true, ignoredClaims
+ }
+
if !allocOk || allocation < 0 {
if allocOk && allocation < 0 {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index db68fb8b60a..0e393939c5d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2501,6 +2501,55 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
require.NotNil(t, feature.UsagePeriod)
})
+ // An unlimited (-1) allocation grants the feature enabled with no Limit,
+ // which the API serves as an omitted "limit" field, the shape the UI
+ // already renders as "Unlimited".
+ t.Run("UnlimitedAllocation", func(t *testing.T) {
+ t.Parallel()
+
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ )
+ require.NoError(t, err)
+ require.Empty(t, entitlements.Errors)
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+
+ // The entitlements JSON served by GET /api/v2/entitlements omits
+ // "limit" entirely for the unlimited feature.
+ data, err := json.Marshal(entitlements)
+ require.NoError(t, err)
+ var raw struct {
+ Features map[codersdk.FeatureName]map[string]any `json:"features"`
+ }
+ require.NoError(t, json.Unmarshal(data, &raw))
+ rawFeature := raw.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, true, rawFeature["enabled"])
+ require.NotContains(t, rawFeature, "limit")
+ require.Contains(t, rawFeature, "usage_period")
+ })
+
// The license with the newest issued-at claim wins, even if another
// license was loaded first or has a larger allocation. The soft and hard
// limits come from the winning license.
@@ -2853,9 +2902,48 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
expectClaimsIgnored: true,
},
{
+ // An unlimited allocation grants the feature with no Limit and
+ // no warning: -1 is the canonical unlimited encoding, not an
+ // issuance mistake.
+ name: "UnlimitedAllocation",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ },
+ {
+ // Threshold claims alongside an unlimited allocation have
+ // nothing to threshold against; the grant survives but the
+ // issuance mistake must stay visible via the warning.
+ name: "UnlimitedWithSoft",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ name: "UnlimitedWithHard",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ // Only exactly -1 is the unlimited sentinel; any other negative
+ // allocation stays unusable.
name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -2,
},
expectClaimsIgnored: true,
},
From 1ec6150aaa5e74b8221f65fa9e24252923cbc4bb Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:07:05 +0000
Subject: [PATCH 031/116] chore: remove data-variant
---
.../LicenseBannerView.stories.tsx | 21 +++++++------------
.../LicenseBanner/LicenseBannerView.tsx | 3 ---
2 files changed, 8 insertions(+), 16 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 6c3dd4e662b..4f2030a3080 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -59,6 +59,10 @@ export const TwoWarnings: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
+ await expect(
+ canvas.getByText("Your license limits have been exceeded"),
+ ).toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: "Show more" }),
).not.toBeInTheDocument();
@@ -241,10 +245,6 @@ const renderLicenseBannerWithAIGovernance = ({
},
});
-// Without the data-variant assertions, every story would keep passing with
-// the muted/prominent classifier disabled.
-const mutedVariant = "warning";
-
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -258,9 +258,6 @@ export const AIGovernanceNearLimit: Story = {
await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
- // Pins the AI Governance near-limit branch of isMutedWarning,
- // independently of the runtime soft-limit branch below.
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -296,17 +293,16 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
},
};
-// Each entry of the frontend's diagnosticMessages set is pinned on both
-// properties the set drives: the muted variant and the suppressed sales
+// Each diagnostic pins role=status (not alert) and a suppressed sales
// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
+// mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
const canvas = within(canvasElement);
const banner = canvas.getByRole("status");
await expect(banner).toHaveTextContent(message);
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
).not.toBeInTheDocument();
@@ -337,8 +333,7 @@ export const UsageDiagnosticsOnlyHeading: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const banner = canvas.getByRole("status");
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(canvas.getByText("License notices")).toBeInTheDocument();
await expect(
canvas.queryByText("Your license limits have been exceeded"),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index bd278dc591c..453b925fe6d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -151,9 +151,6 @@ export const LicenseBannerView: React.FC = ({
return (
From 2ed2fd55c6d4b0894a6455b78e86c07c47ca259f Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:42:51 +0000
Subject: [PATCH 032/116] chore: remove and simplify explanatory comments
---
codersdk/licenses.go | 23 ++------
.../coderd/coderdenttest/coderdenttest.go | 4 --
enterprise/coderd/license/license.go | 57 +++++++------------
enterprise/coderd/license/license_test.go | 6 +-
site/src/api/typesGenerated.ts | 15 -----
.../dashboard/LicenseBanner/LicenseBanner.tsx | 8 +--
.../LicenseBannerView.stories.tsx | 4 +-
.../LicenseBanner/LicenseBannerView.tsx | 5 +-
8 files changed, 33 insertions(+), 89 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 7aecbaa3089..6f277f17510 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -12,23 +12,12 @@ import (
)
const (
- LicenseExpiryClaim = "license_expires"
- LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
- LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
- LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
- LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- // managed agent usage query fails while computing entitlements; the
- // cause is logged server-side. It travels in the entitlements Errors
- // channel so the alertable coderd_license_errors gauge counts
- // measurement failures, but the dashboard recognizes the exact text and
- // renders it as a muted diagnostic rather than a license error.
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
- // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- // license carries unusable Coder Agent runtime hour claims (see
- // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- // the license and the dropped claims. The dashboard renders the exact
- // text as a muted diagnostic without a sales link.
+ LicenseExpiryClaim = "license_expires"
+ LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
+ LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
+ LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
+ LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 8f5f1e4a708..1d2678fd787 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,10 +237,6 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
-// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
-// or hardLimit omits that claim, which a license is allowed to do; a non-nil
-// value is emitted verbatim, so tests can express explicit zero or negative
-// claims.
func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
if softLimit != nil {
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8911f695d3d..66b55f1feea 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -130,8 +130,6 @@ func Entitlements(
}
type FeatureArguments struct {
- // Logger receives the causes behind operator-facing diagnostics whose
- // published message is a stable text. The zero value discards them.
Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
@@ -587,8 +585,6 @@ func LicensesEntitlements(
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
}
if len(ignoredClaims) > 0 {
- // The published warning is a stable text, so the details a
- // support case needs go to the log.
featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
slog.F("license_id", license.UUID),
slog.F("ignored_claims", ignoredClaims),
@@ -721,9 +717,8 @@ func LicensesEntitlements(
}
if ok {
agentLimit.Actual = &managedAgentCount
- // Written back directly: the feature contest is already
- // settled, so AddFeature's Compare must not get a chance to
- // drop the write.
+ // Write directly rather than via AddFeature so its Compare
+ // cannot drop the update.
entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
@@ -863,12 +858,10 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs one usage query over the feature's usage period and owns
-// the shared failure policy: a nil fn is a wiring bug and fails the whole
-// LicensesEntitlements call; a failure with a dead context fails the call
-// without logging; any other failure logs the cause and publishes the stable
-// unavailableText instead. It returns the measured value and true only on
-// success.
+// measureUsage runs fn over the feature's usage period. A nil fn or a
+// failure with a dead context fails the whole call; any other failure logs
+// the cause and publishes unavailableText instead. It returns the measured
+// value and true only on success.
func measureUsage(
ctx context.Context,
entitlements *codersdk.Entitlements,
@@ -884,12 +877,11 @@ func measureUsage(
value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
switch {
case err != nil && ctx.Err() != nil:
- // The computation's own context is dead, so abort the whole call
- // without logging. Do not classify by error shape instead: Postgres
- // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
- // as well as client cancels, and aborting on those would fail every
- // entitlements refresh on a deployment whose statement_timeout is
- // shorter than a usage query.
+ // Do not classify cancellation by error shape instead of ctx.Err():
+ // Postgres raises SQLSTATE 57014 (query_canceled) for
+ // statement_timeout kills as well as client cancels, and aborting on
+ // those would fail every entitlements refresh on a deployment whose
+ // statement_timeout is shorter than a usage query.
return 0, false, xerrors.Errorf("get %s: %w", what, err)
case err != nil:
logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
@@ -987,26 +979,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// allocation claim; per-claim validity rules live on the Claim* constants
// above.
//
-// Unusable claims are dropped, never license-invalidating: rejecting a
-// signed license over a cosmetic threshold claim would drop the deployment
-// to unlicensed. ignoredClaims names each dropped claim (including the
-// feature name itself minted as a claim, the most plausible issuer mistake)
-// so the caller can warn and log instead of letting an incorrectly issued
-// license look healthy.
+// Unusable claims are dropped rather than invalidating the license, since
+// rejecting a signed license over a cosmetic claim would drop the deployment
+// to unlicensed. Each dropped claim is returned in ignoredClaims so the
+// caller can warn and log instead of letting an incorrectly issued license
+// look healthy.
//
-// A zero allocation grants the feature disabled and drops both threshold
-// claims, but Actual is still measured and published. CODAGT-856 will make a
-// zero allocation force a concurrency-limited mode; that mode does not exist
-// yet.
-//
-// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
-// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
-// have nothing to threshold against, so they are dropped with the warning,
-// keeping an incorrectly issued license visible. Note that
-// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
-// issued-at and expiry tie a metered license outranks an unlimited one; ties
-// never happen for separately issued licenses, so this edge is documented
-// rather than special-cased.
+// A zero allocation grants the feature disabled, but Actual is still
+// measured and published. CODAGT-856 will make a zero allocation force a
+// concurrency-limited mode; that mode does not exist yet.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 0e393939c5d..ec8e06b2f8d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -986,8 +986,7 @@ func TestEntitlements(t *testing.T) {
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
- // The failure surfaces its stable text without the raw cause,
- // on the channel the codersdk constant docs prescribe.
+ // The failure surfaces its stable text without the raw cause.
require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
require.NotContains(t, entry, "kaboom")
@@ -1657,8 +1656,7 @@ func TestLicenseEntitlements(t *testing.T) {
},
},
{
- // A query failure is surfaced as a stable text in Errors (see
- // the codersdk constant docs for the channel choice) and
+ // A query failure is surfaced as a stable text in Errors and
// leaves Actual unset without aborting the rest of the
// entitlements.
Name: "ManagedAgentLimit/QueryError",
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index c00449c8b51..677438bcfa4 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5723,13 +5723,6 @@ export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
// From codersdk/licenses.go
-/**
- * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- * license carries unusable Coder Agent runtime hour claims (see
- * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- * the license and the dropped claims. The dashboard renders the exact
- * text as a muted diagnostic without a sales link.
- */
export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
"A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
@@ -5741,14 +5734,6 @@ export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
// From codersdk/licenses.go
-/**
- * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- * managed agent usage query fails while computing entitlements; the
- * cause is logged server-side. It travels in the entitlements Errors
- * channel so the alertable coderd_license_errors gauge counts
- * measurement failures, but the dashboard recognizes the exact text and
- * renders it as a muted diagnostic rather than a license error.
- */
export const LicenseManagedAgentUsageUnavailableErrorText =
"Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 9ef4dda8bb1..74ce21b89fa 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -36,8 +36,7 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// Diagnostics about the license or the usage measurement rather than about
// usage itself. They render muted, without the exceedance heading or a sales
-// link. The "unavailable" pair arrives via entitlements.errors but must not
-// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
@@ -46,9 +45,8 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render in the muted variant: nothing is wrong
-// yet, so they must be visually distinct from warnings that demand action,
-// such as exceeding a license limit.
+// Advisories and diagnostics render muted to stay visually distinct from
+// warnings that demand action, such as exceeding a license limit.
const isMutedWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
isDiagnosticMessage(message);
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 4f2030a3080..7c2e67c9a71 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -294,9 +294,7 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
};
// Each diagnostic pins role=status (not alert) and a suppressed sales
-// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
-// mutedness is covered by the visual snapshot.
+// link. Background mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 453b925fe6d..2cef6164395 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,9 +70,8 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-// The muted variant only wins when every message is muted (see
-// getBannerVariant), which means advisories and diagnostics: nothing has
-// been exceeded, so the heading must not assert exceedance.
+// The muted "warning" variant means every message is an advisory or
+// diagnostic, so the heading must not assert exceedance.
const bannerTitle = (variant: LicenseBannerVariant): string => {
switch (variant) {
case "error":
From 3ea009af14e741a87e4ccdeb3e97d44f310f4328 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 09:40:53 +0000
Subject: [PATCH 033/116] fix: make unlimited usage period features outrank
metered ones on ties
A nil Limit on a usage period feature means unlimited, so Feature.Compare now ranks it above any set limit when issued-at and end dates tie. Also types the LicenseBanner map callbacks as LicenseBannerMessage instead of using as const assertions.
---
codersdk/deployment.go | 12 +++-
codersdk/deployment_test.go | 25 ++++++++
enterprise/coderd/license/license_test.go | 60 +++++++++++++++++++
.../dashboard/LicenseBanner/LicenseBanner.tsx | 32 +++++-----
4 files changed, 111 insertions(+), 18 deletions(-)
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index bba55f81bec..8a60e11415f 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -425,7 +425,7 @@ type UsagePeriod struct {
// 2. The usage period has a greater end date (note: only certain features use usage periods)
// 3. Graceful & capable > Entitled & not capable (only if both have "Actual" values)
// 4. The entitlement is greater
-// 5. The limit is greater
+// 5. The limit is greater (except a nil limit on a usage period feature means unlimited, outranking any set limit)
// 6. Enabled is greater than disabled
// 7. The actual is greater
//
@@ -469,11 +469,19 @@ func (f Feature) Compare(b Feature) int {
return entitlementDifference
}
- // If the entitlement is the same, then we can compare the limits.
+ // If the entitlement is the same, then we can compare the limits. A nil
+ // limit on a usage period feature means unlimited, so it outranks any set
+ // limit; on other features a nil limit loses to a set one.
if f.Limit == nil && b.Limit != nil {
+ if bothHaveUsagePeriod {
+ return 1
+ }
return -1
}
if f.Limit != nil && b.Limit == nil {
+ if bothHaveUsagePeriod {
+ return -1
+ }
return 1
}
if f.Limit != nil && b.Limit != nil {
diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go
index de3395aac84..2abdff578e9 100644
--- a/codersdk/deployment_test.go
+++ b/codersdk/deployment_test.go
@@ -1225,6 +1225,31 @@ func TestFeatureComparison(t *testing.T) {
},
Expected: 1,
},
+ {
+ // A nil limit on a usage period feature means unlimited, so it
+ // outranks a set limit on an exact usage period tie.
+ Name: "UnlimitedUsagePeriodOutranksMeteredOnTie",
+ A: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ B: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ Limit: ptr.Ref(int64(100)),
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ Expected: 1,
+ },
}
for _, tc := range testCases {
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index ec8e06b2f8d..68bff19da49 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2616,6 +2616,66 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
}
})
+ // When an unlimited and a metered license are minted with identical
+ // issued-at and expiry claims, the unlimited grant must win the tie,
+ // regardless of load order.
+ t.Run("UnlimitedOutranksMeteredOnTie", func(t *testing.T) {
+ t.Parallel()
+
+ // JWT NumericDate claims have second granularity, so truncate to
+ // keep the round-tripped issued-at values identical.
+ iat := time.Now().Add(-time.Minute).Truncate(time.Second)
+ nbf := iat
+ exp := iat.Add(time.Hour).Truncate(time.Second)
+ unlimited := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+ metered := database.License{
+ ID: 2,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ }),
+ }
+
+ for _, order := range [][]database.License{
+ {unlimited, metered},
+ {metered, unlimited},
+ } {
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
+ require.NoError(t, err)
+
+ feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+ }
+ })
+
// A newer license without soft/hard limits must fully replace an older
// license that carried them; the limits must not merge across licenses.
t.Run("SoftHardRideAlongWithWinner", func(t *testing.T) {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 74ce21b89fa..2218aa5cf35 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -176,22 +176,22 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map((message) => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message)
- ? ("warning" as const)
- : ("error" as const),
- link: messageLink(message),
- })),
- ...normalizedWarnings.map((message) => ({
- message,
- variant: isMutedWarning(message)
- ? ("warning" as const)
- : ("warningProminent" as const),
- link: messageLink(message),
- })),
+ ...errors.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message) ? "warning" : "error",
+ link: messageLink(message),
+ }),
+ ),
+ ...normalizedWarnings.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ variant: isMutedWarning(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ }),
+ ),
];
if (messages.length === 0) {
From 4b22ef060f4c05be0e62b7bfc4259861e65803fa Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:10:13 +0000
Subject: [PATCH 034/116] fix(enterprise/coderd/license): accept issuer-valid
zero soft limit for agent runtime hours
The license issuer validates the soft limit as 0 <= soft < allocation,
but this decoder dropped soft=0 as an unusable claim and warned. Accept
zero so every license the issuer mints decodes cleanly; omitting the
claim remains the way to express no soft limit.
---
enterprise/coderd/license/license.go | 4 ++--
enterprise/coderd/license/license_test.go | 13 ++++++-------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 66b55f1feea..f2a6924bf57 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -930,7 +930,7 @@ const (
// the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // becomes the feature's SoftLimit when 0 <= soft < allocation and is
// ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
// ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
@@ -1031,7 +1031,7 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
UsagePeriod: &usagePeriod,
}
if softOk {
- if soft > 0 && soft < allocation {
+ if soft >= 0 && soft < allocation {
feature.SoftLimit = &soft
} else {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 68bff19da49..66c46b3b691 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2857,20 +2857,19 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
},
},
{
- // A zero soft limit would warn at zero usage forever, so it is
- // dropped rather than rejecting the license. The canonical way
- // to express "no soft limit" is omitting the claim, so a
- // present-but-dropped zero still warns.
+ // A zero soft limit is valid (0 <= soft < allocation) and warns
+ // from the start of the usage period. Omitting the claim is the
+ // way to express "no soft limit".
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
expectFeature: &codersdk.Feature{
- Enabled: true,
- Limit: ptr.Ref[int64](100),
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](0),
},
- expectClaimsIgnored: true,
},
{
name: "NegativeSoft",
From 8fcb4b3f71b4cf91c55c043c3776406131ba676b Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Thu, 13 Aug 2026 04:37:09 +0000
Subject: [PATCH 035/116] fix(coderd/notifications/dispatch): drop unused
markdown import in smtp test
The revert in cb36cfae2c5 left behind an unused
"github.com/coder/coder/v2/coderd/render" import, breaking compilation
of the dispatch test package and failing fmt, lint, and every
test-go-pg job. Restore the import block to match main.
---
coderd/notifications/dispatch/smtp_internal_test.go | 2 --
1 file changed, 2 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 03324f78573..2e7dff8cbec 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -7,8 +7,6 @@ import (
"github.com/stretchr/testify/require"
- markdown "github.com/coder/coder/v2/coderd/render"
-
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
)
From a4bc61e5995871f1157795ddd366d3f3d529fda8 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:58:22 +0000
Subject: [PATCH 036/116] feat: report stable license measurement diagnostics
and tolerate unusable runtime hours claims
Two coupled changes to the license/entitlements layer.
Measurement diagnostics: extract a measureUsage helper that owns the
usage-query failure policy. A query failure now publishes the stable
codersdk.LicenseManagedAgentUsageUnavailableErrorText on the
entitlements Errors channel (keeping the alertable coderd_license_errors
gauge counting) while the raw cause goes to the coderd log through the
new FeatureArguments.Logger. A nil usage closure becomes a hard
developer error, and a failure with a dead context still aborts the
whole refresh. The managed agent count closure switches from
AsSystemRestricted to the narrower AsUsagePublisher subject. The
dashboard recognizes diagnostic texts and renders them muted, without
the exceedance heading or a sales link.
Claim tolerance: unusable agent_runtime_hours_* claim combinations no
longer reject the whole license. decodeAgentRuntimeHours drops the
unusable claims, reports them in a stable
LicenseAgentRuntimeHoursClaimsIgnoredWarningText warning (deduplicated
across licenses), and logs the affected license and claims;
validateAgentRuntimeHours and its license-invalidating errors are
removed. Rejecting a signed license over a cosmetic threshold claim
would drop the deployment to unlicensed.
Part of the stack for coder/coder#27796.
---
codersdk/licenses.go | 13 +
enterprise/coderd/coderd_test.go | 6 +
.../coderd/coderdenttest/coderdenttest.go | 15 +
enterprise/coderd/license/license.go | 228 +++++----
enterprise/coderd/license/license_test.go | 476 +++++++++++++++---
enterprise/coderd/licenses_test.go | 35 +-
site/src/api/typesGenerated.ts | 23 +
.../dashboard/LicenseBanner/LicenseBanner.tsx | 54 +-
.../LicenseBannerView.stories.tsx | 113 ++++-
.../LicenseBanner/LicenseBannerView.tsx | 20 +-
10 files changed, 762 insertions(+), 221 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index f2ccf009842..7aecbaa3089 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,6 +17,19 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ // managed agent usage query fails while computing entitlements; the
+ // cause is logged server-side. It travels in the entitlements Errors
+ // channel so the alertable coderd_license_errors gauge counts
+ // measurement failures, but the dashboard recognizes the exact text and
+ // renders it as a muted diagnostic rather than a license error.
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
+ // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ // license carries unusable Coder Agent runtime hour claims (see
+ // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ // the license and the dropped claims. The dashboard renders the exact
+ // text as a muted diagnostic without a sales link.
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
type AddLicenseRequest struct {
diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go
index e023da7b1df..7be01405353 100644
--- a/enterprise/coderd/coderd_test.go
+++ b/enterprise/coderd/coderd_test.go
@@ -92,6 +92,12 @@ func TestEntitlements(t *testing.T) {
// Enable all features
features := make(license.Features)
for _, feature := range codersdk.FeatureNames {
+ if feature == codersdk.FeatureAgentRuntimeHours {
+ // The feature name is not a valid license claim; the
+ // feature is encoded as its allocation claim.
+ features[license.ClaimAgentRuntimeHoursAllocation] = 1
+ continue
+ }
features[feature] = 1
}
features[codersdk.FeatureUserLimit] = 100
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 1115ba12118..8f5f1e4a708 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,6 +237,21 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
+// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
+// or hardLimit omits that claim, which a license is allowed to do; a non-nil
+// value is emitted verbatim, so tests can express explicit zero or negative
+// claims.
+func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
+ opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
+ if softLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitSoft, *softLimit)
+ }
+ if hardLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitHard, *hardLimit)
+ }
+ return opts
+}
+
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
if opts.Features == nil {
opts.Features = license.Features{}
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8cce657de65..e6f3598475e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -93,6 +93,7 @@ func Entitlements(
}
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
+ Logger: logger,
ActiveUserCount: activeUserCount,
ActiveAISeatCount: activeAISeatCount,
ReplicaCount: replicaCount,
@@ -114,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Reading usage events requires the usage publisher subject.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -129,6 +130,9 @@ func Entitlements(
}
type FeatureArguments struct {
+ // Logger receives the causes behind operator-facing diagnostics whose
+ // published message is a stable text. The zero value discards them.
+ Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
ReplicaCount int
@@ -508,11 +512,8 @@ func LicensesEntitlements(
continue
}
- // Agent runtime hours are encoded as up to three claims and are
- // decoded together after this loop, see
- // decodeAgentRuntimeHours. The feature name itself is never a
- // valid claim. The allocation must come from the dedicated claim
- // so it is validated against the soft and hard limits.
+ // Agent runtime hour claims are decoded together after this
+ // loop; see decodeAgentRuntimeHours.
if featureName == codersdk.FeatureAgentRuntimeHours ||
isAgentRuntimeHoursClaim(featureName) {
continue
@@ -577,14 +578,25 @@ func LicensesEntitlements(
}
}
- // The loop above skips Agent runtime hours because the
- // three claims that encode them decode into a single feature.
- if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
+ runtimeFeature, granted, ignoredClaims := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
- }); ok {
- entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature)
+ })
+ if granted {
+ entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
+ }
+ if len(ignoredClaims) > 0 {
+ // The published warning is a stable text, so the details a
+ // support case needs go to the log.
+ featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
+ slog.F("license_id", license.UUID),
+ slog.F("ignored_claims", ignoredClaims),
+ )
+ if !slices.Contains(entitlements.Warnings, codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) {
+ entitlements.Warnings = append(entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ }
}
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
@@ -701,24 +713,18 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- var (
- managedAgentCount int64
- err = xerrors.New("dev error: managed agent count function is not set")
- )
- if featureArguments.ManagedAgentCountFn != nil {
- managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
- }
- if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
- // If the context is canceled, we want to bail the entire
- // LicensesEntitlements call.
- return entitlements, xerrors.Errorf("get managed agent count: %w", err)
- }
+ managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
+ featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
+ "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
if err != nil {
- entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
- // no return
- } else {
+ return entitlements, err
+ }
+ if ok {
agentLimit.Actual = &managedAgentCount
- entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
+ // Written back directly: the feature contest is already
+ // settled, so AddFeature's Compare must not get a chance to
+ // drop the write.
+ entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -857,6 +863,42 @@ func LicensesEntitlements(
return entitlements, nil
}
+// measureUsage runs one usage query over the feature's usage period and owns
+// the shared failure policy: a nil fn is a wiring bug and fails the whole
+// LicensesEntitlements call; a failure with a dead context fails the call
+// without logging; any other failure logs the cause and publishes the stable
+// unavailableText instead. It returns the measured value and true only on
+// success.
+func measureUsage(
+ ctx context.Context,
+ entitlements *codersdk.Entitlements,
+ logger slog.Logger,
+ fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
+ usagePeriod codersdk.UsagePeriod,
+ what string,
+ unavailableText string,
+) (int64, bool, error) {
+ if fn == nil {
+ return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
+ }
+ value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
+ switch {
+ case err != nil && ctx.Err() != nil:
+ // The computation's own context is dead, so abort the whole call
+ // without logging. Do not classify by error shape instead: Postgres
+ // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
+ // as well as client cancels, and aborting on those would fail every
+ // entitlements refresh on a deployment whose statement_timeout is
+ // shorter than a usage query.
+ return 0, false, xerrors.Errorf("get %s: %w", what, err)
+ case err != nil:
+ logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
+ entitlements.Errors = append(entitlements.Errors, unavailableText)
+ return 0, false, nil
+ }
+ return value, true, nil
+}
+
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
@@ -885,23 +927,22 @@ const (
VersionClaim = "version"
)
-// Agent runtime hour license claims. These are the canonical claim names
-// minted by github.com/coder/license. All three claims map to the single
-// codersdk.FeatureAgentRuntimeHours feature and are validated together when
-// the license is parsed, see validateClaims.
-//
-// The unit for all three claims is hours.
+// Agent runtime hour license claims, minted by github.com/coder/license.
+// All three are in hours and decode together into the single
+// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit.
+ // allocation for the license term. It becomes the feature's Limit. A
+ // negative allocation is ignored, in which case the license does not
+ // grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // must satisfy 0 <= soft < allocation, so it may only be set when the
- // allocation is greater than 0. It becomes the feature's SoftLimit.
+ // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
- // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be
- // absent or >= allocation, and may only be set when the allocation is
- // greater than 0. It becomes the feature's HardLimit.
+ // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
+ // the feature's HardLimit when the allocation is greater than 0 and
+ // hard >= allocation, and is ignored otherwise.
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
@@ -917,19 +958,12 @@ var (
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
ErrMissingAccountType = xerrors.New("license must contain valid account type")
ErrMissingAccountID = xerrors.New("license must contain valid account ID")
-
- ErrMissingAgentRuntimeHoursAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation)
- ErrAgentRuntimeHoursLimitsWithZeroAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but the %s claim is 0", ClaimAgentRuntimeHoursAllocation)
)
type Features map[codersdk.FeatureName]int64
-// isAgentRuntimeHoursClaim reports whether the claim name is one of the three
-// claims that encode the codersdk.FeatureAgentRuntimeHours feature. These
-// claims are decoded together, see decodeAgentRuntimeHours.
+// isAgentRuntimeHoursClaim reports whether name is one of the three claims
+// decoded by decodeAgentRuntimeHours.
func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
switch name {
case ClaimAgentRuntimeHoursAllocation,
@@ -941,62 +975,65 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
}
}
-// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours feature
-// from the claims that encode it. It reports false when the license carries no
-// allocation claim, in which case the license does not grant the feature.
+// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours
+// feature from its claims. granted is false when there is no usable
+// allocation claim; per-claim validity rules live on the Claim* constants
+// above.
+//
+// Unusable claims are dropped, never license-invalidating: rejecting a
+// signed license over a cosmetic threshold claim would drop the deployment
+// to unlicensed. ignoredClaims names each dropped claim (including the
+// feature name itself minted as a claim, the most plausible issuer mistake)
+// so the caller can warn and log instead of letting an incorrectly issued
+// license look healthy.
//
-// The claim combination is validated when the license is parsed, see
-// Features.validateAgentRuntimeHours. The allocation is never negative here
-// and the soft and hard limits are only present alongside a positive
-// allocation.
-func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (codersdk.Feature, bool) {
- allocation, ok := features[ClaimAgentRuntimeHoursAllocation]
- if !ok {
- return codersdk.Feature{}, false
+// A zero allocation grants the feature disabled and drops both threshold
+// claims, but Actual is still measured and published. CODAGT-856 will make a
+// zero allocation force a concurrency-limited mode; that mode does not exist
+// yet.
+func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
+ if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
+ ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
}
- feature := codersdk.Feature{
+ allocation, allocOk := features[ClaimAgentRuntimeHoursAllocation]
+ soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
+ hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+
+ if !allocOk || allocation < 0 {
+ if allocOk && allocation < 0 {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
+ }
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{}, false, ignoredClaims
+ }
+
+ feature = codersdk.Feature{
Enabled: allocation > 0,
Entitlement: entitlement,
Limit: &allocation,
UsagePeriod: &usagePeriod,
}
- if soft, ok := features[ClaimAgentRuntimeHoursLimitSoft]; ok {
- feature.SoftLimit = &soft
- }
- if hard, ok := features[ClaimAgentRuntimeHoursLimitHard]; ok {
- feature.HardLimit = &hard
- }
- return feature, true
-}
-
-// validateAgentRuntimeHours validates the relationship between the agent
-// runtime hour claims. Invalid combinations reject the entire license.
-func (f Features) validateAgentRuntimeHours() error {
- allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation]
- soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft]
- hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard]
- if !hasAllocation {
- if hasSoft || hasHard {
- return ErrMissingAgentRuntimeHoursAllocation
+ if softOk {
+ if soft > 0 && soft < allocation {
+ feature.SoftLimit = &soft
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
}
- return nil
- }
- if allocation < 0 {
- return ErrInvalidAgentRuntimeHoursAllocation
- }
- // A zero allocation disables the feature.
- // A zero hard limit is not permitted.
- if allocation == 0 && (hasSoft || hasHard) {
- return ErrAgentRuntimeHoursLimitsWithZeroAllocation
}
- if hasSoft && (soft < 0 || soft >= allocation) {
- return ErrInvalidAgentRuntimeHoursSoftLimit
- }
- if hasHard && hard < allocation {
- return ErrInvalidAgentRuntimeHoursHardLimit
+ if hardOk {
+ if allocation > 0 && hard >= allocation {
+ feature.HardLimit = &hard
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
}
- return nil
+ return feature, true, ignoredClaims
}
// Claims is the full set of claims in a license.
@@ -1089,9 +1126,6 @@ func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims.AccountID == "" {
return nil, ErrMissingAccountID
}
- if err := claims.Features.validateAgentRuntimeHours(); err != nil {
- return nil, err
- }
return claims, nil
}
return nil, xerrors.New("unable to parse Claims")
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 6fe50902850..db68fb8b60a 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -1,9 +1,11 @@
package license_test
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "maps"
"slices"
"testing"
"time"
@@ -13,12 +15,17 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
+ "golang.org/x/xerrors"
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "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/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -30,6 +37,42 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
+// premiumRuntimeHoursFixture returns a mock store primed with a Premium
+// license carrying runtime hour claims (allocation 100, soft limit 80, hard
+// limit 120) plus the store expectations every entitlements refresh consumes
+// before usage is measured. Callers add expectations for the usage queries
+// under test.
+func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
+ t.Helper()
+
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+
+ licenseOpts := (&coderdenttest.LicenseOptions{
+ FeatureSet: codersdk.FeatureSetPremium,
+ IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
+ NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
+ GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
+ ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
+ // The addon marks AI Bridge as explicitly entitled, suppressing
+ // the unrelated "AI Governance add-on is required to use AI
+ // Gateway" warning that Premium would otherwise produce.
+ }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
+
+ lic := database.License{
+ ID: 1,
+ JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
+ Exp: licenseOpts.ExpiresAt,
+ }
+
+ mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
+ mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
+ mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
+ mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
+
+ return mDB, licenseOpts
+}
+
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -920,6 +963,63 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
+ t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
+ t.Parallel()
+
+ // Drive the real Entitlements closure with a mock database so
+ // measureUsage's failure path is exercised end to end: the cause
+ // must land in the coderd log, which the stable payload text points
+ // at, and must not land on the unauthenticated entitlements payload.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), xerrors.New("kaboom managed"))
+
+ // The error-level logs are the behavior under test, so the default
+ // failing test logger cannot be used.
+ var logBuf bytes.Buffer
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
+ AppendSinks(sloghuman.Sink(&logBuf))
+
+ entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+
+ // The failure surfaces its stable text without the raw cause,
+ // on the channel the codersdk constant docs prescribe.
+ require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
+ for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
+ require.NotContains(t, entry, "kaboom")
+ }
+
+ logs := logBuf.String()
+ require.Contains(t, logs, "get managed agent count for entitlements")
+ require.Contains(t, logs, "kaboom managed")
+ })
+
+ t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
+ t.Parallel()
+
+ // A query failing while the refresh's own context is canceled,
+ // e.g. during shutdown, aborts the whole entitlements refresh and
+ // must not log a false query-failure alarm at error level.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), context.Canceled)
+
+ var logBuf bytes.Buffer
+ logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.ErrorContains(t, err, "get managed agent count")
+ require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
+ })
+
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1299,6 +1399,12 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
+ // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
+ // injection below so the nil dev-error path can be exercised.
+ KeepNilManagedAgentCountFn bool
+ // CancelContext cancels the context passed to LicensesEntitlements
+ // before the call, exercising the usage-measurement abort policy.
+ CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1550,6 +1656,59 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
+ {
+ // A query failure is surfaced as a stable text in Errors (see
+ // the codersdk constant docs for the channel choice) and
+ // leaves Actual unset without aborting the rest of the
+ // entitlements.
+ Name: "ManagedAgentLimit/QueryError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, xerrors.New("kaboom")
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoWarnings(t, entitlements)
+ require.Len(t, entitlements.Errors, 1)
+ assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
+ // The raw error is logged rather than exposed on the
+ // unauthenticated entitlements payload.
+ assert.NotContains(t, entitlements.Errors[0], "kaboom")
+ feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
+ assert.Nil(t, feature.Actual)
+ },
+ },
+ {
+ // Forgetting to wire ManagedAgentCountFn is a dev error:
+ // production always provides the closure, so it fails the whole
+ // call loudly instead of degrading into an operator-facing
+ // message.
+ Name: "ManagedAgentLimit/NilFnDevError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ KeepNilManagedAgentCountFn: true,
+ ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
+ },
+ {
+ // A failure while the computation's own context is canceled
+ // aborts the whole call rather than degrading to an
+ // entitlements error.
+ Name: "ManagedAgentLimit/ContextCanceled",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ CancelContext: true,
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, context.Canceled
+ },
+ },
+ ExpectedErrorContains: "get managed agent count",
+ },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1581,13 +1740,18 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil {
+ if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
-
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+ ctx := context.Background()
+ if tc.CancelContext {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithCancel(ctx)
+ cancel()
+ }
+ entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1612,6 +1776,15 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
+ // A Premium license grants a managed agent limit by default, and a nil
+ // usage closure is a hard developer error, so these subtests wire a
+ // zero-usage measurement closure.
+ zeroUsageArgs := license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
+ }
+
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1631,7 +1804,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1658,7 +1831,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1690,7 +1863,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1721,7 +1894,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1734,7 +1907,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -2535,15 +2708,22 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
})
}
-// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the
-// agent runtime hour claims reject the entire license.
-func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
+// TestAgentRuntimeHoursClaimTolerance pins decodeAgentRuntimeHours's
+// tolerate-and-warn contract; see that function's doc for the rationale.
+func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
t.Parallel()
testCases := []struct {
- name string
- features license.Features
- expectedErr error
+ name string
+ features license.Features
+
+ // expectFeature is nil when the feature must be absent.
+ expectFeature *codersdk.Feature
+ // expectClaimsIgnored is true when at least one present claim is
+ // dropped, which must surface the claims-ignored warning: tolerating
+ // a claim and signaling nothing would make an incorrectly issued license
+ // undetectable from the deployment.
+ expectClaimsIgnored bool
}{
{
name: "AllClaims",
@@ -2552,109 +2732,172 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](80),
+ HardLimit: ptr.Ref[int64](120),
+ },
},
{
name: "AllocationOnly",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
},
{
+ // A zero soft limit would warn at zero usage forever, so it is
+ // dropped rather than rejecting the license. The canonical way
+ // to express "no soft limit" is omitting the claim, so a
+ // present-but-dropped zero still warns.
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "HardEqualsAllocation",
+ name: "NegativeSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocation",
+ // A soft limit at or above the allocation could never fire
+ // before the allocation warning supersedes it.
+ name: "SoftEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithZeroHard",
+ name: "SoftAboveAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithPositiveHard",
+ name: "HardEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 100,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ HardLimit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
- name: "SoftWithoutAllocation",
+ name: "HardBelowAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 99,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardWithoutAllocation",
+ name: "ZeroAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitHard: 120,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
- name: "NegativeAllocation",
+ // A zero allocation has no hour budget, so threshold claims
+ // alongside it are dropped, with the warning.
+ name: "ZeroAllocationWithLimits",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "NegativeSoft",
+ name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -1,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftEqualsAllocation",
+ name: "SoftWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftAboveAllocation",
+ name: "HardWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftWithZeroAllocation",
+ // The feature name itself is never a valid claim: the
+ // allocation must come from the dedicated claim. It is the
+ // shape every other metered feature uses, so a license minting
+ // it is the most plausible issuer mistake and must warn
+ // rather than being dropped silently.
+ name: "FeatureNameAsClaim",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitSoft: 0,
+ codersdk.FeatureAgentRuntimeHours: 100,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardBelowAllocation",
+ // The feature name claim is dropped (with the warning) even
+ // when a usable allocation claim grants the feature.
+ name: "FeatureNameAlongsideAllocation",
features: license.Features{
+ codersdk.FeatureAgentRuntimeHours: 50,
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 99,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
}
@@ -2662,46 +2905,115 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
- jwt := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: tc.features,
- })
- _, err := license.ParseClaims(jwt, coderdenttest.Keys)
- if tc.expectedErr == nil {
- require.NoError(t, err)
+ features := license.Features{
+ codersdk.FeatureUserLimit: 100,
+ }
+ maps.Copy(features, tc.features)
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: features,
+ }),
+ }
+
+ var logBuf bytes.Buffer
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
+ )
+ require.NoError(t, err)
+
+ // The license as a whole survives: unrelated paid features are
+ // unaffected by an unusable runtime hour claim.
+ require.Empty(t, entitlements.Errors)
+ require.True(t, entitlements.HasLicense)
+ userLimit := entitlements.Features[codersdk.FeatureUserLimit]
+ require.NotNil(t, userLimit.Limit)
+ require.EqualValues(t, 100, *userLimit.Limit)
+
+ // Dropped claims are tolerated but never silent: the operator
+ // sees the stable warning, and the log names the license and
+ // the dropped claims for support.
+ if tc.expectClaimsIgnored {
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ logs := logBuf.String()
+ require.Contains(t, logs, "ignored unusable Coder Agent runtime hour claims in license")
+ require.Contains(t, logs, lic.UUID.String())
+ } else {
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ require.Empty(t, logBuf.String())
+ }
+
+ // Every known feature name has a default entry in the map, so
+ // "the license does not grant the feature" surfaces as the
+ // default: no limit, no usage period, not enabled.
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ if tc.expectFeature == nil {
+ require.Nil(t, feature.Limit, "feature must not be granted")
+ require.Nil(t, feature.UsagePeriod, "feature must not be granted")
+ require.False(t, feature.Enabled)
return
}
- require.ErrorIs(t, err, tc.expectedErr)
+ require.NotNil(t, feature.UsagePeriod, "feature must be granted")
+ require.Equal(t, tc.expectFeature.Enabled, feature.Enabled)
+ require.Equal(t, tc.expectFeature.Limit, feature.Limit)
+ require.Equal(t, tc.expectFeature.SoftLimit, feature.SoftLimit)
+ require.Equal(t, tc.expectFeature.HardLimit, feature.HardLimit)
})
}
- // An invalid license already stored in the database is rejected entirely
- // and produces an entitlements error.
- t.Run("EntitlementsError", func(t *testing.T) {
+ t.Run("WarningDeduplicatedAcrossLicenses", func(t *testing.T) {
t.Parallel()
- lic := database.License{
- ID: 1,
- UploadedAt: time.Now(),
- Exp: time.Now().Add(time.Hour),
- UUID: uuid.New(),
- JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
- },
- }),
+ // Two licenses with unusable claims must publish the stable warning
+ // once, or the banner would stack identical texts, while the log
+ // names each affected license so the operator can tell which ones
+ // need re-issuing.
+ newLicense := func(id int32) database.License {
+ return database.License{
+ ID: id,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
+ // A threshold without an allocation is unusable.
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ }),
+ }
}
+ licenses := []database.License{newLicense(1), newLicense(2)}
+ var logBuf bytes.Buffer
entitlements, err := license.LicensesEntitlements(
- context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ context.Background(), time.Now(), licenses,
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
)
require.NoError(t, err)
- require.Len(t, entitlements.Errors, 1)
- require.Contains(t, entitlements.Errors[0], fmt.Sprintf("Invalid license (%s) parsing claims", lic.UUID))
- require.False(t, entitlements.HasLicense)
- feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
- require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
+
+ warningCount := 0
+ for _, warning := range entitlements.Warnings {
+ if warning == codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText {
+ warningCount++
+ }
+ }
+ require.Equal(t, 1, warningCount, "the claims-ignored warning must appear exactly once")
+
+ logs := logBuf.String()
+ for _, lic := range licenses {
+ require.Contains(t, logs, lic.UUID.String())
+ }
})
}
diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go
index c88f975f2c0..811929f0939 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -105,35 +106,41 @@ func TestPostLicense(t *testing.T) {
require.Contains(t, errResp.Message, "Invalid license")
})
- t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) {
+ t.Run("UnusableAgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- // A soft limit claim without an allocation claim rejects the whole
- // license.
+ // A soft limit claim without an allocation claim is unusable, but it
+ // never rejects the whole license: the license stays valid, the
+ // runtime hours feature is simply not granted, and the dropped claim
+ // is surfaced as a warning. See decodeAgentRuntimeHours.
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: lic,
})
- errResp := &codersdk.Error{}
- require.ErrorAs(t, err, &errResp)
- require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
- require.Contains(t, errResp.Message, "Invalid license")
+ require.NoError(t, err)
+ // The claims round-trip through GET /api/v2/entitlements.
+ //nolint:gocritic // This test asserts license state, not authz behavior.
+ entitlements, err := client.Entitlements(context.Background())
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+ require.Empty(t, entitlements.Errors)
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.UsagePeriod)
})
t.Run("AgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
- license.ClaimAgentRuntimeHoursLimitHard: 120,
- },
- })
+ coderdenttest.AddLicense(t, client,
+ *(&coderdenttest.LicenseOptions{}).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120)))
// The claims round-trip through GET /api/v2/entitlements.
//nolint:gocritic // This test asserts license state, not authz behavior.
entitlements, err := client.Entitlements(context.Background())
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 0222095336d..c00449c8b51 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5722,6 +5722,17 @@ export const LicenseAIGovernance90PercentWarningText =
export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
+// From codersdk/licenses.go
+/**
+ * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ * license carries unusable Coder Agent runtime hour claims (see
+ * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ * the license and the dropped claims. The dashboard renders the exact
+ * text as a muted diagnostic without a sales link.
+ */
+export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
+ "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
+
// From codersdk/licenses.go
export const LicenseExpiryClaim = "license_expires";
@@ -5729,6 +5740,18 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
+// From codersdk/licenses.go
+/**
+ * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ * managed agent usage query fails while computing entitlements; the
+ * cause is logged server-side. It travels in the entitlements Errors
+ * channel so the alertable coderd_license_errors gauge counts
+ * measurement failures, but the dashboard recognizes the exact text and
+ * renders it as a muted diagnostic rather than a license error.
+ */
+export const LicenseManagedAgentUsageUnavailableErrorText =
+ "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
+
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index ba5f739d93d..9ef4dda8bb1 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -1,8 +1,10 @@
import type { FC } from "react";
import {
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -24,8 +26,32 @@ const isAIGovernanceWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
message.startsWith(aiGovernanceOverLimitWarningPrefix);
-const isAIGovernanceNearLimitWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix);
+// Substitutes the given values into the template's %d placeholders in order.
+// No other fmt verb, width, or flag is implemented.
+const formatLicenseMessage = (template: string, ...values: number[]): string =>
+ values.reduce(
+ (message, value) => message.replace("%d", `${value}`),
+ template,
+ );
+
+// Diagnostics about the license or the usage measurement rather than about
+// usage itself. They render muted, without the exceedance heading or a sales
+// link. The "unavailable" pair arrives via entitlements.errors but must not
+// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+const diagnosticMessages: readonly string[] = [
+ LicenseManagedAgentUsageUnavailableErrorText,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
+];
+
+const isDiagnosticMessage = (message: string): boolean =>
+ diagnosticMessages.includes(message);
+
+// Advisories and diagnostics render in the muted variant: nothing is wrong
+// yet, so they must be visually distinct from warnings that demand action,
+// such as exceeding a license limit.
+const isMutedWarning = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
+ isDiagnosticMessage(message);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -48,9 +74,12 @@ const aiGovernanceOverLimitMessage = (
}
const overLimitSeats = actual - limit;
- return LicenseAIGovernanceOverLimitWarningText.replace("%d", `${actual}`)
- .replace("%d", `${limit}`)
- .replace("%d", `${overLimitSeats}`);
+ return formatLicenseMessage(
+ LicenseAIGovernanceOverLimitWarningText,
+ actual,
+ limit,
+ overLimitSeats,
+ );
};
const aiGovernanceNearLimitMessage = (
@@ -99,7 +128,7 @@ const normalizeAIGovernanceWarning = (
);
};
-const messageLink = (message: string): LicenseBannerLink => {
+const messageLink = (message: string): LicenseBannerLink | undefined => {
if (message === LicenseManagedAgentLimitExceededWarningText) {
return {
href: docs("/ai-coder/ai-governance"),
@@ -115,6 +144,11 @@ const messageLink = (message: string): LicenseBannerLink => {
showExternalIcon: false,
};
}
+ // Diagnostics point the operator at the logs or support, so they do not
+ // get a sales link.
+ if (isDiagnosticMessage(message)) {
+ return undefined;
+ }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -146,12 +180,16 @@ export const LicenseBanner: FC = () => {
const messages: LicenseBannerMessage[] = [
...errors.map((message) => ({
message,
- variant: "error" as const,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message)
+ ? ("warning" as const)
+ : ("error" as const),
link: messageLink(message),
})),
...normalizedWarnings.map((message) => ({
message,
- variant: isAIGovernanceNearLimitWarning(message)
+ variant: isMutedWarning(message)
? ("warning" as const)
: ("warningProminent" as const),
link: messageLink(message),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 582493a4eff..6c3dd4e662b 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import {
+ type Entitlements,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -180,30 +183,24 @@ export const ManagedAgentLimitExceededWithOtherWarnings: Story = {
},
};
-const renderLicenseBannerWithAIGovernance = ({
- actual,
- entitlement = "entitled",
- limit,
+const renderLicenseBanner = ({
+ errors = [],
warnings = [],
+ features = {},
}: {
- actual: number;
- entitlement?: "entitled" | "grace_period" | "not_entitled";
- limit?: number;
+ errors?: string[];
warnings?: string[];
+ features?: Partial;
}) => {
const mockDashboardValue: DashboardValue = {
entitlements: {
...MockEntitlements,
has_license: true,
+ errors,
warnings,
features: {
...MockEntitlements.features,
- ai_governance_user_limit: {
- enabled: true,
- entitlement,
- actual,
- ...(limit !== undefined ? { limit } : {}),
- },
+ ...features,
},
},
experiments: MockExperiments,
@@ -215,12 +212,39 @@ const renderLicenseBannerWithAIGovernance = ({
};
return (
-
+
-
+
);
};
+const renderLicenseBannerWithAIGovernance = ({
+ actual,
+ entitlement = "entitled",
+ limit,
+ warnings = [],
+}: {
+ actual: number;
+ entitlement?: "entitled" | "grace_period" | "not_entitled";
+ limit?: number;
+ warnings?: string[];
+}) =>
+ renderLicenseBanner({
+ warnings,
+ features: {
+ ai_governance_user_limit: {
+ enabled: true,
+ entitlement,
+ actual,
+ ...(limit !== undefined ? { limit } : {}),
+ },
+ },
+ });
+
+// Without the data-variant assertions, every story would keep passing with
+// the muted/prominent classifier disabled.
+const mutedVariant = "warning";
+
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -230,9 +254,13 @@ export const AIGovernanceNearLimit: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByRole("status")).toHaveTextContent(
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
+ // Pins the AI Governance near-limit branch of isMutedWarning,
+ // independently of the runtime soft-limit branch below.
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -267,3 +295,56 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
);
},
};
+
+// Each entry of the frontend's diagnosticMessages set is pinned on both
+// properties the set drives: the muted variant and the suppressed sales
+// link. The "unavailable" message arrives on the errors channel; see the
+// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+const playMutedDiagnostic =
+ (message: string): Story["play"] =>
+ async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(message);
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(
+ canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
+ ).not.toBeInTheDocument();
+ };
+
+export const ManagedAgentUsageUnavailable: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ }),
+ play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
+};
+
+export const AgentRuntimeHoursClaimsIgnored: Story = {
+ render: () =>
+ renderLicenseBanner({
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText),
+};
+
+// An all-diagnostic banner must not claim license limits were exceeded.
+export const UsageDiagnosticsOnlyHeading: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByText("License notices")).toBeInTheDocument();
+ await expect(
+ canvas.queryByText("Your license limits have been exceeded"),
+ ).not.toBeInTheDocument();
+ await expect(
+ canvas.queryByText("License errors require attention"),
+ ).not.toBeInTheDocument();
+ },
+};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 80674fcbb7e..bd278dc591c 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,10 +70,19 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-const bannerTitle = (variant: LicenseBannerVariant): string =>
- variant === "error"
- ? "License errors require attention"
- : "Your license limits have been exceeded";
+// The muted variant only wins when every message is muted (see
+// getBannerVariant), which means advisories and diagnostics: nothing has
+// been exceeded, so the heading must not assert exceedance.
+const bannerTitle = (variant: LicenseBannerVariant): string => {
+ switch (variant) {
+ case "error":
+ return "License errors require attention";
+ case "warningProminent":
+ return "Your license limits have been exceeded";
+ case "warning":
+ return "License notices";
+ }
+};
const bannerRole = (variant: LicenseBannerVariant): "alert" | "status" =>
variant === "error" ? "alert" : "status";
@@ -142,6 +151,9 @@ export const LicenseBannerView: React.FC = ({
return (
From 9d358f429b7fd773b3519f718b0bd5c13ca67fb7 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 05:47:20 +0000
Subject: [PATCH 037/116] feat(enterprise/coderd/license): decode -1 runtime
hours allocation as unlimited
An agent_runtime_hours_allocation claim of exactly -1 now grants the
agent_runtime_hours feature enabled with a nil Limit, which the
entitlements API serves as an omitted "limit" field, the shape the UI
already renders as Unlimited. Threshold claims alongside it are dropped
with the existing claims-ignored warning, and any other negative
allocation remains unusable.
---
enterprise/coderd/license/license.go | 36 ++++++++-
enterprise/coderd/license/license_test.go | 90 ++++++++++++++++++++++-
2 files changed, 122 insertions(+), 4 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index e6f3598475e..8911f695d3d 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -932,9 +932,10 @@ const (
// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit. A
- // negative allocation is ignored, in which case the license does not
- // grant the feature.
+ // allocation for the license term. It becomes the feature's Limit.
+ // AgentRuntimeHoursUnlimitedAllocation (-1) is reserved to mean
+ // unlimited; any other negative allocation is ignored, in which case
+ // the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
// becomes the feature's SoftLimit when 0 < soft < allocation and is
@@ -946,6 +947,12 @@ const (
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
+// AgentRuntimeHoursUnlimitedAllocation is the reserved
+// ClaimAgentRuntimeHoursAllocation value meaning the license grants
+// unlimited runtime hours. It decodes to an enabled feature with a nil
+// Limit. Mirrored in github.com/coder/license.
+const AgentRuntimeHoursUnlimitedAllocation int64 = -1
+
var (
ValidMethods = []string{"EdDSA"}
@@ -991,6 +998,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// claims, but Actual is still measured and published. CODAGT-856 will make a
// zero allocation force a concurrency-limited mode; that mode does not exist
// yet.
+//
+// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
+// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
+// have nothing to threshold against, so they are dropped with the warning,
+// keeping an incorrectly issued license visible. Note that
+// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
+// issued-at and expiry tie a metered license outranks an unlimited one; ties
+// never happen for separately issued licenses, so this edge is documented
+// rather than special-cased.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
@@ -1000,6 +1016,20 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+ if allocOk && allocation == AgentRuntimeHoursUnlimitedAllocation {
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{
+ Enabled: true,
+ Entitlement: entitlement,
+ UsagePeriod: &usagePeriod,
+ }, true, ignoredClaims
+ }
+
if !allocOk || allocation < 0 {
if allocOk && allocation < 0 {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index db68fb8b60a..0e393939c5d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2501,6 +2501,55 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
require.NotNil(t, feature.UsagePeriod)
})
+ // An unlimited (-1) allocation grants the feature enabled with no Limit,
+ // which the API serves as an omitted "limit" field, the shape the UI
+ // already renders as "Unlimited".
+ t.Run("UnlimitedAllocation", func(t *testing.T) {
+ t.Parallel()
+
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ )
+ require.NoError(t, err)
+ require.Empty(t, entitlements.Errors)
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+
+ // The entitlements JSON served by GET /api/v2/entitlements omits
+ // "limit" entirely for the unlimited feature.
+ data, err := json.Marshal(entitlements)
+ require.NoError(t, err)
+ var raw struct {
+ Features map[codersdk.FeatureName]map[string]any `json:"features"`
+ }
+ require.NoError(t, json.Unmarshal(data, &raw))
+ rawFeature := raw.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, true, rawFeature["enabled"])
+ require.NotContains(t, rawFeature, "limit")
+ require.Contains(t, rawFeature, "usage_period")
+ })
+
// The license with the newest issued-at claim wins, even if another
// license was loaded first or has a larger allocation. The soft and hard
// limits come from the winning license.
@@ -2853,9 +2902,48 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
expectClaimsIgnored: true,
},
{
+ // An unlimited allocation grants the feature with no Limit and
+ // no warning: -1 is the canonical unlimited encoding, not an
+ // issuance mistake.
+ name: "UnlimitedAllocation",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ },
+ {
+ // Threshold claims alongside an unlimited allocation have
+ // nothing to threshold against; the grant survives but the
+ // issuance mistake must stay visible via the warning.
+ name: "UnlimitedWithSoft",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ name: "UnlimitedWithHard",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ // Only exactly -1 is the unlimited sentinel; any other negative
+ // allocation stays unusable.
name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -2,
},
expectClaimsIgnored: true,
},
From cd376397262c055c898fb66c800dbb66a32bdca4 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:07:05 +0000
Subject: [PATCH 038/116] chore: remove data-variant
---
.../LicenseBannerView.stories.tsx | 21 +++++++------------
.../LicenseBanner/LicenseBannerView.tsx | 3 ---
2 files changed, 8 insertions(+), 16 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 6c3dd4e662b..4f2030a3080 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -59,6 +59,10 @@ export const TwoWarnings: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
+ await expect(
+ canvas.getByText("Your license limits have been exceeded"),
+ ).toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: "Show more" }),
).not.toBeInTheDocument();
@@ -241,10 +245,6 @@ const renderLicenseBannerWithAIGovernance = ({
},
});
-// Without the data-variant assertions, every story would keep passing with
-// the muted/prominent classifier disabled.
-const mutedVariant = "warning";
-
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -258,9 +258,6 @@ export const AIGovernanceNearLimit: Story = {
await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
- // Pins the AI Governance near-limit branch of isMutedWarning,
- // independently of the runtime soft-limit branch below.
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -296,17 +293,16 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
},
};
-// Each entry of the frontend's diagnosticMessages set is pinned on both
-// properties the set drives: the muted variant and the suppressed sales
+// Each diagnostic pins role=status (not alert) and a suppressed sales
// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
+// mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
const canvas = within(canvasElement);
const banner = canvas.getByRole("status");
await expect(banner).toHaveTextContent(message);
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
).not.toBeInTheDocument();
@@ -337,8 +333,7 @@ export const UsageDiagnosticsOnlyHeading: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const banner = canvas.getByRole("status");
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(canvas.getByText("License notices")).toBeInTheDocument();
await expect(
canvas.queryByText("Your license limits have been exceeded"),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index bd278dc591c..453b925fe6d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -151,9 +151,6 @@ export const LicenseBannerView: React.FC = ({
return (
From 99167d38d751fa23d74dfed7ae3a59e54258a304 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:42:51 +0000
Subject: [PATCH 039/116] chore: remove and simplify explanatory comments
---
codersdk/licenses.go | 23 ++------
.../coderd/coderdenttest/coderdenttest.go | 4 --
enterprise/coderd/license/license.go | 57 +++++++------------
enterprise/coderd/license/license_test.go | 6 +-
site/src/api/typesGenerated.ts | 15 -----
.../dashboard/LicenseBanner/LicenseBanner.tsx | 8 +--
.../LicenseBannerView.stories.tsx | 4 +-
.../LicenseBanner/LicenseBannerView.tsx | 5 +-
8 files changed, 33 insertions(+), 89 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 7aecbaa3089..6f277f17510 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -12,23 +12,12 @@ import (
)
const (
- LicenseExpiryClaim = "license_expires"
- LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
- LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
- LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
- LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- // managed agent usage query fails while computing entitlements; the
- // cause is logged server-side. It travels in the entitlements Errors
- // channel so the alertable coderd_license_errors gauge counts
- // measurement failures, but the dashboard recognizes the exact text and
- // renders it as a muted diagnostic rather than a license error.
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
- // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- // license carries unusable Coder Agent runtime hour claims (see
- // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- // the license and the dropped claims. The dashboard renders the exact
- // text as a muted diagnostic without a sales link.
+ LicenseExpiryClaim = "license_expires"
+ LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
+ LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
+ LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
+ LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 8f5f1e4a708..1d2678fd787 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,10 +237,6 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
-// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
-// or hardLimit omits that claim, which a license is allowed to do; a non-nil
-// value is emitted verbatim, so tests can express explicit zero or negative
-// claims.
func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
if softLimit != nil {
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8911f695d3d..66b55f1feea 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -130,8 +130,6 @@ func Entitlements(
}
type FeatureArguments struct {
- // Logger receives the causes behind operator-facing diagnostics whose
- // published message is a stable text. The zero value discards them.
Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
@@ -587,8 +585,6 @@ func LicensesEntitlements(
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
}
if len(ignoredClaims) > 0 {
- // The published warning is a stable text, so the details a
- // support case needs go to the log.
featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
slog.F("license_id", license.UUID),
slog.F("ignored_claims", ignoredClaims),
@@ -721,9 +717,8 @@ func LicensesEntitlements(
}
if ok {
agentLimit.Actual = &managedAgentCount
- // Written back directly: the feature contest is already
- // settled, so AddFeature's Compare must not get a chance to
- // drop the write.
+ // Write directly rather than via AddFeature so its Compare
+ // cannot drop the update.
entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
@@ -863,12 +858,10 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs one usage query over the feature's usage period and owns
-// the shared failure policy: a nil fn is a wiring bug and fails the whole
-// LicensesEntitlements call; a failure with a dead context fails the call
-// without logging; any other failure logs the cause and publishes the stable
-// unavailableText instead. It returns the measured value and true only on
-// success.
+// measureUsage runs fn over the feature's usage period. A nil fn or a
+// failure with a dead context fails the whole call; any other failure logs
+// the cause and publishes unavailableText instead. It returns the measured
+// value and true only on success.
func measureUsage(
ctx context.Context,
entitlements *codersdk.Entitlements,
@@ -884,12 +877,11 @@ func measureUsage(
value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
switch {
case err != nil && ctx.Err() != nil:
- // The computation's own context is dead, so abort the whole call
- // without logging. Do not classify by error shape instead: Postgres
- // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
- // as well as client cancels, and aborting on those would fail every
- // entitlements refresh on a deployment whose statement_timeout is
- // shorter than a usage query.
+ // Do not classify cancellation by error shape instead of ctx.Err():
+ // Postgres raises SQLSTATE 57014 (query_canceled) for
+ // statement_timeout kills as well as client cancels, and aborting on
+ // those would fail every entitlements refresh on a deployment whose
+ // statement_timeout is shorter than a usage query.
return 0, false, xerrors.Errorf("get %s: %w", what, err)
case err != nil:
logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
@@ -987,26 +979,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// allocation claim; per-claim validity rules live on the Claim* constants
// above.
//
-// Unusable claims are dropped, never license-invalidating: rejecting a
-// signed license over a cosmetic threshold claim would drop the deployment
-// to unlicensed. ignoredClaims names each dropped claim (including the
-// feature name itself minted as a claim, the most plausible issuer mistake)
-// so the caller can warn and log instead of letting an incorrectly issued
-// license look healthy.
+// Unusable claims are dropped rather than invalidating the license, since
+// rejecting a signed license over a cosmetic claim would drop the deployment
+// to unlicensed. Each dropped claim is returned in ignoredClaims so the
+// caller can warn and log instead of letting an incorrectly issued license
+// look healthy.
//
-// A zero allocation grants the feature disabled and drops both threshold
-// claims, but Actual is still measured and published. CODAGT-856 will make a
-// zero allocation force a concurrency-limited mode; that mode does not exist
-// yet.
-//
-// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
-// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
-// have nothing to threshold against, so they are dropped with the warning,
-// keeping an incorrectly issued license visible. Note that
-// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
-// issued-at and expiry tie a metered license outranks an unlimited one; ties
-// never happen for separately issued licenses, so this edge is documented
-// rather than special-cased.
+// A zero allocation grants the feature disabled, but Actual is still
+// measured and published. CODAGT-856 will make a zero allocation force a
+// concurrency-limited mode; that mode does not exist yet.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 0e393939c5d..ec8e06b2f8d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -986,8 +986,7 @@ func TestEntitlements(t *testing.T) {
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
- // The failure surfaces its stable text without the raw cause,
- // on the channel the codersdk constant docs prescribe.
+ // The failure surfaces its stable text without the raw cause.
require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
require.NotContains(t, entry, "kaboom")
@@ -1657,8 +1656,7 @@ func TestLicenseEntitlements(t *testing.T) {
},
},
{
- // A query failure is surfaced as a stable text in Errors (see
- // the codersdk constant docs for the channel choice) and
+ // A query failure is surfaced as a stable text in Errors and
// leaves Actual unset without aborting the rest of the
// entitlements.
Name: "ManagedAgentLimit/QueryError",
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index c00449c8b51..677438bcfa4 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5723,13 +5723,6 @@ export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
// From codersdk/licenses.go
-/**
- * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- * license carries unusable Coder Agent runtime hour claims (see
- * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- * the license and the dropped claims. The dashboard renders the exact
- * text as a muted diagnostic without a sales link.
- */
export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
"A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
@@ -5741,14 +5734,6 @@ export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
// From codersdk/licenses.go
-/**
- * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- * managed agent usage query fails while computing entitlements; the
- * cause is logged server-side. It travels in the entitlements Errors
- * channel so the alertable coderd_license_errors gauge counts
- * measurement failures, but the dashboard recognizes the exact text and
- * renders it as a muted diagnostic rather than a license error.
- */
export const LicenseManagedAgentUsageUnavailableErrorText =
"Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 9ef4dda8bb1..74ce21b89fa 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -36,8 +36,7 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// Diagnostics about the license or the usage measurement rather than about
// usage itself. They render muted, without the exceedance heading or a sales
-// link. The "unavailable" pair arrives via entitlements.errors but must not
-// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
@@ -46,9 +45,8 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render in the muted variant: nothing is wrong
-// yet, so they must be visually distinct from warnings that demand action,
-// such as exceeding a license limit.
+// Advisories and diagnostics render muted to stay visually distinct from
+// warnings that demand action, such as exceeding a license limit.
const isMutedWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
isDiagnosticMessage(message);
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 4f2030a3080..7c2e67c9a71 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -294,9 +294,7 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
};
// Each diagnostic pins role=status (not alert) and a suppressed sales
-// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
-// mutedness is covered by the visual snapshot.
+// link. Background mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 453b925fe6d..2cef6164395 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,9 +70,8 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-// The muted variant only wins when every message is muted (see
-// getBannerVariant), which means advisories and diagnostics: nothing has
-// been exceeded, so the heading must not assert exceedance.
+// The muted "warning" variant means every message is an advisory or
+// diagnostic, so the heading must not assert exceedance.
const bannerTitle = (variant: LicenseBannerVariant): string => {
switch (variant) {
case "error":
From 0fda91cb45101549bb71a9373496d42efc3dd630 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 09:40:53 +0000
Subject: [PATCH 040/116] fix: make unlimited usage period features outrank
metered ones on ties
A nil Limit on a usage period feature means unlimited, so Feature.Compare now ranks it above any set limit when issued-at and end dates tie. Also types the LicenseBanner map callbacks as LicenseBannerMessage instead of using as const assertions.
---
codersdk/deployment.go | 12 +++-
codersdk/deployment_test.go | 25 ++++++++
enterprise/coderd/license/license_test.go | 60 +++++++++++++++++++
.../dashboard/LicenseBanner/LicenseBanner.tsx | 32 +++++-----
4 files changed, 111 insertions(+), 18 deletions(-)
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index bba55f81bec..8a60e11415f 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -425,7 +425,7 @@ type UsagePeriod struct {
// 2. The usage period has a greater end date (note: only certain features use usage periods)
// 3. Graceful & capable > Entitled & not capable (only if both have "Actual" values)
// 4. The entitlement is greater
-// 5. The limit is greater
+// 5. The limit is greater (except a nil limit on a usage period feature means unlimited, outranking any set limit)
// 6. Enabled is greater than disabled
// 7. The actual is greater
//
@@ -469,11 +469,19 @@ func (f Feature) Compare(b Feature) int {
return entitlementDifference
}
- // If the entitlement is the same, then we can compare the limits.
+ // If the entitlement is the same, then we can compare the limits. A nil
+ // limit on a usage period feature means unlimited, so it outranks any set
+ // limit; on other features a nil limit loses to a set one.
if f.Limit == nil && b.Limit != nil {
+ if bothHaveUsagePeriod {
+ return 1
+ }
return -1
}
if f.Limit != nil && b.Limit == nil {
+ if bothHaveUsagePeriod {
+ return -1
+ }
return 1
}
if f.Limit != nil && b.Limit != nil {
diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go
index de3395aac84..2abdff578e9 100644
--- a/codersdk/deployment_test.go
+++ b/codersdk/deployment_test.go
@@ -1225,6 +1225,31 @@ func TestFeatureComparison(t *testing.T) {
},
Expected: 1,
},
+ {
+ // A nil limit on a usage period feature means unlimited, so it
+ // outranks a set limit on an exact usage period tie.
+ Name: "UnlimitedUsagePeriodOutranksMeteredOnTie",
+ A: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ B: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ Limit: ptr.Ref(int64(100)),
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ Expected: 1,
+ },
}
for _, tc := range testCases {
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index ec8e06b2f8d..68bff19da49 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2616,6 +2616,66 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
}
})
+ // When an unlimited and a metered license are minted with identical
+ // issued-at and expiry claims, the unlimited grant must win the tie,
+ // regardless of load order.
+ t.Run("UnlimitedOutranksMeteredOnTie", func(t *testing.T) {
+ t.Parallel()
+
+ // JWT NumericDate claims have second granularity, so truncate to
+ // keep the round-tripped issued-at values identical.
+ iat := time.Now().Add(-time.Minute).Truncate(time.Second)
+ nbf := iat
+ exp := iat.Add(time.Hour).Truncate(time.Second)
+ unlimited := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+ metered := database.License{
+ ID: 2,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ }),
+ }
+
+ for _, order := range [][]database.License{
+ {unlimited, metered},
+ {metered, unlimited},
+ } {
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
+ require.NoError(t, err)
+
+ feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+ }
+ })
+
// A newer license without soft/hard limits must fully replace an older
// license that carried them; the limits must not merge across licenses.
t.Run("SoftHardRideAlongWithWinner", func(t *testing.T) {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 74ce21b89fa..2218aa5cf35 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -176,22 +176,22 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map((message) => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message)
- ? ("warning" as const)
- : ("error" as const),
- link: messageLink(message),
- })),
- ...normalizedWarnings.map((message) => ({
- message,
- variant: isMutedWarning(message)
- ? ("warning" as const)
- : ("warningProminent" as const),
- link: messageLink(message),
- })),
+ ...errors.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message) ? "warning" : "error",
+ link: messageLink(message),
+ }),
+ ),
+ ...normalizedWarnings.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ variant: isMutedWarning(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ }),
+ ),
];
if (messages.length === 0) {
From 97eab522ea36d16ad18b05b32bb002b981cc0095 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:10:13 +0000
Subject: [PATCH 041/116] fix(enterprise/coderd/license): accept issuer-valid
zero soft limit for agent runtime hours
The license issuer validates the soft limit as 0 <= soft < allocation,
but this decoder dropped soft=0 as an unusable claim and warned. Accept
zero so every license the issuer mints decodes cleanly; omitting the
claim remains the way to express no soft limit.
---
enterprise/coderd/license/license.go | 4 ++--
enterprise/coderd/license/license_test.go | 13 ++++++-------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 66b55f1feea..f2a6924bf57 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -930,7 +930,7 @@ const (
// the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // becomes the feature's SoftLimit when 0 <= soft < allocation and is
// ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
// ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
@@ -1031,7 +1031,7 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
UsagePeriod: &usagePeriod,
}
if softOk {
- if soft > 0 && soft < allocation {
+ if soft >= 0 && soft < allocation {
feature.SoftLimit = &soft
} else {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 68bff19da49..66c46b3b691 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2857,20 +2857,19 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
},
},
{
- // A zero soft limit would warn at zero usage forever, so it is
- // dropped rather than rejecting the license. The canonical way
- // to express "no soft limit" is omitting the claim, so a
- // present-but-dropped zero still warns.
+ // A zero soft limit is valid (0 <= soft < allocation) and warns
+ // from the start of the usage period. Omitting the claim is the
+ // way to express "no soft limit".
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
expectFeature: &codersdk.Feature{
- Enabled: true,
- Limit: ptr.Ref[int64](100),
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](0),
},
- expectClaimsIgnored: true,
},
{
name: "NegativeSoft",
From a04611a1ae34f3d23fb1f9d16f18b8d8fab3fbd3 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Thu, 13 Aug 2026 05:33:27 +0000
Subject: [PATCH 042/116] feat(enterprise/coderd/license): grandfather
claim-less premium licenses into a zero-hour agent runtime allocation
Premium licenses without agent_runtime_hours_* claims now grant the
agent_runtime_hours feature disabled with a zero limit over the license
term, matching an explicit zero allocation: usage is measured and
published, and chatd caps concurrent agentic chats. The default carries
a fixed early UsagePeriod.IssuedAt so any license actually carrying the
claims outranks it in the Compare merge regardless of relative license
issue dates. Enterprise licenses are unchanged.
---
enterprise/coderd/license/license.go | 49 +++++-
enterprise/coderd/license/license_test.go | 196 +++++++++++++++++++++-
2 files changed, 238 insertions(+), 7 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index edb1586a387..29a120af0bf 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -481,6 +481,37 @@ func LicensesEntitlements(
End: defaultManagedAgentsEnd,
},
})
+
+ // Premium licenses without agent_runtime_hours_* claims are
+ // grandfathered into a zero-hour allocation: the feature is
+ // granted disabled with a zero limit, which measures and
+ // publishes usage (see the measureUsage call below) and caps
+ // concurrent agentic chats the same as an explicit zero
+ // allocation.
+ var (
+ // A fixed issue time that predates any license issued with
+ // agent_runtime_hours_* claims, so a license that actually
+ // carries those claims outranks this default in
+ // Feature.Compare (IssuedAt-first for usage period features)
+ // regardless of the licenses' relative issue dates. This
+ // must remain earlier than the earliest legitimately issued
+ // claim-bearing license.
+ defaultAgentRuntimeHoursIssuedAt = time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
+ defaultAgentRuntimeHoursLimit int64
+ )
+ entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, codersdk.Feature{
+ Enabled: false,
+ Entitlement: entitlement,
+ Limit: &defaultAgentRuntimeHoursLimit,
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: defaultAgentRuntimeHoursIssuedAt,
+ // The license term, matching a license with an explicit
+ // zero allocation, so measured usage covers the current
+ // term.
+ Start: usagePeriodStart,
+ End: usagePeriodEnd,
+ },
+ })
}
// TODO: Remove this tracking once AI Bridge is enforced as an add-on license.
@@ -746,9 +777,12 @@ func LicensesEntitlements(
}
// Usage is measured even for a zero allocation, which reports the
- // feature disabled: see decodeAgentRuntimeHours. Reported usage can
- // trail real usage; the sources of staleness and loss are documented
- // on the enterprise/coderd/usage.AgentRuntime* constants.
+ // feature disabled: see decodeAgentRuntimeHours. Premium licenses
+ // without agent runtime hour claims grant the same disabled zero-limit
+ // feature (see the grandfather default above), so every premium
+ // deployment reports usage here. Reported usage can trail real usage;
+ // the sources of staleness and loss are documented on the
+ // enterprise/coderd/usage.AgentRuntime* constants.
runtimeHours := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
if entitlements.HasLicense && runtimeHours.UsagePeriod != nil {
runtimeMs, ok, err := measureUsage(ctx, &entitlements,
@@ -939,6 +973,10 @@ func measureUsage(
// allocation supersedes the advisory soft limit, so the dashboard banner
// never stacks both messages.
func appendAgentRuntimeHoursWarning(warnings []string, actualHours int64, allocation int64, softLimit *int64) []string {
+ // A zero allocation (explicit or the grandfathered premium default) has
+ // no thresholds to warn about: those deployments are steered by the
+ // in-page upgrade CTA and the concurrent chat cap, not a
+ // deployment-wide banner.
if allocation <= 0 {
return warnings
}
@@ -1065,8 +1103,9 @@ func agentRuntimeMsToHours(ms int64) int64 {
// look healthy.
//
// A zero allocation grants the feature disabled, but Actual is still
-// measured and published. CODAGT-856 will make a zero allocation force a
-// concurrency-limited mode; that mode does not exist yet.
+// measured and published. A disabled feature forces the concurrency-limited
+// mode from CODAGT-856 (chatd pooled admission), which also covers premium
+// licenses granted the grandfathered zero-hour default in Entitlements.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 83d54ec9e53..6e9dbf6c34f 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -640,6 +640,24 @@ func TestEntitlements(t *testing.T) {
require.WithinDuration(t, agentUsagePeriodEnd, agentEntitlement.UsagePeriod.End, time.Second)
continue
}
+ if featureName == codersdk.FeatureAgentRuntimeHours {
+ // Premium licenses without agent runtime hour claims are
+ // grandfathered into a zero-hour allocation over the
+ // license term, with usage still measured. See license.go
+ // for more details.
+ runtimeEntitlement := entitlements.Features[featureName]
+ require.False(t, runtimeEntitlement.Enabled)
+ require.Equal(t, codersdk.EntitlementEntitled, runtimeEntitlement.Entitlement)
+ require.NotNil(t, runtimeEntitlement.Limit)
+ require.EqualValues(t, 0, *runtimeEntitlement.Limit)
+ require.NotNil(t, runtimeEntitlement.UsagePeriod)
+ require.Equal(t, time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC), runtimeEntitlement.UsagePeriod.IssuedAt)
+ require.WithinDuration(t, licenseOptions.NotBefore, runtimeEntitlement.UsagePeriod.Start, time.Second)
+ require.WithinDuration(t, licenseOptions.ExpiresAt, runtimeEntitlement.UsagePeriod.End, time.Second)
+ require.NotNil(t, runtimeEntitlement.Actual)
+ require.EqualValues(t, 0, *runtimeEntitlement.Actual)
+ continue
+ }
if featureName.IsAddonFeature() {
continue
}
@@ -935,6 +953,12 @@ func TestEntitlements(t *testing.T) {
return true
})).
Return(int64(175), nil)
+ // The premium grandfather default grants a zero-hour agent runtime
+ // allocation, so that usage is queried too. It is not what this
+ // test is about.
+ mDB.EXPECT().
+ GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), nil)
mDB.EXPECT().
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
Return([]database.Template{}, nil)
@@ -1217,6 +1241,12 @@ func TestEntitlements(t *testing.T) {
mDB.EXPECT().
GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
Return(int64(0), nil)
+ // The premium grandfather default grants a zero-hour agent
+ // runtime allocation, so that usage is queried too. It is
+ // not what this test is about.
+ mDB.EXPECT().
+ GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), nil)
mDB.EXPECT().
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
Return([]database.Template{}, nil)
@@ -1410,6 +1440,12 @@ func TestEntitlements(t *testing.T) {
mDB.EXPECT().
GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
Return(int64(0), nil)
+ // The premium grandfather default grants a zero-hour agent
+ // runtime allocation, so that usage is queried too. It is not
+ // what this test is about.
+ mDB.EXPECT().
+ GetTotalUsageHBAgentRuntimeV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), nil)
mDB.EXPECT().
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
Return([]database.Template{}, nil)
@@ -1514,6 +1550,15 @@ func TestLicenseEntitlements(t *testing.T) {
var agentRuntimeUsageQueryFrom, agentRuntimeUsageQueryTo time.Time
var agentRuntimeUsageQueryCalled bool
+ // grandfatherIssuedAt is the fixed UsagePeriod.IssuedAt carried by the
+ // zero-hour agent runtime allocation that premium licenses without
+ // agent runtime hour claims are grandfathered into; see license.go.
+ grandfatherIssuedAt := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
+ // runtimeClaimIssuedAt mints claim-bearing licenses in the grandfather
+ // precedence cases below, so the merged feature's UsagePeriod.IssuedAt
+ // identifies which candidate won.
+ runtimeClaimIssuedAt := dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second)
+
premiumLicense := func() *coderdenttest.LicenseOptions {
return (&coderdenttest.LicenseOptions{
AccountType: "salesforce",
@@ -1903,8 +1948,10 @@ func TestLicenseEntitlements(t *testing.T) {
},
},
{
- // A license without the allocation claim does not grant the
- // feature, so usage is never queried and nothing warns.
+ // An enterprise license without the allocation claim does not
+ // grant the feature, so usage is never queried and nothing
+ // warns. Only premium licenses are grandfathered into a
+ // zero-hour allocation.
Name: "AgentRuntimeHours/NoClaimNoFeature",
Licenses: []*coderdenttest.LicenseOptions{
enterpriseLicense().UserLimit(100),
@@ -2066,6 +2113,131 @@ func TestLicenseEntitlements(t *testing.T) {
fmt.Sprintf(codersdk.LicenseAgentRuntimeHoursAllocationReachedWarningText, 100, 100))
},
},
+ {
+ // A premium license without agent runtime hour claims is
+ // grandfathered into a zero-hour allocation: granted disabled
+ // with a zero limit over the license term, usage still
+ // measured, and no deployment-wide warning even with nonzero
+ // usage.
+ Name: "AgentRuntimeHours/PremiumGrandfathered",
+ Licenses: []*coderdenttest.LicenseOptions{
+ premiumLicense().UserLimit(100),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: hoursToMsFn(50),
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ assertNoWarnings(t, entitlements)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ assert.False(t, feature.Enabled)
+ assert.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.NotNil(t, feature.Limit)
+ assert.Equal(t, int64(0), *feature.Limit)
+ assert.Nil(t, feature.SoftLimit)
+ assert.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+ assert.Equal(t, grandfatherIssuedAt, feature.UsagePeriod.IssuedAt)
+ // The usage period is the license term (premiumLicense is
+ // valid from roughly now until 60 days out), not the
+ // managed-agent default's fixed 100-year window.
+ assert.WithinDuration(t, time.Now(), feature.UsagePeriod.Start, 5*time.Minute)
+ assert.WithinDuration(t, time.Now().Add(60*24*time.Hour), feature.UsagePeriod.End, 5*time.Minute)
+ require.NotNil(t, feature.Actual)
+ assert.Equal(t, int64(50), *feature.Actual)
+ },
+ },
+ {
+ // A grace-period premium license grandfathers the same
+ // zero-hour allocation with a grace entitlement.
+ Name: "AgentRuntimeHours/PremiumGrandfatheredGracePeriod",
+ Licenses: []*coderdenttest.LicenseOptions{
+ premiumLicense().UserLimit(100).GracePeriod(time.Now()),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: hoursToMsFn(50),
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ assert.False(t, feature.Enabled)
+ assert.Equal(t, codersdk.EntitlementGracePeriod, feature.Entitlement)
+ require.NotNil(t, feature.Limit)
+ assert.Equal(t, int64(0), *feature.Limit)
+ require.NotNil(t, feature.Actual)
+ assert.Equal(t, int64(50), *feature.Actual)
+ },
+ },
+ {
+ // The grandfathered default carries a fixed early
+ // UsagePeriod.IssuedAt, so a license actually carrying the
+ // allocation claim wins the merge even when the claim-less
+ // premium license is issued later.
+ Name: "AgentRuntimeHours/GrandfatherLosesToAllocation",
+ Licenses: []*coderdenttest.LicenseOptions{
+ premiumLicense().UserLimit(100).WithIssuedAt(dbtime.Now().Add(-time.Hour)),
+ agentRuntimeHoursLicense(20000, nil).WithIssuedAt(runtimeClaimIssuedAt),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: hoursToMsFn(50),
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ assertNoWarnings(t, entitlements)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ assert.True(t, feature.Enabled)
+ require.NotNil(t, feature.Limit)
+ assert.Equal(t, int64(20000), *feature.Limit)
+ require.NotNil(t, feature.UsagePeriod)
+ assert.WithinDuration(t, runtimeClaimIssuedAt, feature.UsagePeriod.IssuedAt, time.Second)
+ },
+ },
+ {
+ // An unlimited allocation on any license outranks the
+ // grandfathered zero-hour default.
+ Name: "AgentRuntimeHours/GrandfatherLosesToUnlimited",
+ Licenses: []*coderdenttest.LicenseOptions{
+ premiumLicense().UserLimit(100),
+ enterpriseLicense().UserLimit(100).AgentRuntimeHours(license.AgentRuntimeHoursUnlimitedAllocation, nil, nil),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: hoursToMsFn(1_000_000),
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ assertNoWarnings(t, entitlements)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ assert.True(t, feature.Enabled)
+ assert.Nil(t, feature.Limit)
+ require.NotNil(t, feature.Actual)
+ assert.Equal(t, int64(1_000_000), *feature.Actual)
+ },
+ },
+ {
+ // An explicit zero allocation and the grandfathered default
+ // have identical semantics; the explicit claim's later issue
+ // time wins the merge, which pins the Compare path.
+ Name: "AgentRuntimeHours/GrandfatherLosesToExplicitZero",
+ Licenses: []*coderdenttest.LicenseOptions{
+ premiumLicense().UserLimit(100),
+ agentRuntimeHoursLicense(0, nil).WithIssuedAt(runtimeClaimIssuedAt),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: hoursToMsFn(50),
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ assertNoWarnings(t, entitlements)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ assert.False(t, feature.Enabled)
+ require.NotNil(t, feature.Limit)
+ assert.Equal(t, int64(0), *feature.Limit)
+ require.NotNil(t, feature.UsagePeriod)
+ assert.WithinDuration(t, runtimeClaimIssuedAt, feature.UsagePeriod.IssuedAt, time.Second)
+ require.NotNil(t, feature.Actual)
+ assert.Equal(t, int64(50), *feature.Actual)
+ },
+ },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -2504,6 +2676,11 @@ func TestOldStyleManagedAgentLicenses(t *testing.T) {
ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
return actualAgents, nil
},
+ // The premium grandfather default grants a zero-hour agent
+ // runtime allocation, so a runtime closure is required too.
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
}
entitlements, err := license.LicensesEntitlements(
@@ -2588,6 +2765,11 @@ func TestManagedAgentLimitDefault(t *testing.T) {
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return actualAgents, nil
},
+ // The premium grandfather default grants a zero-hour agent
+ // runtime allocation, so a runtime closure is required too.
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments)
@@ -2633,6 +2815,11 @@ func TestManagedAgentLimitDefault(t *testing.T) {
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return actualAgents, nil
},
+ // The premium grandfather default grants a zero-hour agent
+ // runtime allocation, so a runtime closure is required too.
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments)
@@ -2678,6 +2865,11 @@ func TestManagedAgentLimitDefault(t *testing.T) {
ManagedAgentCountFn: func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return actualAgents, nil
},
+ // The premium grandfather default grants a zero-hour agent
+ // runtime allocation, so a runtime closure is required too.
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
}
entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{lic}, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, arguments)
From 7e5b4b8d106485e1d531ad2dd4dd7425e25e7e9d Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Thu, 13 Aug 2026 06:59:19 +0000
Subject: [PATCH 043/116] feat: report precise agent runtime milliseconds in
entitlements
The agent_runtime_hours feature floors recorded milliseconds to whole
hours before exposing them as Feature.Actual, so clients cannot render
fractional usage such as 10.3 hours. Add Feature.ActualMs (JSON
actual_ms), set only for FeatureAgentRuntimeHours, carrying the exact
stored milliseconds alongside the floored whole-hour Actual.
---
coderd/apidoc/docs.go | 6 ++-
coderd/apidoc/swagger.json | 6 ++-
codersdk/deployment.go | 11 +++--
docs/reference/api/enterprise.md | 2 +
docs/reference/api/schemas.md | 22 +++++----
enterprise/coderd/license/license.go | 6 +++
enterprise/coderd/license/license_test.go | 55 ++++++++++++++++++++++-
enterprise/coderd/licenses_test.go | 4 +-
site/src/api/typesGenerated.ts | 13 ++++--
9 files changed, 106 insertions(+), 19 deletions(-)
diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index b9a3b82c74e..349403203bc 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -20704,7 +20704,11 @@ const docTemplate = `{
"type": "object",
"properties": {
"actual": {
- "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds. FeatureAgentRuntimeHours usage can trail by\nroughly one hour because the current hour is not emitted, plus the\nentitlement refresh interval.",
+ "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds, with the precise value available in\nActualMs. FeatureAgentRuntimeHours usage can trail by roughly one\nhour because the current hour is not emitted, plus the entitlement\nrefresh interval.",
+ "type": "integer"
+ },
+ "actual_ms": {
+ "description": "ActualMs is the precise usage backing Actual, in milliseconds, for\nfeatures measured in time. It has the same freshness as Actual.\nOnly FeatureAgentRuntimeHours sets this field.",
"type": "integer"
},
"enabled": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 974cdd50ede..e6a505b4914 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -18834,7 +18834,11 @@
"type": "object",
"properties": {
"actual": {
- "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds. FeatureAgentRuntimeHours usage can trail by\nroughly one hour because the current hour is not emitted, plus the\nentitlement refresh interval.",
+ "description": "Actual is the usage measured against Limit, when known: a\npoint-in-time count for most features, or usage accumulated over\nUsagePeriod for features that set one. Its unit matches Limit's;\nFeatureAgentRuntimeHours reports whole hours floored from the\nrecorded milliseconds, with the precise value available in\nActualMs. FeatureAgentRuntimeHours usage can trail by roughly one\nhour because the current hour is not emitted, plus the entitlement\nrefresh interval.",
+ "type": "integer"
+ },
+ "actual_ms": {
+ "description": "ActualMs is the precise usage backing Actual, in milliseconds, for\nfeatures measured in time. It has the same freshness as Actual.\nOnly FeatureAgentRuntimeHours sets this field.",
"type": "integer"
},
"enabled": {
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 7006f4a2f0f..1261bf02b37 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -398,10 +398,15 @@ type Feature struct {
// point-in-time count for most features, or usage accumulated over
// UsagePeriod for features that set one. Its unit matches Limit's;
// FeatureAgentRuntimeHours reports whole hours floored from the
- // recorded milliseconds. FeatureAgentRuntimeHours usage can trail by
- // roughly one hour because the current hour is not emitted, plus the
- // entitlement refresh interval.
+ // recorded milliseconds, with the precise value available in
+ // ActualMs. FeatureAgentRuntimeHours usage can trail by roughly one
+ // hour because the current hour is not emitted, plus the entitlement
+ // refresh interval.
Actual *int64 `json:"actual,omitempty"`
+ // ActualMs is the precise usage backing Actual, in milliseconds, for
+ // features measured in time. It has the same freshness as Actual.
+ // Only FeatureAgentRuntimeHours sets this field.
+ ActualMs *int64 `json:"actual_ms,omitempty"`
// Below is only for features that use usage periods.
diff --git a/docs/reference/api/enterprise.md b/docs/reference/api/enterprise.md
index a6c7114b162..aada5a73777 100644
--- a/docs/reference/api/enterprise.md
+++ b/docs/reference/api/enterprise.md
@@ -574,6 +574,7 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \
"features": {
"property1": {
"actual": 0,
+ "actual_ms": 0,
"enabled": true,
"entitlement": "entitled",
"hard_limit": 0,
@@ -587,6 +588,7 @@ curl -X GET http://coder-server:8080/api/v2/entitlements \
},
"property2": {
"actual": 0,
+ "actual_ms": 0,
"enabled": true,
"entitlement": "entitled",
"hard_limit": 0,
diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md
index 83c08fa1df9..fd9ae336be3 100644
--- a/docs/reference/api/schemas.md
+++ b/docs/reference/api/schemas.md
@@ -7487,6 +7487,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"features": {
"property1": {
"actual": 0,
+ "actual_ms": 0,
"enabled": true,
"entitlement": "entitled",
"hard_limit": 0,
@@ -7500,6 +7501,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
},
"property2": {
"actual": 0,
+ "actual_ms": 0,
"enabled": true,
"entitlement": "entitled",
"hard_limit": 0,
@@ -7785,6 +7787,7 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
```json
{
"actual": 0,
+ "actual_ms": 0,
"enabled": true,
"entitlement": "entitled",
"hard_limit": 0,
@@ -7800,15 +7803,16 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
### Properties
-| Name | Type | Required | Restrictions | Description |
-|----------------|----------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `actual` | integer | false | | Actual is the usage measured against Limit, when known: a point-in-time count for most features, or usage accumulated over UsagePeriod for features that set one. Its unit matches Limit's; FeatureAgentRuntimeHours reports whole hours floored from the recorded milliseconds. FeatureAgentRuntimeHours usage can trail by roughly one hour because the current hour is not emitted, plus the entitlement refresh interval. |
-| `enabled` | boolean | false | | |
-| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | |
-| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. |
-| `limit` | integer | false | | Limit is the maximum value the license grants for the feature, in the feature's own unit. For FeatureAgentRuntimeHours, an enabled feature with Limit omitted means the license grants unlimited runtime hours. |
-| `soft_limit` | integer | false | | Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation; an unlimited allocation has no thresholds, so SoftLimit is omitted alongside the omitted Limit. Only FeatureAgentRuntimeHours sets this field. |
-| `usage_period` | [codersdk.UsagePeriod](#codersdkusageperiod) | false | | Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this field. |
+| Name | Type | Required | Restrictions | Description |
+|----------------|----------------------------------------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `actual` | integer | false | | Actual is the usage measured against Limit, when known: a point-in-time count for most features, or usage accumulated over UsagePeriod for features that set one. Its unit matches Limit's; FeatureAgentRuntimeHours reports whole hours floored from the recorded milliseconds, with the precise value available in ActualMs. FeatureAgentRuntimeHours usage can trail by roughly one hour because the current hour is not emitted, plus the entitlement refresh interval. |
+| `actual_ms` | integer | false | | Actual ms is the precise usage backing Actual, in milliseconds, for features measured in time. It has the same freshness as Actual. Only FeatureAgentRuntimeHours sets this field. |
+| `enabled` | boolean | false | | |
+| `entitlement` | [codersdk.Entitlement](#codersdkentitlement) | false | | |
+| `hard_limit` | integer | false | | Hard limit is the enforcement threshold that accompanies Limit for features whose license carries it. See SoftLimit for the set of features that use these thresholds. |
+| `limit` | integer | false | | Limit is the maximum value the license grants for the feature, in the feature's own unit. For FeatureAgentRuntimeHours, an enabled feature with Limit omitted means the license grants unlimited runtime hours. |
+| `soft_limit` | integer | false | | Soft limit is the advisory warning threshold that accompanies Limit for features whose license carries it. For these features, Limit carries the purchased allocation; an unlimited allocation has no thresholds, so SoftLimit is omitted alongside the omitted Limit. Only FeatureAgentRuntimeHours sets this field. |
+| `usage_period` | [codersdk.UsagePeriod](#codersdkusageperiod) | false | | Usage period denotes that the usage is a counter that accumulates over this period (and most likely resets with the issuance of the next license). These dates are determined from the license that this entitlement comes from, see enterprise/coderd/license/license.go. Only FeatureManagedAgentLimit and FeatureAgentRuntimeHours set this field. |
## codersdk.FriendlyDiagnostic
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 29a120af0bf..567b1503a23 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -794,6 +794,12 @@ func LicensesEntitlements(
if ok {
actualHours := agentRuntimeMsToHours(runtimeMs)
runtimeHours.Actual = &actualHours
+ // ActualMs carries the exact stored milliseconds so clients can
+ // render fractional hours. Negative input clamps to 0, mirroring
+ // agentRuntimeMsToHours, since AgentRuntimeMsFn is a
+ // caller-supplied seam.
+ actualMs := max(runtimeMs, 0)
+ runtimeHours.ActualMs = &actualMs
// Written back directly rather than through AddFeature; see
// the managed-agent write-back above for why.
entitlements.Features[codersdk.FeatureAgentRuntimeHours] = runtimeHours
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 6e9dbf6c34f..f61ea3ff941 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -1882,6 +1882,8 @@ func TestLicenseEntitlements(t *testing.T) {
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.NotNil(t, feature.Actual)
assert.Equal(t, int64(80), *feature.Actual)
+ require.NotNil(t, feature.ActualMs)
+ assert.Equal(t, (80 * time.Hour).Milliseconds(), *feature.ActualMs)
},
},
{
@@ -1927,7 +1929,8 @@ func TestLicenseEntitlements(t *testing.T) {
},
{
// Partial hours are floored, so 99h59m59s does not reach the
- // 100 hour allocation.
+ // 100 hour allocation. ActualMs still carries the exact
+ // milliseconds so clients can render the fraction.
Name: "AgentRuntimeHours/PartialHourFloored",
Licenses: []*coderdenttest.LicenseOptions{
agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
@@ -1945,6 +1948,53 @@ func TestLicenseEntitlements(t *testing.T) {
feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
require.NotNil(t, feature.Actual)
assert.Equal(t, int64(99), *feature.Actual)
+ require.NotNil(t, feature.ActualMs)
+ assert.Equal(t, (100*time.Hour).Milliseconds()-1, *feature.ActualMs)
+ },
+ },
+ {
+ // A fractional-hour runtime: Actual floors to whole hours
+ // while ActualMs preserves the fraction (10.3 hours here).
+ Name: "AgentRuntimeHours/FractionalHours",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return (10*time.Hour + 18*time.Minute).Milliseconds(), nil
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ assertNoWarnings(t, entitlements)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.NotNil(t, feature.Actual)
+ assert.Equal(t, int64(10), *feature.Actual)
+ require.NotNil(t, feature.ActualMs)
+ assert.Equal(t, int64(37_080_000), *feature.ActualMs)
+ },
+ },
+ {
+ // Negative runtime is not producible by the production query,
+ // but AgentRuntimeMsFn is a caller-supplied seam, so both
+ // Actual and ActualMs clamp to 0.
+ Name: "AgentRuntimeHours/NegativeRuntimeClamped",
+ Licenses: []*coderdenttest.LicenseOptions{
+ agentRuntimeHoursLicense(100, ptr.Ref[int64](80)),
+ },
+ Arguments: license.FeatureArguments{
+ AgentRuntimeMsFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return -1, nil
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoErrors(t, entitlements)
+ assertNoWarnings(t, entitlements)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.NotNil(t, feature.Actual)
+ assert.Equal(t, int64(0), *feature.Actual)
+ require.NotNil(t, feature.ActualMs)
+ assert.Equal(t, int64(0), *feature.ActualMs)
},
},
{
@@ -2951,6 +3001,8 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
// Actual is populated from usage, which is zero for this license.
require.NotNil(t, feature.Actual)
require.EqualValues(t, 0, *feature.Actual)
+ require.NotNil(t, feature.ActualMs)
+ require.EqualValues(t, 0, *feature.ActualMs)
require.NotNil(t, feature.UsagePeriod)
require.WithinDuration(t, licIat, feature.UsagePeriod.IssuedAt, 2*time.Second)
require.WithinDuration(t, licNbf, feature.UsagePeriod.Start, 2*time.Second)
@@ -2968,6 +3020,7 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
require.EqualValues(t, 100, rawFeature["limit"])
require.EqualValues(t, 80, rawFeature["soft_limit"])
require.EqualValues(t, 120, rawFeature["hard_limit"])
+ require.EqualValues(t, 0, rawFeature["actual_ms"])
require.Contains(t, rawFeature, "usage_period")
})
diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go
index d006a5b16d0..953f9dd1935 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -158,9 +158,11 @@ func TestPostLicense(t *testing.T) {
require.NotNil(t, feature.UsagePeriod)
// Actual is read from usage_events, which has no runtime events in
// this deployment. It is reported in whole hours, matching the unit
- // of the claims above.
+ // of the claims above, with the precise milliseconds in ActualMs.
require.NotNil(t, feature.Actual)
require.EqualValues(t, 0, *feature.Actual)
+ require.NotNil(t, feature.ActualMs)
+ require.EqualValues(t, 0, *feature.ActualMs)
require.Empty(t, entitlements.Errors)
// Zero usage is below both thresholds, so no runtime warning
// fires. Unrelated warnings from this bare license are ignored.
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 4eb6eb6f941..098b19cb328 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5171,11 +5171,18 @@ export interface Feature {
* point-in-time count for most features, or usage accumulated over
* UsagePeriod for features that set one. Its unit matches Limit's;
* FeatureAgentRuntimeHours reports whole hours floored from the
- * recorded milliseconds. FeatureAgentRuntimeHours usage can trail by
- * roughly one hour because the current hour is not emitted, plus the
- * entitlement refresh interval.
+ * recorded milliseconds, with the precise value available in
+ * ActualMs. FeatureAgentRuntimeHours usage can trail by roughly one
+ * hour because the current hour is not emitted, plus the entitlement
+ * refresh interval.
*/
readonly actual?: number;
+ /**
+ * ActualMs is the precise usage backing Actual, in milliseconds, for
+ * features measured in time. It has the same freshness as Actual.
+ * Only FeatureAgentRuntimeHours sets this field.
+ */
+ readonly actual_ms?: number;
/**
* UsagePeriod denotes that the usage is a counter that accumulates over
* this period (and most likely resets with the issuance of the next
From 9c83be40cc6de55ae79b22a54ec3b659bb39868f Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:37:13 +0000
Subject: [PATCH 044/116] chore(coderd/notifications): sync formatting and
rendered-template goldens with generators
make fmt/go (gci) regroups the imports in smtp_internal_test.go, and
make gen/golden-files re-renders the AI budget notification goldens with
HTML-escaped apostrophes. Both were committed out of sync with the
repo's pinned tools, so every local pre-commit run failed its unstaged
check. No behavior change.
---
coderd/notifications/dispatch/smtp_internal_test.go | 1 +
.../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ----
.../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++--
.../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++--
.../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++--
5 files changed, 7 insertions(+), 10 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 2e7dff8cbec..3557a42a0e3 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -9,6 +9,7 @@ import (
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
+ markdown "github.com/coder/coder/v2/coderd/render"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
index 550fd9b56fd..f64981add03 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
@@ -17,8 +17,6 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
Effective group: Engineering
-This limit is a per-user override.
-
AI budget period: July 1, 2026 - August 1, 2026
@@ -56,8 +54,6 @@ limit ($1000.00). Subsequent requests will be blocked.
Effective group: Engineering
-This limit is a per-user override.
-
AI budget period: July 1, 2026 - August 1, 2026
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
index 37db6f733cc..94d00686923 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+ Codestin Search App
- You've reached your monthly AI budget limit
+ You've reached your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
index 3927ab28e31..4d5ffdf4744 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+
Codestin Search App
- You're approaching your monthly AI budget limit
+ You're approaching your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
index 4315def7665..1523ba12285 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
@@ -24,6 +24,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
- "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
- "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
+ "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
+ "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
}
\ No newline at end of file
From 5b38bab9bc7295c6015409e43c3ddcf6f294fc52 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:38:12 +0000
Subject: [PATCH 045/116] fix: enforce uniqueness and hour alignment for agent
runtime usage events
The usage generator writes hb_agent_runtime_v1 rows with created_at at
the UTC hourly bucket start and exactly one row per bucket, but nothing
in the schema enforced either invariant. A duplicate bucket row under a
different id would be double-counted by any consumer summing runtime_ms,
and a misaligned created_at would skew which usage period a bucket is
attributed to.
Replace the non-unique partial index idx_usage_events_agent_runtime with
a unique index of the same shape and add an hour-alignment CHECK
constraint. generateBucket treats a unique violation on the bucket index
as another replica having won the race, mirroring the existing
ON CONFLICT (id) no-op for committed rows.
---
coderd/database/check_constraint.go | 1 +
coderd/database/dump.sql | 5 ++-
...e_events_agent_runtime_invariants.down.sql | 9 ++++
...age_events_agent_runtime_invariants.up.sql | 26 +++++++++++
coderd/database/querier.go | 8 +++-
coderd/database/querier_test.go | 41 +++++++++++++++---
coderd/database/queries.sql.go | 8 +++-
coderd/database/queries/usageevents.sql | 8 +++-
coderd/database/unique_constraint.go | 1 +
enterprise/coderd/usage/generator.go | 38 ++++++++--------
.../coderd/usage/generator_internal_test.go | 43 +++++++++++++++++++
11 files changed, 155 insertions(+), 33 deletions(-)
create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
create mode 100644 enterprise/coderd/usage/generator_internal_test.go
diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go
index 268009cd29b..b07a16a5178 100644
--- a/coderd/database/check_constraint.go
+++ b/coderd/database/check_constraint.go
@@ -55,6 +55,7 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
+ CheckUsageEventsAgentRuntimeHourAligned CheckConstraint = "usage_events_agent_runtime_hour_aligned" // usage_events
CheckUserAIBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
CheckUserAIProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
CheckUserSecretsEnabledRequiresTarget CheckConstraint = "user_secrets_enabled_requires_target" // user_secrets
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 79e987cc084..f869088245f 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -3544,7 +3544,8 @@ CREATE TABLE usage_events (
publish_started_at timestamp with time zone,
published_at timestamp with time zone,
failure_message text,
- CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text])))
+ CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text]))),
+ CONSTRAINT usage_events_agent_runtime_hour_aligned CHECK (((event_type <> 'hb_agent_runtime_v1'::text) OR (date_trunc('hour'::text, timezone('UTC'::text, created_at)) = timezone('UTC'::text, created_at))))
);
COMMENT ON TABLE usage_events IS 'usage_events contains usage data that is collected from the product and potentially shipped to the usage collector service.';
@@ -4891,7 +4892,7 @@ CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree
CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
-CREATE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
+CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
CREATE INDEX idx_usage_events_ai_seats ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_ai_seats_v1'::text);
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
new file mode 100644
index 00000000000..ed8b4ae78b8
--- /dev/null
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
@@ -0,0 +1,9 @@
+-- IF EXISTS matches 000561's down migration, so rolling back out of order
+-- (e.g. during an incident) is a no-op instead of a failure.
+DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
+CREATE INDEX idx_usage_events_agent_runtime
+ ON usage_events (event_type, created_at)
+ WHERE event_type = 'hb_agent_runtime_v1';
+
+ALTER TABLE usage_events
+ DROP CONSTRAINT IF EXISTS usage_events_agent_runtime_hour_aligned;
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
new file mode 100644
index 00000000000..33c0c28f1a7
--- /dev/null
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
@@ -0,0 +1,26 @@
+-- The usage generator writes hb_agent_runtime_v1 rows with created_at at
+-- the UTC hourly bucket start and exactly one row per bucket. Uniqueness
+-- keeps any consumer that sums runtime_ms from counting a bucket twice;
+-- the alignment CHECK protects the attribution model, which charges a
+-- bucket to the usage period containing its start.
+--
+-- Both statements validate existing rows. Every supported writer has always
+-- produced conforming data, so a pre-existing violator is anomalous and
+-- failing the migration loudly beats silently rewriting usage rows.
+ALTER TABLE usage_events
+ ADD CONSTRAINT usage_events_agent_runtime_hour_aligned
+ CHECK (
+ event_type <> 'hb_agent_runtime_v1'
+ OR date_trunc('hour', (created_at AT TIME ZONE 'UTC')) = (created_at AT TIME ZONE 'UTC')
+ );
+
+-- Replace the non-unique partial index with a unique one of the same shape,
+-- so reads are served identically. Inserts keep their (id) arbiter:
+-- re-inserting a bucket under its deterministic id stays a silent no-op,
+-- while a duplicate bucket row under a different id raises instead of being
+-- counted twice (generateBucket in enterprise/coderd/usage/generator.go
+-- handles the violation).
+DROP INDEX idx_usage_events_agent_runtime;
+CREATE UNIQUE INDEX idx_usage_events_agent_runtime
+ ON usage_events (event_type, created_at)
+ WHERE event_type = 'hb_agent_runtime_v1';
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 34e74dedc4f..cf563ec624c 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -1159,8 +1159,12 @@ type sqlcQuerier interface {
InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error
InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error)
InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error)
- // Duplicate events are ignored intentionally to allow for multiple replicas to
- // publish heartbeat events.
+ // Duplicate events are ignored intentionally to allow for multiple replicas
+ // to publish heartbeat events. The (id) arbiter scopes that tolerance to
+ // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+ // bucket under a different id raises on idx_usage_events_agent_runtime
+ // instead, which generateBucket in enterprise/coderd/usage/generator.go
+ // handles.
InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
// InsertUserGroupsByID adds a user to all provided groups, if they exist.
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index d416a3f6880..a873b84990c 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -10911,11 +10911,10 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb_agent_runtime_v1:2025-01-02_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 250}`, day2)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
- // Re-inserting a bucket must not double-count it. The daily rollup
- // sums runtime_ms, so idempotency rests on the aggregate trigger
- // being AFTER INSERT: Postgres does not fire it for rows suppressed
- // by ON CONFLICT (id) DO NOTHING. Concurrent replicas and backfill
- // re-runs both take this path.
+ // Re-inserting a bucket under its deterministic id must not
+ // double-count it: the daily rollup's AFTER INSERT trigger does not
+ // fire for rows suppressed by the insert's ON CONFLICT (id)
+ // arbiter.
insert("hb_agent_runtime_v1:2025-01-01_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 1000}`, day1)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
@@ -10923,6 +10922,38 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb-seats-1", "hb_ai_seats_v1", `{"count": 3}`, day2)
rows := getDailyRows(ctx, sqlDB)
require.Len(t, rows, 3)
+
+ // The same bucket under a different id is not an idempotent
+ // re-insert but a duplicate that would double any aggregate summing
+ // runtime_ms; the unique partial index
+ // idx_usage_events_agent_runtime rejects it loudly instead of the
+ // (id) arbiter silently dropping it.
+ err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "different-id-same-bucket",
+ EventType: "hb_agent_runtime_v1",
+ EventData: []byte(`{"runtime_ms": 9999}`),
+ CreatedAt: day1,
+ })
+ require.True(t, database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime),
+ "expected unique violation on idx_usage_events_agent_runtime, got %v", err)
+ // The rejected row must not have reached the daily rollup either.
+ rows = getDailyRows(ctx, sqlDB)
+ require.Len(t, rows, 3)
+ require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
+
+ // created_at must be the exact UTC hourly bucket start;
+ // usage_events_agent_runtime_hour_aligned rejects a misaligned row
+ // so it cannot skew the period a bucket is attributed to.
+ err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "hb_agent_runtime_v1:misaligned",
+ EventType: "hb_agent_runtime_v1",
+ EventData: []byte(`{"runtime_ms": 100}`),
+ CreatedAt: day1.Add(30 * time.Minute),
+ })
+ require.ErrorContains(t, err, string(database.CheckUsageEventsAgentRuntimeHourAligned))
+ rows = getDailyRows(ctx, sqlDB)
+ require.Len(t, rows, 3)
+ require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
})
t.Run("UnknownEventType", func(t *testing.T) {
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 5ada09d52b0..ebb6013162f 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28607,8 +28607,12 @@ type InsertUsageEventParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
-// Duplicate events are ignored intentionally to allow for multiple replicas to
-// publish heartbeat events.
+// Duplicate events are ignored intentionally to allow for multiple replicas
+// to publish heartbeat events. The (id) arbiter scopes that tolerance to
+// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+// bucket under a different id raises on idx_usage_events_agent_runtime
+// instead, which generateBucket in enterprise/coderd/usage/generator.go
+// handles.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 8ba706b0c8a..03f435c5af7 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -1,6 +1,10 @@
-- name: InsertUsageEvent :exec
--- Duplicate events are ignored intentionally to allow for multiple replicas to
--- publish heartbeat events.
+-- Duplicate events are ignored intentionally to allow for multiple replicas
+-- to publish heartbeat events. The (id) arbiter scopes that tolerance to
+-- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+-- bucket under a different id raises on idx_usage_events_agent_runtime
+-- instead, which generateBucket in enterprise/coderd/usage/generator.go
+-- handles.
INSERT INTO
usage_events (
id,
diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go
index 4b1a4376f2d..23256ed3b86 100644
--- a/coderd/database/unique_constraint.go
+++ b/coderd/database/unique_constraint.go
@@ -160,6 +160,7 @@ const (
UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text)));
UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true);
UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
+ UniqueIndexUsageEventsAgentRuntime UniqueConstraint = "idx_usage_events_agent_runtime" // CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE ((deleted = false) AND (email <> ''::text));
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index 91d911b57a7..a5b6788a214 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -48,8 +48,11 @@ const (
// Cron jobs, which sample live state when they fire, the Generator derives
// events from data already persisted in the database, so it can
// deterministically backfill hours missed while the deployment was down,
-// zero-filling idle hours. Deterministic event IDs plus the database's
-// ON CONFLICT (id) DO NOTHING make concurrent replicas safe without locking.
+// zero-filling idle hours. Deterministic event IDs make concurrent replicas
+// safe without locking: a re-insert of a committed bucket is a no-op via the
+// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted
+// bucket surface a unique violation that generateBucket recognizes as the
+// other replica winning.
//
// Events are generated unconditionally in enterprise builds; the
// publish_usage_data license flag only gates publishing to Tallyman.
@@ -155,24 +158,12 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error {
return xerrors.Errorf("list existing agent runtime events: %w", err)
}
// A row marks its bucket complete regardless of publish outcome, so a
- // bucket whose event Tallyman permanently rejected is never
- // regenerated (re-inserting under the deterministic ID is a no-op via
- // ON CONFLICT (id) DO NOTHING).
- //
- // The runtime is not lost locally: the row still holds it, and the
- // event can be re-queued for publishing with
- //
- // UPDATE usage_events
- // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL
- // WHERE id = 'hb_agent_runtime_v1:';
- //
- // That re-arm only has an effect while the bucket is inside the
- // publisher's 30-day cutoff: SelectUsageEventsForPublishing also
- // filters created_at > now - INTERVAL '30 days', and created_at is the
- // bucket start, so past that the UPDATE reports success but the row is
- // never picked up again. The release gate (Tallyman must accept this
- // event type before coderd ships it) is what keeps permanent
- // rejections exceptional.
+ // bucket whose event Tallyman permanently rejected is never regenerated
+ // (re-inserting under the deterministic ID is a no-op via the insert's
+ // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row
+ // keeps it, and clearing the row's publish columns re-queues it while
+ // the bucket is within SelectUsageEventsForPublishing's 30-day
+ // created_at cutoff.
existing := make(map[time.Time]struct{}, len(existingTimes))
for _, ts := range existingTimes {
// created_at is always the exact bucket start for this event type;
@@ -235,6 +226,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
// time) so daily rollups attribute backfilled hours to the correct day.
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
+ if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
+ // The insert's ON CONFLICT (id) arbiter only sees committed rows, so
+ // a concurrent replica inserting the same bucket can trip the bucket
+ // unique index instead. Either way a row for this bucket already
+ // exists, which is all generateBucket needs.
+ return nil
+ }
if err != nil {
return xerrors.Errorf("insert usage event: %w", err)
}
diff --git a/enterprise/coderd/usage/generator_internal_test.go b/enterprise/coderd/usage/generator_internal_test.go
new file mode 100644
index 00000000000..fa6aeb63440
--- /dev/null
+++ b/enterprise/coderd/usage/generator_internal_test.go
@@ -0,0 +1,43 @@
+package usage
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ "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/testutil"
+ "github.com/coder/quartz"
+)
+
+// TestGenerateBucketUniqueViolation pins that a unique violation on the
+// bucket index resolves the bucket as complete: another writer already
+// recorded it. TestGeneratorConcurrentReplicas also reaches this path, but
+// only when its goroutines actually interleave; this case cannot pass by
+// scheduling accident.
+func TestGenerateBucketUniqueViolation(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitShort)
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+ gen := NewGenerator(quartz.NewMock(t), slogtest.Make(t, nil), mDB, NewDBInserter())
+
+ mDB.EXPECT().
+ GetTotalChatMessageRuntimeMsInRange(gomock.Any(), gomock.Any()).
+ Return(int64(1000), nil)
+ mDB.EXPECT().
+ InsertUsageEvent(gomock.Any(), gomock.Any()).
+ Return(&pq.Error{
+ Code: "23505", // unique_violation
+ Constraint: string(database.UniqueIndexUsageEventsAgentRuntime),
+ })
+
+ bucket := time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC)
+ require.NoError(t, gen.generateBucket(ctx, bucket))
+}
From 32553088d348b82099789a7edf1187e9cee70b4c Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 12:04:06 +0000
Subject: [PATCH 046/116] revert: "chore(coderd/notifications): sync formatting
and rendered-template goldens with generators"
This reverts commit ed531d9d365720b4e394f4b422ea9776886c56a3.
The gen/fmt drift it patched over came from main commit 07f79af65b2
(fix: markdown rendering improvements) having landed out of sync with
its generators; main has since reverted that commit entirely in
8c2f7adeb1f (#27979), so the sync is obsolete and conflicts with the
regenerated goldens on latest main.
---
coderd/notifications/dispatch/smtp_internal_test.go | 3 ++-
.../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ++++
.../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++--
.../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++--
.../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++--
5 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 3557a42a0e3..03324f78573 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -7,9 +7,10 @@ import (
"github.com/stretchr/testify/require"
+ markdown "github.com/coder/coder/v2/coderd/render"
+
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
- markdown "github.com/coder/coder/v2/coderd/render"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
index f64981add03..550fd9b56fd 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
@@ -17,6 +17,8 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
Effective group: Engineering
+This limit is a per-user override.
+
AI budget period: July 1, 2026 - August 1, 2026
@@ -54,6 +56,8 @@ limit ($1000.00). Subsequent requests will be blocked.
Effective group: Engineering
+This limit is a per-user override.
+
AI budget period: July 1, 2026 - August 1, 2026
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
index 94d00686923..37db6f733cc 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+ Codestin Search App
- You've reached your monthly AI budget limit
+ You've reached your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
index 4d5ffdf4744..3927ab28e31 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+
Codestin Search App
- You're approaching your monthly AI budget limit
+ You're approaching your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
index 1523ba12285..4315def7665 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
@@ -24,6 +24,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
- "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
- "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
+ "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
+ "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
}
\ No newline at end of file
From 74bac4d3bfca01ea48297545b7deb60d23e9fa8c Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 13:21:33 +0000
Subject: [PATCH 047/116] updates for PR review
---
.../000565_usage_events_agent_runtime_invariants.down.sql | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
index ed8b4ae78b8..8a6a0240988 100644
--- a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
@@ -1,5 +1,6 @@
--- IF EXISTS matches 000561's down migration, so rolling back out of order
--- (e.g. during an incident) is a no-op instead of a failure.
+-- IF EXISTS matches 000561's down migration: if the index is already gone
+-- (e.g. rolling back out of order during an incident), tolerate the miss
+-- instead of failing.
DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
CREATE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
From b08cdff7dee33dca7721f03c49ec308e085eb46e Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:16:52 +0000
Subject: [PATCH 048/116] chore(coderd/database): restore original
InsertUsageEvent comment
The expanded arbiter-scoping commentary is not needed on the insert
query; the duplicate-bucket behavior is documented on the migration and
in generateBucket where it is handled.
---
coderd/database/querier.go | 8 ++------
coderd/database/queries.sql.go | 8 ++------
coderd/database/queries/usageevents.sql | 8 ++------
3 files changed, 6 insertions(+), 18 deletions(-)
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index cf563ec624c..34e74dedc4f 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -1159,12 +1159,8 @@ type sqlcQuerier interface {
InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error
InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error)
InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error)
- // Duplicate events are ignored intentionally to allow for multiple replicas
- // to publish heartbeat events. The (id) arbiter scopes that tolerance to
- // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
- // bucket under a different id raises on idx_usage_events_agent_runtime
- // instead, which generateBucket in enterprise/coderd/usage/generator.go
- // handles.
+ // Duplicate events are ignored intentionally to allow for multiple replicas to
+ // publish heartbeat events.
InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
// InsertUserGroupsByID adds a user to all provided groups, if they exist.
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index ebb6013162f..5ada09d52b0 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28607,12 +28607,8 @@ type InsertUsageEventParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
-// Duplicate events are ignored intentionally to allow for multiple replicas
-// to publish heartbeat events. The (id) arbiter scopes that tolerance to
-// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
-// bucket under a different id raises on idx_usage_events_agent_runtime
-// instead, which generateBucket in enterprise/coderd/usage/generator.go
-// handles.
+// Duplicate events are ignored intentionally to allow for multiple replicas to
+// publish heartbeat events.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 03f435c5af7..8ba706b0c8a 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -1,10 +1,6 @@
-- name: InsertUsageEvent :exec
--- Duplicate events are ignored intentionally to allow for multiple replicas
--- to publish heartbeat events. The (id) arbiter scopes that tolerance to
--- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
--- bucket under a different id raises on idx_usage_events_agent_runtime
--- instead, which generateBucket in enterprise/coderd/usage/generator.go
--- handles.
+-- Duplicate events are ignored intentionally to allow for multiple replicas to
+-- publish heartbeat events.
INSERT INTO
usage_events (
id,
From 414fa85c93d588d75e624dbf8ac58f33539d7dda Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:46:36 +0000
Subject: [PATCH 049/116] fix(coderd/database): renumber agent runtime
invariants migration to 000567
Main landed its own 000565 and 000566 (oauth2 client type constraint and
auth method backfill) after this branch was cut, making the migration
number a duplicate that fails migrate setup.
---
....sql => 000567_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.down.sql => 000567_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
From 6b0ea7455468913581107d8738c5666e92e2db06 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 07:12:28 +0000
Subject: [PATCH 050/116] chore(enterprise/coderd/usage): restore agent runtime
requeue guidance comment
---
enterprise/coderd/usage/generator.go | 24 ++++++++++++++++++------
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index a5b6788a214..af7fcd713ee 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -158,12 +158,24 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error {
return xerrors.Errorf("list existing agent runtime events: %w", err)
}
// A row marks its bucket complete regardless of publish outcome, so a
- // bucket whose event Tallyman permanently rejected is never regenerated
- // (re-inserting under the deterministic ID is a no-op via the insert's
- // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row
- // keeps it, and clearing the row's publish columns re-queues it while
- // the bucket is within SelectUsageEventsForPublishing's 30-day
- // created_at cutoff.
+ // bucket whose event Tallyman permanently rejected is never
+ // regenerated (re-inserting under the deterministic ID is a no-op via
+ // the insert's ON CONFLICT (id) arbiter).
+ //
+ // The runtime is not lost locally: the row still holds it, and the
+ // event can be re-queued for publishing with
+ //
+ // UPDATE usage_events
+ // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL
+ // WHERE id = 'hb_agent_runtime_v1:';
+ //
+ // That re-arm only has an effect while the bucket is inside the
+ // publisher's 30-day cutoff: SelectUsageEventsForPublishing also
+ // filters created_at > now - INTERVAL '30 days', and created_at is the
+ // bucket start, so past that the UPDATE reports success but the row is
+ // never picked up again. The release gate (Tallyman must accept this
+ // event type before coderd ships it) is what keeps permanent
+ // rejections exceptional.
existing := make(map[time.Time]struct{}, len(existingTimes))
for _, ts := range existingTimes {
// created_at is always the exact bucket start for this event type;
From 8f174bbc189b46a7035bb377af234e3fd782d6be Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:32:48 +0000
Subject: [PATCH 051/116] fix(coderd/database): renumber agent runtime
invariants migration to 000569
Main landed its own 000567 and 000568 (chat file purge indexes and
service account notifications) after this branch was renumbered to
000567, making the migration number a duplicate that fails migrate
setup in the test merge.
---
....sql => 000569_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.down.sql => 000569_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
From 59ca44461a8e3bd43e91188e898df37da76c6cdf Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:45:24 +0000
Subject: [PATCH 052/116] chore(enterprise/coderd/usage): correct ON CONFLICT
concurrency comments
The (id) arbiter is not limited to committed rows: when a competing
row's arbiter index entry is visible, PostgreSQL waits on that
transaction and takes the DO NOTHING path if it commits. Only the
narrower speculative-insertion race, before the arbiter entry exists,
trips the bucket unique index.
---
enterprise/coderd/usage/generator.go | 22 ++++++++++++++--------
1 file changed, 14 insertions(+), 8 deletions(-)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index af7fcd713ee..cec94a1a511 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -49,10 +49,13 @@ const (
// events from data already persisted in the database, so it can
// deterministically backfill hours missed while the deployment was down,
// zero-filling idle hours. Deterministic event IDs make concurrent replicas
-// safe without locking: a re-insert of a committed bucket is a no-op via the
-// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted
-// bucket surface a unique violation that generateBucket recognizes as the
-// other replica winning.
+// safe without locking: the insert's ON CONFLICT (id) arbiter turns a
+// re-insert of a bucket into a no-op, even when the competing insert is
+// still in flight (once its arbiter index entry is visible, PostgreSQL
+// waits on that transaction and takes the DO NOTHING path if it commits).
+// Only the narrow speculative-insertion race, before the competing row's
+// arbiter entry exists, surfaces a bucket unique violation instead, which
+// generateBucket recognizes as the other replica winning.
//
// Events are generated unconditionally in enterprise builds; the
// publish_usage_data license flag only gates publishing to Tallyman.
@@ -239,10 +242,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
- // The insert's ON CONFLICT (id) arbiter only sees committed rows, so
- // a concurrent replica inserting the same bucket can trip the bucket
- // unique index instead. Either way a row for this bucket already
- // exists, which is all generateBucket needs.
+ // The insert's ON CONFLICT (id) arbiter absorbs most duplicate
+ // inserts, including in-flight ones: once a competing row's arbiter
+ // index entry is visible, PostgreSQL waits on that transaction and
+ // takes the DO NOTHING path if it commits. Only the narrow
+ // speculative-insertion race, before that entry exists, trips the
+ // bucket unique index instead. Either way a row for this bucket
+ // already exists, which is all generateBucket needs.
return nil
}
if err != nil {
From e8efb48e52882965fa1856a2d6f8f1123fa66634 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Thu, 13 Aug 2026 04:37:09 +0000
Subject: [PATCH 053/116] fix(coderd/notifications/dispatch): drop unused
markdown import in smtp test
The revert in cb36cfae2c5 left behind an unused
"github.com/coder/coder/v2/coderd/render" import, breaking compilation
of the dispatch test package and failing fmt, lint, and every
test-go-pg job. Restore the import block to match main.
---
coderd/notifications/dispatch/smtp_internal_test.go | 2 --
1 file changed, 2 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 03324f78573..2e7dff8cbec 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -7,8 +7,6 @@ import (
"github.com/stretchr/testify/require"
- markdown "github.com/coder/coder/v2/coderd/render"
-
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
)
From 92cb793717bac64fdc0bd1556a74e2fffda9391d Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:58:22 +0000
Subject: [PATCH 054/116] feat: report stable license measurement diagnostics
and tolerate unusable runtime hours claims
Two coupled changes to the license/entitlements layer.
Measurement diagnostics: extract a measureUsage helper that owns the
usage-query failure policy. A query failure now publishes the stable
codersdk.LicenseManagedAgentUsageUnavailableErrorText on the
entitlements Errors channel (keeping the alertable coderd_license_errors
gauge counting) while the raw cause goes to the coderd log through the
new FeatureArguments.Logger. A nil usage closure becomes a hard
developer error, and a failure with a dead context still aborts the
whole refresh. The managed agent count closure switches from
AsSystemRestricted to the narrower AsUsagePublisher subject. The
dashboard recognizes diagnostic texts and renders them muted, without
the exceedance heading or a sales link.
Claim tolerance: unusable agent_runtime_hours_* claim combinations no
longer reject the whole license. decodeAgentRuntimeHours drops the
unusable claims, reports them in a stable
LicenseAgentRuntimeHoursClaimsIgnoredWarningText warning (deduplicated
across licenses), and logs the affected license and claims;
validateAgentRuntimeHours and its license-invalidating errors are
removed. Rejecting a signed license over a cosmetic threshold claim
would drop the deployment to unlicensed.
Part of the stack for coder/coder#27796.
---
codersdk/licenses.go | 13 +
enterprise/coderd/coderd_test.go | 6 +
.../coderd/coderdenttest/coderdenttest.go | 15 +
enterprise/coderd/license/license.go | 228 +++++----
enterprise/coderd/license/license_test.go | 476 +++++++++++++++---
enterprise/coderd/licenses_test.go | 35 +-
site/src/api/typesGenerated.ts | 23 +
.../dashboard/LicenseBanner/LicenseBanner.tsx | 54 +-
.../LicenseBannerView.stories.tsx | 113 ++++-
.../LicenseBanner/LicenseBannerView.tsx | 20 +-
10 files changed, 762 insertions(+), 221 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index f2ccf009842..7aecbaa3089 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,6 +17,19 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ // managed agent usage query fails while computing entitlements; the
+ // cause is logged server-side. It travels in the entitlements Errors
+ // channel so the alertable coderd_license_errors gauge counts
+ // measurement failures, but the dashboard recognizes the exact text and
+ // renders it as a muted diagnostic rather than a license error.
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
+ // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ // license carries unusable Coder Agent runtime hour claims (see
+ // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ // the license and the dropped claims. The dashboard renders the exact
+ // text as a muted diagnostic without a sales link.
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
type AddLicenseRequest struct {
diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go
index e023da7b1df..7be01405353 100644
--- a/enterprise/coderd/coderd_test.go
+++ b/enterprise/coderd/coderd_test.go
@@ -92,6 +92,12 @@ func TestEntitlements(t *testing.T) {
// Enable all features
features := make(license.Features)
for _, feature := range codersdk.FeatureNames {
+ if feature == codersdk.FeatureAgentRuntimeHours {
+ // The feature name is not a valid license claim; the
+ // feature is encoded as its allocation claim.
+ features[license.ClaimAgentRuntimeHoursAllocation] = 1
+ continue
+ }
features[feature] = 1
}
features[codersdk.FeatureUserLimit] = 100
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 1115ba12118..8f5f1e4a708 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,6 +237,21 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
+// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
+// or hardLimit omits that claim, which a license is allowed to do; a non-nil
+// value is emitted verbatim, so tests can express explicit zero or negative
+// claims.
+func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
+ opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
+ if softLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitSoft, *softLimit)
+ }
+ if hardLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitHard, *hardLimit)
+ }
+ return opts
+}
+
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
if opts.Features == nil {
opts.Features = license.Features{}
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8cce657de65..e6f3598475e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -93,6 +93,7 @@ func Entitlements(
}
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
+ Logger: logger,
ActiveUserCount: activeUserCount,
ActiveAISeatCount: activeAISeatCount,
ReplicaCount: replicaCount,
@@ -114,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Reading usage events requires the usage publisher subject.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -129,6 +130,9 @@ func Entitlements(
}
type FeatureArguments struct {
+ // Logger receives the causes behind operator-facing diagnostics whose
+ // published message is a stable text. The zero value discards them.
+ Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
ReplicaCount int
@@ -508,11 +512,8 @@ func LicensesEntitlements(
continue
}
- // Agent runtime hours are encoded as up to three claims and are
- // decoded together after this loop, see
- // decodeAgentRuntimeHours. The feature name itself is never a
- // valid claim. The allocation must come from the dedicated claim
- // so it is validated against the soft and hard limits.
+ // Agent runtime hour claims are decoded together after this
+ // loop; see decodeAgentRuntimeHours.
if featureName == codersdk.FeatureAgentRuntimeHours ||
isAgentRuntimeHoursClaim(featureName) {
continue
@@ -577,14 +578,25 @@ func LicensesEntitlements(
}
}
- // The loop above skips Agent runtime hours because the
- // three claims that encode them decode into a single feature.
- if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
+ runtimeFeature, granted, ignoredClaims := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
- }); ok {
- entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature)
+ })
+ if granted {
+ entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
+ }
+ if len(ignoredClaims) > 0 {
+ // The published warning is a stable text, so the details a
+ // support case needs go to the log.
+ featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
+ slog.F("license_id", license.UUID),
+ slog.F("ignored_claims", ignoredClaims),
+ )
+ if !slices.Contains(entitlements.Warnings, codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) {
+ entitlements.Warnings = append(entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ }
}
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
@@ -701,24 +713,18 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- var (
- managedAgentCount int64
- err = xerrors.New("dev error: managed agent count function is not set")
- )
- if featureArguments.ManagedAgentCountFn != nil {
- managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
- }
- if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
- // If the context is canceled, we want to bail the entire
- // LicensesEntitlements call.
- return entitlements, xerrors.Errorf("get managed agent count: %w", err)
- }
+ managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
+ featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
+ "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
if err != nil {
- entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
- // no return
- } else {
+ return entitlements, err
+ }
+ if ok {
agentLimit.Actual = &managedAgentCount
- entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
+ // Written back directly: the feature contest is already
+ // settled, so AddFeature's Compare must not get a chance to
+ // drop the write.
+ entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -857,6 +863,42 @@ func LicensesEntitlements(
return entitlements, nil
}
+// measureUsage runs one usage query over the feature's usage period and owns
+// the shared failure policy: a nil fn is a wiring bug and fails the whole
+// LicensesEntitlements call; a failure with a dead context fails the call
+// without logging; any other failure logs the cause and publishes the stable
+// unavailableText instead. It returns the measured value and true only on
+// success.
+func measureUsage(
+ ctx context.Context,
+ entitlements *codersdk.Entitlements,
+ logger slog.Logger,
+ fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
+ usagePeriod codersdk.UsagePeriod,
+ what string,
+ unavailableText string,
+) (int64, bool, error) {
+ if fn == nil {
+ return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
+ }
+ value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
+ switch {
+ case err != nil && ctx.Err() != nil:
+ // The computation's own context is dead, so abort the whole call
+ // without logging. Do not classify by error shape instead: Postgres
+ // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
+ // as well as client cancels, and aborting on those would fail every
+ // entitlements refresh on a deployment whose statement_timeout is
+ // shorter than a usage query.
+ return 0, false, xerrors.Errorf("get %s: %w", what, err)
+ case err != nil:
+ logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
+ entitlements.Errors = append(entitlements.Errors, unavailableText)
+ return 0, false, nil
+ }
+ return value, true, nil
+}
+
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
@@ -885,23 +927,22 @@ const (
VersionClaim = "version"
)
-// Agent runtime hour license claims. These are the canonical claim names
-// minted by github.com/coder/license. All three claims map to the single
-// codersdk.FeatureAgentRuntimeHours feature and are validated together when
-// the license is parsed, see validateClaims.
-//
-// The unit for all three claims is hours.
+// Agent runtime hour license claims, minted by github.com/coder/license.
+// All three are in hours and decode together into the single
+// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit.
+ // allocation for the license term. It becomes the feature's Limit. A
+ // negative allocation is ignored, in which case the license does not
+ // grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // must satisfy 0 <= soft < allocation, so it may only be set when the
- // allocation is greater than 0. It becomes the feature's SoftLimit.
+ // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
- // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be
- // absent or >= allocation, and may only be set when the allocation is
- // greater than 0. It becomes the feature's HardLimit.
+ // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
+ // the feature's HardLimit when the allocation is greater than 0 and
+ // hard >= allocation, and is ignored otherwise.
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
@@ -917,19 +958,12 @@ var (
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
ErrMissingAccountType = xerrors.New("license must contain valid account type")
ErrMissingAccountID = xerrors.New("license must contain valid account ID")
-
- ErrMissingAgentRuntimeHoursAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation)
- ErrAgentRuntimeHoursLimitsWithZeroAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but the %s claim is 0", ClaimAgentRuntimeHoursAllocation)
)
type Features map[codersdk.FeatureName]int64
-// isAgentRuntimeHoursClaim reports whether the claim name is one of the three
-// claims that encode the codersdk.FeatureAgentRuntimeHours feature. These
-// claims are decoded together, see decodeAgentRuntimeHours.
+// isAgentRuntimeHoursClaim reports whether name is one of the three claims
+// decoded by decodeAgentRuntimeHours.
func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
switch name {
case ClaimAgentRuntimeHoursAllocation,
@@ -941,62 +975,65 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
}
}
-// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours feature
-// from the claims that encode it. It reports false when the license carries no
-// allocation claim, in which case the license does not grant the feature.
+// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours
+// feature from its claims. granted is false when there is no usable
+// allocation claim; per-claim validity rules live on the Claim* constants
+// above.
+//
+// Unusable claims are dropped, never license-invalidating: rejecting a
+// signed license over a cosmetic threshold claim would drop the deployment
+// to unlicensed. ignoredClaims names each dropped claim (including the
+// feature name itself minted as a claim, the most plausible issuer mistake)
+// so the caller can warn and log instead of letting an incorrectly issued
+// license look healthy.
//
-// The claim combination is validated when the license is parsed, see
-// Features.validateAgentRuntimeHours. The allocation is never negative here
-// and the soft and hard limits are only present alongside a positive
-// allocation.
-func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (codersdk.Feature, bool) {
- allocation, ok := features[ClaimAgentRuntimeHoursAllocation]
- if !ok {
- return codersdk.Feature{}, false
+// A zero allocation grants the feature disabled and drops both threshold
+// claims, but Actual is still measured and published. CODAGT-856 will make a
+// zero allocation force a concurrency-limited mode; that mode does not exist
+// yet.
+func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
+ if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
+ ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
}
- feature := codersdk.Feature{
+ allocation, allocOk := features[ClaimAgentRuntimeHoursAllocation]
+ soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
+ hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+
+ if !allocOk || allocation < 0 {
+ if allocOk && allocation < 0 {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
+ }
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{}, false, ignoredClaims
+ }
+
+ feature = codersdk.Feature{
Enabled: allocation > 0,
Entitlement: entitlement,
Limit: &allocation,
UsagePeriod: &usagePeriod,
}
- if soft, ok := features[ClaimAgentRuntimeHoursLimitSoft]; ok {
- feature.SoftLimit = &soft
- }
- if hard, ok := features[ClaimAgentRuntimeHoursLimitHard]; ok {
- feature.HardLimit = &hard
- }
- return feature, true
-}
-
-// validateAgentRuntimeHours validates the relationship between the agent
-// runtime hour claims. Invalid combinations reject the entire license.
-func (f Features) validateAgentRuntimeHours() error {
- allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation]
- soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft]
- hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard]
- if !hasAllocation {
- if hasSoft || hasHard {
- return ErrMissingAgentRuntimeHoursAllocation
+ if softOk {
+ if soft > 0 && soft < allocation {
+ feature.SoftLimit = &soft
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
}
- return nil
- }
- if allocation < 0 {
- return ErrInvalidAgentRuntimeHoursAllocation
- }
- // A zero allocation disables the feature.
- // A zero hard limit is not permitted.
- if allocation == 0 && (hasSoft || hasHard) {
- return ErrAgentRuntimeHoursLimitsWithZeroAllocation
}
- if hasSoft && (soft < 0 || soft >= allocation) {
- return ErrInvalidAgentRuntimeHoursSoftLimit
- }
- if hasHard && hard < allocation {
- return ErrInvalidAgentRuntimeHoursHardLimit
+ if hardOk {
+ if allocation > 0 && hard >= allocation {
+ feature.HardLimit = &hard
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
}
- return nil
+ return feature, true, ignoredClaims
}
// Claims is the full set of claims in a license.
@@ -1089,9 +1126,6 @@ func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims.AccountID == "" {
return nil, ErrMissingAccountID
}
- if err := claims.Features.validateAgentRuntimeHours(); err != nil {
- return nil, err
- }
return claims, nil
}
return nil, xerrors.New("unable to parse Claims")
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 6fe50902850..db68fb8b60a 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -1,9 +1,11 @@
package license_test
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "maps"
"slices"
"testing"
"time"
@@ -13,12 +15,17 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
+ "golang.org/x/xerrors"
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "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/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -30,6 +37,42 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
+// premiumRuntimeHoursFixture returns a mock store primed with a Premium
+// license carrying runtime hour claims (allocation 100, soft limit 80, hard
+// limit 120) plus the store expectations every entitlements refresh consumes
+// before usage is measured. Callers add expectations for the usage queries
+// under test.
+func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
+ t.Helper()
+
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+
+ licenseOpts := (&coderdenttest.LicenseOptions{
+ FeatureSet: codersdk.FeatureSetPremium,
+ IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
+ NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
+ GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
+ ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
+ // The addon marks AI Bridge as explicitly entitled, suppressing
+ // the unrelated "AI Governance add-on is required to use AI
+ // Gateway" warning that Premium would otherwise produce.
+ }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
+
+ lic := database.License{
+ ID: 1,
+ JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
+ Exp: licenseOpts.ExpiresAt,
+ }
+
+ mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
+ mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
+ mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
+ mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
+
+ return mDB, licenseOpts
+}
+
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -920,6 +963,63 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
+ t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
+ t.Parallel()
+
+ // Drive the real Entitlements closure with a mock database so
+ // measureUsage's failure path is exercised end to end: the cause
+ // must land in the coderd log, which the stable payload text points
+ // at, and must not land on the unauthenticated entitlements payload.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), xerrors.New("kaboom managed"))
+
+ // The error-level logs are the behavior under test, so the default
+ // failing test logger cannot be used.
+ var logBuf bytes.Buffer
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
+ AppendSinks(sloghuman.Sink(&logBuf))
+
+ entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+
+ // The failure surfaces its stable text without the raw cause,
+ // on the channel the codersdk constant docs prescribe.
+ require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
+ for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
+ require.NotContains(t, entry, "kaboom")
+ }
+
+ logs := logBuf.String()
+ require.Contains(t, logs, "get managed agent count for entitlements")
+ require.Contains(t, logs, "kaboom managed")
+ })
+
+ t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
+ t.Parallel()
+
+ // A query failing while the refresh's own context is canceled,
+ // e.g. during shutdown, aborts the whole entitlements refresh and
+ // must not log a false query-failure alarm at error level.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), context.Canceled)
+
+ var logBuf bytes.Buffer
+ logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.ErrorContains(t, err, "get managed agent count")
+ require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
+ })
+
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1299,6 +1399,12 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
+ // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
+ // injection below so the nil dev-error path can be exercised.
+ KeepNilManagedAgentCountFn bool
+ // CancelContext cancels the context passed to LicensesEntitlements
+ // before the call, exercising the usage-measurement abort policy.
+ CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1550,6 +1656,59 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
+ {
+ // A query failure is surfaced as a stable text in Errors (see
+ // the codersdk constant docs for the channel choice) and
+ // leaves Actual unset without aborting the rest of the
+ // entitlements.
+ Name: "ManagedAgentLimit/QueryError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, xerrors.New("kaboom")
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoWarnings(t, entitlements)
+ require.Len(t, entitlements.Errors, 1)
+ assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
+ // The raw error is logged rather than exposed on the
+ // unauthenticated entitlements payload.
+ assert.NotContains(t, entitlements.Errors[0], "kaboom")
+ feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
+ assert.Nil(t, feature.Actual)
+ },
+ },
+ {
+ // Forgetting to wire ManagedAgentCountFn is a dev error:
+ // production always provides the closure, so it fails the whole
+ // call loudly instead of degrading into an operator-facing
+ // message.
+ Name: "ManagedAgentLimit/NilFnDevError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ KeepNilManagedAgentCountFn: true,
+ ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
+ },
+ {
+ // A failure while the computation's own context is canceled
+ // aborts the whole call rather than degrading to an
+ // entitlements error.
+ Name: "ManagedAgentLimit/ContextCanceled",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ CancelContext: true,
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, context.Canceled
+ },
+ },
+ ExpectedErrorContains: "get managed agent count",
+ },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1581,13 +1740,18 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil {
+ if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
-
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+ ctx := context.Background()
+ if tc.CancelContext {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithCancel(ctx)
+ cancel()
+ }
+ entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1612,6 +1776,15 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
+ // A Premium license grants a managed agent limit by default, and a nil
+ // usage closure is a hard developer error, so these subtests wire a
+ // zero-usage measurement closure.
+ zeroUsageArgs := license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
+ }
+
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1631,7 +1804,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1658,7 +1831,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1690,7 +1863,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1721,7 +1894,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1734,7 +1907,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -2535,15 +2708,22 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
})
}
-// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the
-// agent runtime hour claims reject the entire license.
-func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
+// TestAgentRuntimeHoursClaimTolerance pins decodeAgentRuntimeHours's
+// tolerate-and-warn contract; see that function's doc for the rationale.
+func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
t.Parallel()
testCases := []struct {
- name string
- features license.Features
- expectedErr error
+ name string
+ features license.Features
+
+ // expectFeature is nil when the feature must be absent.
+ expectFeature *codersdk.Feature
+ // expectClaimsIgnored is true when at least one present claim is
+ // dropped, which must surface the claims-ignored warning: tolerating
+ // a claim and signaling nothing would make an incorrectly issued license
+ // undetectable from the deployment.
+ expectClaimsIgnored bool
}{
{
name: "AllClaims",
@@ -2552,109 +2732,172 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](80),
+ HardLimit: ptr.Ref[int64](120),
+ },
},
{
name: "AllocationOnly",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
},
{
+ // A zero soft limit would warn at zero usage forever, so it is
+ // dropped rather than rejecting the license. The canonical way
+ // to express "no soft limit" is omitting the claim, so a
+ // present-but-dropped zero still warns.
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "HardEqualsAllocation",
+ name: "NegativeSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocation",
+ // A soft limit at or above the allocation could never fire
+ // before the allocation warning supersedes it.
+ name: "SoftEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithZeroHard",
+ name: "SoftAboveAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithPositiveHard",
+ name: "HardEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 100,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ HardLimit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
- name: "SoftWithoutAllocation",
+ name: "HardBelowAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 99,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardWithoutAllocation",
+ name: "ZeroAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitHard: 120,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
- name: "NegativeAllocation",
+ // A zero allocation has no hour budget, so threshold claims
+ // alongside it are dropped, with the warning.
+ name: "ZeroAllocationWithLimits",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "NegativeSoft",
+ name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -1,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftEqualsAllocation",
+ name: "SoftWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftAboveAllocation",
+ name: "HardWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftWithZeroAllocation",
+ // The feature name itself is never a valid claim: the
+ // allocation must come from the dedicated claim. It is the
+ // shape every other metered feature uses, so a license minting
+ // it is the most plausible issuer mistake and must warn
+ // rather than being dropped silently.
+ name: "FeatureNameAsClaim",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitSoft: 0,
+ codersdk.FeatureAgentRuntimeHours: 100,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardBelowAllocation",
+ // The feature name claim is dropped (with the warning) even
+ // when a usable allocation claim grants the feature.
+ name: "FeatureNameAlongsideAllocation",
features: license.Features{
+ codersdk.FeatureAgentRuntimeHours: 50,
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 99,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
}
@@ -2662,46 +2905,115 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
- jwt := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: tc.features,
- })
- _, err := license.ParseClaims(jwt, coderdenttest.Keys)
- if tc.expectedErr == nil {
- require.NoError(t, err)
+ features := license.Features{
+ codersdk.FeatureUserLimit: 100,
+ }
+ maps.Copy(features, tc.features)
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: features,
+ }),
+ }
+
+ var logBuf bytes.Buffer
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
+ )
+ require.NoError(t, err)
+
+ // The license as a whole survives: unrelated paid features are
+ // unaffected by an unusable runtime hour claim.
+ require.Empty(t, entitlements.Errors)
+ require.True(t, entitlements.HasLicense)
+ userLimit := entitlements.Features[codersdk.FeatureUserLimit]
+ require.NotNil(t, userLimit.Limit)
+ require.EqualValues(t, 100, *userLimit.Limit)
+
+ // Dropped claims are tolerated but never silent: the operator
+ // sees the stable warning, and the log names the license and
+ // the dropped claims for support.
+ if tc.expectClaimsIgnored {
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ logs := logBuf.String()
+ require.Contains(t, logs, "ignored unusable Coder Agent runtime hour claims in license")
+ require.Contains(t, logs, lic.UUID.String())
+ } else {
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ require.Empty(t, logBuf.String())
+ }
+
+ // Every known feature name has a default entry in the map, so
+ // "the license does not grant the feature" surfaces as the
+ // default: no limit, no usage period, not enabled.
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ if tc.expectFeature == nil {
+ require.Nil(t, feature.Limit, "feature must not be granted")
+ require.Nil(t, feature.UsagePeriod, "feature must not be granted")
+ require.False(t, feature.Enabled)
return
}
- require.ErrorIs(t, err, tc.expectedErr)
+ require.NotNil(t, feature.UsagePeriod, "feature must be granted")
+ require.Equal(t, tc.expectFeature.Enabled, feature.Enabled)
+ require.Equal(t, tc.expectFeature.Limit, feature.Limit)
+ require.Equal(t, tc.expectFeature.SoftLimit, feature.SoftLimit)
+ require.Equal(t, tc.expectFeature.HardLimit, feature.HardLimit)
})
}
- // An invalid license already stored in the database is rejected entirely
- // and produces an entitlements error.
- t.Run("EntitlementsError", func(t *testing.T) {
+ t.Run("WarningDeduplicatedAcrossLicenses", func(t *testing.T) {
t.Parallel()
- lic := database.License{
- ID: 1,
- UploadedAt: time.Now(),
- Exp: time.Now().Add(time.Hour),
- UUID: uuid.New(),
- JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
- },
- }),
+ // Two licenses with unusable claims must publish the stable warning
+ // once, or the banner would stack identical texts, while the log
+ // names each affected license so the operator can tell which ones
+ // need re-issuing.
+ newLicense := func(id int32) database.License {
+ return database.License{
+ ID: id,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
+ // A threshold without an allocation is unusable.
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ }),
+ }
}
+ licenses := []database.License{newLicense(1), newLicense(2)}
+ var logBuf bytes.Buffer
entitlements, err := license.LicensesEntitlements(
- context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ context.Background(), time.Now(), licenses,
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
)
require.NoError(t, err)
- require.Len(t, entitlements.Errors, 1)
- require.Contains(t, entitlements.Errors[0], fmt.Sprintf("Invalid license (%s) parsing claims", lic.UUID))
- require.False(t, entitlements.HasLicense)
- feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
- require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
+
+ warningCount := 0
+ for _, warning := range entitlements.Warnings {
+ if warning == codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText {
+ warningCount++
+ }
+ }
+ require.Equal(t, 1, warningCount, "the claims-ignored warning must appear exactly once")
+
+ logs := logBuf.String()
+ for _, lic := range licenses {
+ require.Contains(t, logs, lic.UUID.String())
+ }
})
}
diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go
index c88f975f2c0..811929f0939 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -105,35 +106,41 @@ func TestPostLicense(t *testing.T) {
require.Contains(t, errResp.Message, "Invalid license")
})
- t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) {
+ t.Run("UnusableAgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- // A soft limit claim without an allocation claim rejects the whole
- // license.
+ // A soft limit claim without an allocation claim is unusable, but it
+ // never rejects the whole license: the license stays valid, the
+ // runtime hours feature is simply not granted, and the dropped claim
+ // is surfaced as a warning. See decodeAgentRuntimeHours.
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: lic,
})
- errResp := &codersdk.Error{}
- require.ErrorAs(t, err, &errResp)
- require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
- require.Contains(t, errResp.Message, "Invalid license")
+ require.NoError(t, err)
+ // The claims round-trip through GET /api/v2/entitlements.
+ //nolint:gocritic // This test asserts license state, not authz behavior.
+ entitlements, err := client.Entitlements(context.Background())
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+ require.Empty(t, entitlements.Errors)
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.UsagePeriod)
})
t.Run("AgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
- license.ClaimAgentRuntimeHoursLimitHard: 120,
- },
- })
+ coderdenttest.AddLicense(t, client,
+ *(&coderdenttest.LicenseOptions{}).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120)))
// The claims round-trip through GET /api/v2/entitlements.
//nolint:gocritic // This test asserts license state, not authz behavior.
entitlements, err := client.Entitlements(context.Background())
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 0222095336d..c00449c8b51 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5722,6 +5722,17 @@ export const LicenseAIGovernance90PercentWarningText =
export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
+// From codersdk/licenses.go
+/**
+ * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ * license carries unusable Coder Agent runtime hour claims (see
+ * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ * the license and the dropped claims. The dashboard renders the exact
+ * text as a muted diagnostic without a sales link.
+ */
+export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
+ "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
+
// From codersdk/licenses.go
export const LicenseExpiryClaim = "license_expires";
@@ -5729,6 +5740,18 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
+// From codersdk/licenses.go
+/**
+ * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ * managed agent usage query fails while computing entitlements; the
+ * cause is logged server-side. It travels in the entitlements Errors
+ * channel so the alertable coderd_license_errors gauge counts
+ * measurement failures, but the dashboard recognizes the exact text and
+ * renders it as a muted diagnostic rather than a license error.
+ */
+export const LicenseManagedAgentUsageUnavailableErrorText =
+ "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
+
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index ba5f739d93d..9ef4dda8bb1 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -1,8 +1,10 @@
import type { FC } from "react";
import {
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -24,8 +26,32 @@ const isAIGovernanceWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
message.startsWith(aiGovernanceOverLimitWarningPrefix);
-const isAIGovernanceNearLimitWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix);
+// Substitutes the given values into the template's %d placeholders in order.
+// No other fmt verb, width, or flag is implemented.
+const formatLicenseMessage = (template: string, ...values: number[]): string =>
+ values.reduce(
+ (message, value) => message.replace("%d", `${value}`),
+ template,
+ );
+
+// Diagnostics about the license or the usage measurement rather than about
+// usage itself. They render muted, without the exceedance heading or a sales
+// link. The "unavailable" pair arrives via entitlements.errors but must not
+// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+const diagnosticMessages: readonly string[] = [
+ LicenseManagedAgentUsageUnavailableErrorText,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
+];
+
+const isDiagnosticMessage = (message: string): boolean =>
+ diagnosticMessages.includes(message);
+
+// Advisories and diagnostics render in the muted variant: nothing is wrong
+// yet, so they must be visually distinct from warnings that demand action,
+// such as exceeding a license limit.
+const isMutedWarning = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
+ isDiagnosticMessage(message);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -48,9 +74,12 @@ const aiGovernanceOverLimitMessage = (
}
const overLimitSeats = actual - limit;
- return LicenseAIGovernanceOverLimitWarningText.replace("%d", `${actual}`)
- .replace("%d", `${limit}`)
- .replace("%d", `${overLimitSeats}`);
+ return formatLicenseMessage(
+ LicenseAIGovernanceOverLimitWarningText,
+ actual,
+ limit,
+ overLimitSeats,
+ );
};
const aiGovernanceNearLimitMessage = (
@@ -99,7 +128,7 @@ const normalizeAIGovernanceWarning = (
);
};
-const messageLink = (message: string): LicenseBannerLink => {
+const messageLink = (message: string): LicenseBannerLink | undefined => {
if (message === LicenseManagedAgentLimitExceededWarningText) {
return {
href: docs("/ai-coder/ai-governance"),
@@ -115,6 +144,11 @@ const messageLink = (message: string): LicenseBannerLink => {
showExternalIcon: false,
};
}
+ // Diagnostics point the operator at the logs or support, so they do not
+ // get a sales link.
+ if (isDiagnosticMessage(message)) {
+ return undefined;
+ }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -146,12 +180,16 @@ export const LicenseBanner: FC = () => {
const messages: LicenseBannerMessage[] = [
...errors.map((message) => ({
message,
- variant: "error" as const,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message)
+ ? ("warning" as const)
+ : ("error" as const),
link: messageLink(message),
})),
...normalizedWarnings.map((message) => ({
message,
- variant: isAIGovernanceNearLimitWarning(message)
+ variant: isMutedWarning(message)
? ("warning" as const)
: ("warningProminent" as const),
link: messageLink(message),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 582493a4eff..6c3dd4e662b 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import {
+ type Entitlements,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -180,30 +183,24 @@ export const ManagedAgentLimitExceededWithOtherWarnings: Story = {
},
};
-const renderLicenseBannerWithAIGovernance = ({
- actual,
- entitlement = "entitled",
- limit,
+const renderLicenseBanner = ({
+ errors = [],
warnings = [],
+ features = {},
}: {
- actual: number;
- entitlement?: "entitled" | "grace_period" | "not_entitled";
- limit?: number;
+ errors?: string[];
warnings?: string[];
+ features?: Partial;
}) => {
const mockDashboardValue: DashboardValue = {
entitlements: {
...MockEntitlements,
has_license: true,
+ errors,
warnings,
features: {
...MockEntitlements.features,
- ai_governance_user_limit: {
- enabled: true,
- entitlement,
- actual,
- ...(limit !== undefined ? { limit } : {}),
- },
+ ...features,
},
},
experiments: MockExperiments,
@@ -215,12 +212,39 @@ const renderLicenseBannerWithAIGovernance = ({
};
return (
-
+
-
+
);
};
+const renderLicenseBannerWithAIGovernance = ({
+ actual,
+ entitlement = "entitled",
+ limit,
+ warnings = [],
+}: {
+ actual: number;
+ entitlement?: "entitled" | "grace_period" | "not_entitled";
+ limit?: number;
+ warnings?: string[];
+}) =>
+ renderLicenseBanner({
+ warnings,
+ features: {
+ ai_governance_user_limit: {
+ enabled: true,
+ entitlement,
+ actual,
+ ...(limit !== undefined ? { limit } : {}),
+ },
+ },
+ });
+
+// Without the data-variant assertions, every story would keep passing with
+// the muted/prominent classifier disabled.
+const mutedVariant = "warning";
+
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -230,9 +254,13 @@ export const AIGovernanceNearLimit: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByRole("status")).toHaveTextContent(
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
+ // Pins the AI Governance near-limit branch of isMutedWarning,
+ // independently of the runtime soft-limit branch below.
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -267,3 +295,56 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
);
},
};
+
+// Each entry of the frontend's diagnosticMessages set is pinned on both
+// properties the set drives: the muted variant and the suppressed sales
+// link. The "unavailable" message arrives on the errors channel; see the
+// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+const playMutedDiagnostic =
+ (message: string): Story["play"] =>
+ async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(message);
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(
+ canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
+ ).not.toBeInTheDocument();
+ };
+
+export const ManagedAgentUsageUnavailable: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ }),
+ play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
+};
+
+export const AgentRuntimeHoursClaimsIgnored: Story = {
+ render: () =>
+ renderLicenseBanner({
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText),
+};
+
+// An all-diagnostic banner must not claim license limits were exceeded.
+export const UsageDiagnosticsOnlyHeading: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByText("License notices")).toBeInTheDocument();
+ await expect(
+ canvas.queryByText("Your license limits have been exceeded"),
+ ).not.toBeInTheDocument();
+ await expect(
+ canvas.queryByText("License errors require attention"),
+ ).not.toBeInTheDocument();
+ },
+};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 80674fcbb7e..bd278dc591c 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,10 +70,19 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-const bannerTitle = (variant: LicenseBannerVariant): string =>
- variant === "error"
- ? "License errors require attention"
- : "Your license limits have been exceeded";
+// The muted variant only wins when every message is muted (see
+// getBannerVariant), which means advisories and diagnostics: nothing has
+// been exceeded, so the heading must not assert exceedance.
+const bannerTitle = (variant: LicenseBannerVariant): string => {
+ switch (variant) {
+ case "error":
+ return "License errors require attention";
+ case "warningProminent":
+ return "Your license limits have been exceeded";
+ case "warning":
+ return "License notices";
+ }
+};
const bannerRole = (variant: LicenseBannerVariant): "alert" | "status" =>
variant === "error" ? "alert" : "status";
@@ -142,6 +151,9 @@ export const LicenseBannerView: React.FC = ({
return (
From 967089da9b3c320f77c3f5aa296b5c2465b2d786 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 05:47:20 +0000
Subject: [PATCH 055/116] feat(enterprise/coderd/license): decode -1 runtime
hours allocation as unlimited
An agent_runtime_hours_allocation claim of exactly -1 now grants the
agent_runtime_hours feature enabled with a nil Limit, which the
entitlements API serves as an omitted "limit" field, the shape the UI
already renders as Unlimited. Threshold claims alongside it are dropped
with the existing claims-ignored warning, and any other negative
allocation remains unusable.
---
enterprise/coderd/license/license.go | 36 ++++++++-
enterprise/coderd/license/license_test.go | 90 ++++++++++++++++++++++-
2 files changed, 122 insertions(+), 4 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index e6f3598475e..8911f695d3d 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -932,9 +932,10 @@ const (
// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit. A
- // negative allocation is ignored, in which case the license does not
- // grant the feature.
+ // allocation for the license term. It becomes the feature's Limit.
+ // AgentRuntimeHoursUnlimitedAllocation (-1) is reserved to mean
+ // unlimited; any other negative allocation is ignored, in which case
+ // the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
// becomes the feature's SoftLimit when 0 < soft < allocation and is
@@ -946,6 +947,12 @@ const (
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
+// AgentRuntimeHoursUnlimitedAllocation is the reserved
+// ClaimAgentRuntimeHoursAllocation value meaning the license grants
+// unlimited runtime hours. It decodes to an enabled feature with a nil
+// Limit. Mirrored in github.com/coder/license.
+const AgentRuntimeHoursUnlimitedAllocation int64 = -1
+
var (
ValidMethods = []string{"EdDSA"}
@@ -991,6 +998,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// claims, but Actual is still measured and published. CODAGT-856 will make a
// zero allocation force a concurrency-limited mode; that mode does not exist
// yet.
+//
+// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
+// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
+// have nothing to threshold against, so they are dropped with the warning,
+// keeping an incorrectly issued license visible. Note that
+// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
+// issued-at and expiry tie a metered license outranks an unlimited one; ties
+// never happen for separately issued licenses, so this edge is documented
+// rather than special-cased.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
@@ -1000,6 +1016,20 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+ if allocOk && allocation == AgentRuntimeHoursUnlimitedAllocation {
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{
+ Enabled: true,
+ Entitlement: entitlement,
+ UsagePeriod: &usagePeriod,
+ }, true, ignoredClaims
+ }
+
if !allocOk || allocation < 0 {
if allocOk && allocation < 0 {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index db68fb8b60a..0e393939c5d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2501,6 +2501,55 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
require.NotNil(t, feature.UsagePeriod)
})
+ // An unlimited (-1) allocation grants the feature enabled with no Limit,
+ // which the API serves as an omitted "limit" field, the shape the UI
+ // already renders as "Unlimited".
+ t.Run("UnlimitedAllocation", func(t *testing.T) {
+ t.Parallel()
+
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ )
+ require.NoError(t, err)
+ require.Empty(t, entitlements.Errors)
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+
+ // The entitlements JSON served by GET /api/v2/entitlements omits
+ // "limit" entirely for the unlimited feature.
+ data, err := json.Marshal(entitlements)
+ require.NoError(t, err)
+ var raw struct {
+ Features map[codersdk.FeatureName]map[string]any `json:"features"`
+ }
+ require.NoError(t, json.Unmarshal(data, &raw))
+ rawFeature := raw.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, true, rawFeature["enabled"])
+ require.NotContains(t, rawFeature, "limit")
+ require.Contains(t, rawFeature, "usage_period")
+ })
+
// The license with the newest issued-at claim wins, even if another
// license was loaded first or has a larger allocation. The soft and hard
// limits come from the winning license.
@@ -2853,9 +2902,48 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
expectClaimsIgnored: true,
},
{
+ // An unlimited allocation grants the feature with no Limit and
+ // no warning: -1 is the canonical unlimited encoding, not an
+ // issuance mistake.
+ name: "UnlimitedAllocation",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ },
+ {
+ // Threshold claims alongside an unlimited allocation have
+ // nothing to threshold against; the grant survives but the
+ // issuance mistake must stay visible via the warning.
+ name: "UnlimitedWithSoft",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ name: "UnlimitedWithHard",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ // Only exactly -1 is the unlimited sentinel; any other negative
+ // allocation stays unusable.
name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -2,
},
expectClaimsIgnored: true,
},
From 0c8742470ede5dfd10ade28c4dedc6e2e0b6406d Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:07:05 +0000
Subject: [PATCH 056/116] chore: remove data-variant
---
.../LicenseBannerView.stories.tsx | 21 +++++++------------
.../LicenseBanner/LicenseBannerView.tsx | 3 ---
2 files changed, 8 insertions(+), 16 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 6c3dd4e662b..4f2030a3080 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -59,6 +59,10 @@ export const TwoWarnings: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
+ await expect(
+ canvas.getByText("Your license limits have been exceeded"),
+ ).toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: "Show more" }),
).not.toBeInTheDocument();
@@ -241,10 +245,6 @@ const renderLicenseBannerWithAIGovernance = ({
},
});
-// Without the data-variant assertions, every story would keep passing with
-// the muted/prominent classifier disabled.
-const mutedVariant = "warning";
-
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -258,9 +258,6 @@ export const AIGovernanceNearLimit: Story = {
await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
- // Pins the AI Governance near-limit branch of isMutedWarning,
- // independently of the runtime soft-limit branch below.
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -296,17 +293,16 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
},
};
-// Each entry of the frontend's diagnosticMessages set is pinned on both
-// properties the set drives: the muted variant and the suppressed sales
+// Each diagnostic pins role=status (not alert) and a suppressed sales
// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
+// mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
const canvas = within(canvasElement);
const banner = canvas.getByRole("status");
await expect(banner).toHaveTextContent(message);
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
).not.toBeInTheDocument();
@@ -337,8 +333,7 @@ export const UsageDiagnosticsOnlyHeading: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const banner = canvas.getByRole("status");
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(canvas.getByText("License notices")).toBeInTheDocument();
await expect(
canvas.queryByText("Your license limits have been exceeded"),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index bd278dc591c..453b925fe6d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -151,9 +151,6 @@ export const LicenseBannerView: React.FC = ({
return (
From 46ab198bc1dd865271e34c85ad3d12364cb09f37 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:42:51 +0000
Subject: [PATCH 057/116] chore: remove and simplify explanatory comments
---
codersdk/licenses.go | 23 ++------
.../coderd/coderdenttest/coderdenttest.go | 4 --
enterprise/coderd/license/license.go | 57 +++++++------------
enterprise/coderd/license/license_test.go | 6 +-
site/src/api/typesGenerated.ts | 15 -----
.../dashboard/LicenseBanner/LicenseBanner.tsx | 8 +--
.../LicenseBannerView.stories.tsx | 4 +-
.../LicenseBanner/LicenseBannerView.tsx | 5 +-
8 files changed, 33 insertions(+), 89 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 7aecbaa3089..6f277f17510 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -12,23 +12,12 @@ import (
)
const (
- LicenseExpiryClaim = "license_expires"
- LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
- LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
- LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
- LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- // managed agent usage query fails while computing entitlements; the
- // cause is logged server-side. It travels in the entitlements Errors
- // channel so the alertable coderd_license_errors gauge counts
- // measurement failures, but the dashboard recognizes the exact text and
- // renders it as a muted diagnostic rather than a license error.
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
- // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- // license carries unusable Coder Agent runtime hour claims (see
- // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- // the license and the dropped claims. The dashboard renders the exact
- // text as a muted diagnostic without a sales link.
+ LicenseExpiryClaim = "license_expires"
+ LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
+ LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
+ LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
+ LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 8f5f1e4a708..1d2678fd787 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,10 +237,6 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
-// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
-// or hardLimit omits that claim, which a license is allowed to do; a non-nil
-// value is emitted verbatim, so tests can express explicit zero or negative
-// claims.
func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
if softLimit != nil {
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8911f695d3d..66b55f1feea 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -130,8 +130,6 @@ func Entitlements(
}
type FeatureArguments struct {
- // Logger receives the causes behind operator-facing diagnostics whose
- // published message is a stable text. The zero value discards them.
Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
@@ -587,8 +585,6 @@ func LicensesEntitlements(
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
}
if len(ignoredClaims) > 0 {
- // The published warning is a stable text, so the details a
- // support case needs go to the log.
featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
slog.F("license_id", license.UUID),
slog.F("ignored_claims", ignoredClaims),
@@ -721,9 +717,8 @@ func LicensesEntitlements(
}
if ok {
agentLimit.Actual = &managedAgentCount
- // Written back directly: the feature contest is already
- // settled, so AddFeature's Compare must not get a chance to
- // drop the write.
+ // Write directly rather than via AddFeature so its Compare
+ // cannot drop the update.
entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
@@ -863,12 +858,10 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs one usage query over the feature's usage period and owns
-// the shared failure policy: a nil fn is a wiring bug and fails the whole
-// LicensesEntitlements call; a failure with a dead context fails the call
-// without logging; any other failure logs the cause and publishes the stable
-// unavailableText instead. It returns the measured value and true only on
-// success.
+// measureUsage runs fn over the feature's usage period. A nil fn or a
+// failure with a dead context fails the whole call; any other failure logs
+// the cause and publishes unavailableText instead. It returns the measured
+// value and true only on success.
func measureUsage(
ctx context.Context,
entitlements *codersdk.Entitlements,
@@ -884,12 +877,11 @@ func measureUsage(
value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
switch {
case err != nil && ctx.Err() != nil:
- // The computation's own context is dead, so abort the whole call
- // without logging. Do not classify by error shape instead: Postgres
- // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
- // as well as client cancels, and aborting on those would fail every
- // entitlements refresh on a deployment whose statement_timeout is
- // shorter than a usage query.
+ // Do not classify cancellation by error shape instead of ctx.Err():
+ // Postgres raises SQLSTATE 57014 (query_canceled) for
+ // statement_timeout kills as well as client cancels, and aborting on
+ // those would fail every entitlements refresh on a deployment whose
+ // statement_timeout is shorter than a usage query.
return 0, false, xerrors.Errorf("get %s: %w", what, err)
case err != nil:
logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
@@ -987,26 +979,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// allocation claim; per-claim validity rules live on the Claim* constants
// above.
//
-// Unusable claims are dropped, never license-invalidating: rejecting a
-// signed license over a cosmetic threshold claim would drop the deployment
-// to unlicensed. ignoredClaims names each dropped claim (including the
-// feature name itself minted as a claim, the most plausible issuer mistake)
-// so the caller can warn and log instead of letting an incorrectly issued
-// license look healthy.
+// Unusable claims are dropped rather than invalidating the license, since
+// rejecting a signed license over a cosmetic claim would drop the deployment
+// to unlicensed. Each dropped claim is returned in ignoredClaims so the
+// caller can warn and log instead of letting an incorrectly issued license
+// look healthy.
//
-// A zero allocation grants the feature disabled and drops both threshold
-// claims, but Actual is still measured and published. CODAGT-856 will make a
-// zero allocation force a concurrency-limited mode; that mode does not exist
-// yet.
-//
-// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
-// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
-// have nothing to threshold against, so they are dropped with the warning,
-// keeping an incorrectly issued license visible. Note that
-// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
-// issued-at and expiry tie a metered license outranks an unlimited one; ties
-// never happen for separately issued licenses, so this edge is documented
-// rather than special-cased.
+// A zero allocation grants the feature disabled, but Actual is still
+// measured and published. CODAGT-856 will make a zero allocation force a
+// concurrency-limited mode; that mode does not exist yet.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 0e393939c5d..ec8e06b2f8d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -986,8 +986,7 @@ func TestEntitlements(t *testing.T) {
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
- // The failure surfaces its stable text without the raw cause,
- // on the channel the codersdk constant docs prescribe.
+ // The failure surfaces its stable text without the raw cause.
require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
require.NotContains(t, entry, "kaboom")
@@ -1657,8 +1656,7 @@ func TestLicenseEntitlements(t *testing.T) {
},
},
{
- // A query failure is surfaced as a stable text in Errors (see
- // the codersdk constant docs for the channel choice) and
+ // A query failure is surfaced as a stable text in Errors and
// leaves Actual unset without aborting the rest of the
// entitlements.
Name: "ManagedAgentLimit/QueryError",
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index c00449c8b51..677438bcfa4 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5723,13 +5723,6 @@ export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
// From codersdk/licenses.go
-/**
- * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- * license carries unusable Coder Agent runtime hour claims (see
- * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- * the license and the dropped claims. The dashboard renders the exact
- * text as a muted diagnostic without a sales link.
- */
export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
"A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
@@ -5741,14 +5734,6 @@ export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
// From codersdk/licenses.go
-/**
- * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- * managed agent usage query fails while computing entitlements; the
- * cause is logged server-side. It travels in the entitlements Errors
- * channel so the alertable coderd_license_errors gauge counts
- * measurement failures, but the dashboard recognizes the exact text and
- * renders it as a muted diagnostic rather than a license error.
- */
export const LicenseManagedAgentUsageUnavailableErrorText =
"Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 9ef4dda8bb1..74ce21b89fa 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -36,8 +36,7 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// Diagnostics about the license or the usage measurement rather than about
// usage itself. They render muted, without the exceedance heading or a sales
-// link. The "unavailable" pair arrives via entitlements.errors but must not
-// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
@@ -46,9 +45,8 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render in the muted variant: nothing is wrong
-// yet, so they must be visually distinct from warnings that demand action,
-// such as exceeding a license limit.
+// Advisories and diagnostics render muted to stay visually distinct from
+// warnings that demand action, such as exceeding a license limit.
const isMutedWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
isDiagnosticMessage(message);
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 4f2030a3080..7c2e67c9a71 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -294,9 +294,7 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
};
// Each diagnostic pins role=status (not alert) and a suppressed sales
-// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
-// mutedness is covered by the visual snapshot.
+// link. Background mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 453b925fe6d..2cef6164395 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,9 +70,8 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-// The muted variant only wins when every message is muted (see
-// getBannerVariant), which means advisories and diagnostics: nothing has
-// been exceeded, so the heading must not assert exceedance.
+// The muted "warning" variant means every message is an advisory or
+// diagnostic, so the heading must not assert exceedance.
const bannerTitle = (variant: LicenseBannerVariant): string => {
switch (variant) {
case "error":
From 1f6ef657a991fac8f636c6409c472a7c4576ea53 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 09:40:53 +0000
Subject: [PATCH 058/116] fix: make unlimited usage period features outrank
metered ones on ties
A nil Limit on a usage period feature means unlimited, so Feature.Compare now ranks it above any set limit when issued-at and end dates tie. Also types the LicenseBanner map callbacks as LicenseBannerMessage instead of using as const assertions.
---
codersdk/deployment.go | 12 +++-
codersdk/deployment_test.go | 25 ++++++++
enterprise/coderd/license/license_test.go | 60 +++++++++++++++++++
.../dashboard/LicenseBanner/LicenseBanner.tsx | 32 +++++-----
4 files changed, 111 insertions(+), 18 deletions(-)
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index bba55f81bec..8a60e11415f 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -425,7 +425,7 @@ type UsagePeriod struct {
// 2. The usage period has a greater end date (note: only certain features use usage periods)
// 3. Graceful & capable > Entitled & not capable (only if both have "Actual" values)
// 4. The entitlement is greater
-// 5. The limit is greater
+// 5. The limit is greater (except a nil limit on a usage period feature means unlimited, outranking any set limit)
// 6. Enabled is greater than disabled
// 7. The actual is greater
//
@@ -469,11 +469,19 @@ func (f Feature) Compare(b Feature) int {
return entitlementDifference
}
- // If the entitlement is the same, then we can compare the limits.
+ // If the entitlement is the same, then we can compare the limits. A nil
+ // limit on a usage period feature means unlimited, so it outranks any set
+ // limit; on other features a nil limit loses to a set one.
if f.Limit == nil && b.Limit != nil {
+ if bothHaveUsagePeriod {
+ return 1
+ }
return -1
}
if f.Limit != nil && b.Limit == nil {
+ if bothHaveUsagePeriod {
+ return -1
+ }
return 1
}
if f.Limit != nil && b.Limit != nil {
diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go
index de3395aac84..2abdff578e9 100644
--- a/codersdk/deployment_test.go
+++ b/codersdk/deployment_test.go
@@ -1225,6 +1225,31 @@ func TestFeatureComparison(t *testing.T) {
},
Expected: 1,
},
+ {
+ // A nil limit on a usage period feature means unlimited, so it
+ // outranks a set limit on an exact usage period tie.
+ Name: "UnlimitedUsagePeriodOutranksMeteredOnTie",
+ A: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ B: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ Limit: ptr.Ref(int64(100)),
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ Expected: 1,
+ },
}
for _, tc := range testCases {
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index ec8e06b2f8d..68bff19da49 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2616,6 +2616,66 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
}
})
+ // When an unlimited and a metered license are minted with identical
+ // issued-at and expiry claims, the unlimited grant must win the tie,
+ // regardless of load order.
+ t.Run("UnlimitedOutranksMeteredOnTie", func(t *testing.T) {
+ t.Parallel()
+
+ // JWT NumericDate claims have second granularity, so truncate to
+ // keep the round-tripped issued-at values identical.
+ iat := time.Now().Add(-time.Minute).Truncate(time.Second)
+ nbf := iat
+ exp := iat.Add(time.Hour).Truncate(time.Second)
+ unlimited := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+ metered := database.License{
+ ID: 2,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ }),
+ }
+
+ for _, order := range [][]database.License{
+ {unlimited, metered},
+ {metered, unlimited},
+ } {
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
+ require.NoError(t, err)
+
+ feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+ }
+ })
+
// A newer license without soft/hard limits must fully replace an older
// license that carried them; the limits must not merge across licenses.
t.Run("SoftHardRideAlongWithWinner", func(t *testing.T) {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 74ce21b89fa..2218aa5cf35 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -176,22 +176,22 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map((message) => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message)
- ? ("warning" as const)
- : ("error" as const),
- link: messageLink(message),
- })),
- ...normalizedWarnings.map((message) => ({
- message,
- variant: isMutedWarning(message)
- ? ("warning" as const)
- : ("warningProminent" as const),
- link: messageLink(message),
- })),
+ ...errors.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message) ? "warning" : "error",
+ link: messageLink(message),
+ }),
+ ),
+ ...normalizedWarnings.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ variant: isMutedWarning(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ }),
+ ),
];
if (messages.length === 0) {
From 2975cf4681d06eec609f2c91a7e7ad2bf96b37d3 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:10:13 +0000
Subject: [PATCH 059/116] fix(enterprise/coderd/license): accept issuer-valid
zero soft limit for agent runtime hours
The license issuer validates the soft limit as 0 <= soft < allocation,
but this decoder dropped soft=0 as an unusable claim and warned. Accept
zero so every license the issuer mints decodes cleanly; omitting the
claim remains the way to express no soft limit.
---
enterprise/coderd/license/license.go | 4 ++--
enterprise/coderd/license/license_test.go | 13 ++++++-------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 66b55f1feea..f2a6924bf57 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -930,7 +930,7 @@ const (
// the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // becomes the feature's SoftLimit when 0 <= soft < allocation and is
// ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
// ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
@@ -1031,7 +1031,7 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
UsagePeriod: &usagePeriod,
}
if softOk {
- if soft > 0 && soft < allocation {
+ if soft >= 0 && soft < allocation {
feature.SoftLimit = &soft
} else {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 68bff19da49..66c46b3b691 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2857,20 +2857,19 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
},
},
{
- // A zero soft limit would warn at zero usage forever, so it is
- // dropped rather than rejecting the license. The canonical way
- // to express "no soft limit" is omitting the claim, so a
- // present-but-dropped zero still warns.
+ // A zero soft limit is valid (0 <= soft < allocation) and warns
+ // from the start of the usage period. Omitting the claim is the
+ // way to express "no soft limit".
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
expectFeature: &codersdk.Feature{
- Enabled: true,
- Limit: ptr.Ref[int64](100),
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](0),
},
- expectClaimsIgnored: true,
},
{
name: "NegativeSoft",
From 24577fbc796ee7f55e428bb980e3c4eb21ef6e65 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Thu, 13 Aug 2026 12:20:55 +0000
Subject: [PATCH 060/116] chore: leave managed agent measurement untouched by
license diagnostics
Managed agents are deprecated and slated for removal, so the stable
measurement diagnostics no longer convert that path: the measureUsage
helper, the stable LicenseManagedAgentUsageUnavailableErrorText, the
AsUsagePublisher subject switch, and the direct Features map write are
dropped, restoring the original managed agent count handling. The
runtime hours claim tolerance, the -1 unlimited allocation, and the
muted diagnostics rendering (now seeded only with the claims-ignored
warning) are unchanged.
---
codersdk/licenses.go | 1 -
enterprise/coderd/license/license.go | 62 ++----
enterprise/coderd/license/license_test.go | 182 +-----------------
site/src/api/typesGenerated.ts | 4 -
.../dashboard/LicenseBanner/LicenseBanner.tsx | 2 -
.../LicenseBannerView.stories.tsx | 10 -
6 files changed, 26 insertions(+), 235 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 6f277f17510..414cfbcf04b 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,7 +17,6 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index f2a6924bf57..c4fe1ebcf4e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -115,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Reading usage events requires the usage publisher subject.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -709,17 +709,24 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
- featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
- "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
- if err != nil {
- return entitlements, err
+ var (
+ managedAgentCount int64
+ err = xerrors.New("dev error: managed agent count function is not set")
+ )
+ if featureArguments.ManagedAgentCountFn != nil {
+ managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
+ }
+ if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
+ // If the context is canceled, we want to bail the entire
+ // LicensesEntitlements call.
+ return entitlements, xerrors.Errorf("get managed agent count: %w", err)
}
- if ok {
+ if err != nil {
+ entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
+ // no return
+ } else {
agentLimit.Actual = &managedAgentCount
- // Write directly rather than via AddFeature so its Compare
- // cannot drop the update.
- entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
+ entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -858,39 +865,6 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs fn over the feature's usage period. A nil fn or a
-// failure with a dead context fails the whole call; any other failure logs
-// the cause and publishes unavailableText instead. It returns the measured
-// value and true only on success.
-func measureUsage(
- ctx context.Context,
- entitlements *codersdk.Entitlements,
- logger slog.Logger,
- fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
- usagePeriod codersdk.UsagePeriod,
- what string,
- unavailableText string,
-) (int64, bool, error) {
- if fn == nil {
- return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
- }
- value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
- switch {
- case err != nil && ctx.Err() != nil:
- // Do not classify cancellation by error shape instead of ctx.Err():
- // Postgres raises SQLSTATE 57014 (query_canceled) for
- // statement_timeout kills as well as client cancels, and aborting on
- // those would fail every entitlements refresh on a deployment whose
- // statement_timeout is shorter than a usage query.
- return 0, false, xerrors.Errorf("get %s: %w", what, err)
- case err != nil:
- logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
- entitlements.Errors = append(entitlements.Errors, unavailableText)
- return 0, false, nil
- }
- return value, true, nil
-}
-
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 66c46b3b691..24bd86b326c 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -15,11 +15,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
- "golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
- "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/database/dbtestutil"
@@ -37,42 +35,6 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
-// premiumRuntimeHoursFixture returns a mock store primed with a Premium
-// license carrying runtime hour claims (allocation 100, soft limit 80, hard
-// limit 120) plus the store expectations every entitlements refresh consumes
-// before usage is measured. Callers add expectations for the usage queries
-// under test.
-func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
- t.Helper()
-
- ctrl := gomock.NewController(t)
- mDB := dbmock.NewMockStore(ctrl)
-
- licenseOpts := (&coderdenttest.LicenseOptions{
- FeatureSet: codersdk.FeatureSetPremium,
- IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
- NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
- GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
- ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
- // The addon marks AI Bridge as explicitly entitled, suppressing
- // the unrelated "AI Governance add-on is required to use AI
- // Gateway" warning that Premium would otherwise produce.
- }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
-
- lic := database.License{
- ID: 1,
- JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
- Exp: licenseOpts.ExpiresAt,
- }
-
- mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
- mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
- mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
- mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
-
- return mDB, licenseOpts
-}
-
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -963,62 +925,6 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
- t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
- t.Parallel()
-
- // Drive the real Entitlements closure with a mock database so
- // measureUsage's failure path is exercised end to end: the cause
- // must land in the coderd log, which the stable payload text points
- // at, and must not land on the unauthenticated entitlements payload.
- mDB, _ := premiumRuntimeHoursFixture(t)
-
- mDB.EXPECT().
- GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
- Return(int64(0), xerrors.New("kaboom managed"))
-
- // The error-level logs are the behavior under test, so the default
- // failing test logger cannot be used.
- var logBuf bytes.Buffer
- logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
- AppendSinks(sloghuman.Sink(&logBuf))
-
- entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
- require.NoError(t, err)
- require.True(t, entitlements.HasLicense)
-
- // The failure surfaces its stable text without the raw cause.
- require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
- for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
- require.NotContains(t, entry, "kaboom")
- }
-
- logs := logBuf.String()
- require.Contains(t, logs, "get managed agent count for entitlements")
- require.Contains(t, logs, "kaboom managed")
- })
-
- t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
- t.Parallel()
-
- // A query failing while the refresh's own context is canceled,
- // e.g. during shutdown, aborts the whole entitlements refresh and
- // must not log a false query-failure alarm at error level.
- mDB, _ := premiumRuntimeHoursFixture(t)
-
- mDB.EXPECT().
- GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
- Return(int64(0), context.Canceled)
-
- var logBuf bytes.Buffer
- logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
-
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
- require.ErrorContains(t, err, "get managed agent count")
- require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
- })
-
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1398,12 +1304,6 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
- // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
- // injection below so the nil dev-error path can be exercised.
- KeepNilManagedAgentCountFn bool
- // CancelContext cancels the context passed to LicensesEntitlements
- // before the call, exercising the usage-measurement abort policy.
- CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1655,58 +1555,6 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
- {
- // A query failure is surfaced as a stable text in Errors and
- // leaves Actual unset without aborting the rest of the
- // entitlements.
- Name: "ManagedAgentLimit/QueryError",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- Arguments: license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, xerrors.New("kaboom")
- },
- },
- AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
- assertNoWarnings(t, entitlements)
- require.Len(t, entitlements.Errors, 1)
- assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
- // The raw error is logged rather than exposed on the
- // unauthenticated entitlements payload.
- assert.NotContains(t, entitlements.Errors[0], "kaboom")
- feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
- assert.Nil(t, feature.Actual)
- },
- },
- {
- // Forgetting to wire ManagedAgentCountFn is a dev error:
- // production always provides the closure, so it fails the whole
- // call loudly instead of degrading into an operator-facing
- // message.
- Name: "ManagedAgentLimit/NilFnDevError",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- KeepNilManagedAgentCountFn: true,
- ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
- },
- {
- // A failure while the computation's own context is canceled
- // aborts the whole call rather than degrading to an
- // entitlements error.
- Name: "ManagedAgentLimit/ContextCanceled",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- CancelContext: true,
- Arguments: license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, context.Canceled
- },
- },
- ExpectedErrorContains: "get managed agent count",
- },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1738,18 +1586,13 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
+ if tc.Arguments.ManagedAgentCountFn == nil {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
- ctx := context.Background()
- if tc.CancelContext {
- var cancel context.CancelFunc
- ctx, cancel = context.WithCancel(ctx)
- cancel()
- }
- entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1774,15 +1617,6 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
- // A Premium license grants a managed agent limit by default, and a nil
- // usage closure is a hard developer error, so these subtests wire a
- // zero-usage measurement closure.
- zeroUsageArgs := license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, nil
- },
- }
-
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1802,7 +1636,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1829,7 +1663,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1861,7 +1695,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1892,7 +1726,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1905,7 +1739,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 677438bcfa4..11ad4eb7cd6 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5733,10 +5733,6 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
-// From codersdk/licenses.go
-export const LicenseManagedAgentUsageUnavailableErrorText =
- "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
-
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 2218aa5cf35..a6c4cf072ce 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -4,7 +4,6 @@ import {
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -38,7 +37,6 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// usage itself. They render muted, without the exceedance heading or a sales
// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
];
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 7c2e67c9a71..c351a417655 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -5,7 +5,6 @@ import {
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -306,14 +305,6 @@ const playMutedDiagnostic =
).not.toBeInTheDocument();
};
-export const ManagedAgentUsageUnavailable: Story = {
- render: () =>
- renderLicenseBanner({
- errors: [LicenseManagedAgentUsageUnavailableErrorText],
- }),
- play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
-};
-
export const AgentRuntimeHoursClaimsIgnored: Story = {
render: () =>
renderLicenseBanner({
@@ -326,7 +317,6 @@ export const AgentRuntimeHoursClaimsIgnored: Story = {
export const UsageDiagnosticsOnlyHeading: Story = {
render: () =>
renderLicenseBanner({
- errors: [LicenseManagedAgentUsageUnavailableErrorText],
warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
}),
play: async ({ canvasElement }) => {
From f80de75422bb50d4b8815005f76e7410aea22a89 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:39:21 +0000
Subject: [PATCH 061/116] chore(coderd): trim historical framing from invariant
comments
Migration comments now state only the current invariant and rollback
reason, and generateBucket defers the full ON CONFLICT concurrency
contract to the Generator doc comment instead of repeating it.
---
...0569_usage_events_agent_runtime_invariants.down.sql | 5 ++---
...000569_usage_events_agent_runtime_invariants.up.sql | 10 ++++------
enterprise/coderd/usage/generator.go | 10 +++-------
3 files changed, 9 insertions(+), 16 deletions(-)
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
index 8a6a0240988..55d3b173185 100644
--- a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
+++ b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
@@ -1,6 +1,5 @@
--- IF EXISTS matches 000561's down migration: if the index is already gone
--- (e.g. rolling back out of order during an incident), tolerate the miss
--- instead of failing.
+-- IF EXISTS tolerates the index already being gone (e.g. rolling back out
+-- of order during an incident) instead of failing.
DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
CREATE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
index 33c0c28f1a7..7577b386f02 100644
--- a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
+++ b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
@@ -14,12 +14,10 @@ ALTER TABLE usage_events
OR date_trunc('hour', (created_at AT TIME ZONE 'UTC')) = (created_at AT TIME ZONE 'UTC')
);
--- Replace the non-unique partial index with a unique one of the same shape,
--- so reads are served identically. Inserts keep their (id) arbiter:
--- re-inserting a bucket under its deterministic id stays a silent no-op,
--- while a duplicate bucket row under a different id raises instead of being
--- counted twice (generateBucket in enterprise/coderd/usage/generator.go
--- handles the violation).
+-- Inserts keep their (id) arbiter: re-inserting a bucket under its
+-- deterministic id stays a silent no-op, while a duplicate bucket row under
+-- a different id raises a unique violation (generateBucket in
+-- enterprise/coderd/usage/generator.go handles it).
DROP INDEX idx_usage_events_agent_runtime;
CREATE UNIQUE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index cec94a1a511..74e8ae9ac19 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -242,13 +242,9 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
- // The insert's ON CONFLICT (id) arbiter absorbs most duplicate
- // inserts, including in-flight ones: once a competing row's arbiter
- // index entry is visible, PostgreSQL waits on that transaction and
- // takes the DO NOTHING path if it commits. Only the narrow
- // speculative-insertion race, before that entry exists, trips the
- // bucket unique index instead. Either way a row for this bucket
- // already exists, which is all generateBucket needs.
+ // Another replica already created this bucket's row. The Generator
+ // doc comment explains why this race reaches the bucket unique
+ // index instead of the insert's ON CONFLICT (id) arbiter.
return nil
}
if err != nil {
From 727d2332e9646f5d6d872950e749ebdd01e3f1ea Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:43:17 +0000
Subject: [PATCH 062/116] refactor(enterprise/coderd/license): specialize usage
measurement to agent runtime
measureUsage kept generic what/unavailableText parameters for a managed
agent consumer that the parent branch removed, so the helper is renamed
to measureAgentRuntimeMs with the runtime-hours strings inlined. Error,
log, and entitlements texts are unchanged. Also replaces the fixture's
duration-narrating comments with the actual invariant: far-future
expiry keeps the license-expiry warning out of warning assertions.
---
enterprise/coderd/license/license.go | 33 +++++++++++------------
enterprise/coderd/license/license_test.go | 8 +++---
2 files changed, 20 insertions(+), 21 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 74a2abe408b..c6bb2495c6a 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -485,9 +485,9 @@ func LicensesEntitlements(
// Premium licenses without agent_runtime_hours_* claims are
// grandfathered into a zero-hour allocation: the feature is
// granted disabled with a zero limit, which measures and
- // publishes usage (see the measureUsage call below) and caps
- // concurrent agentic chats the same as an explicit zero
- // allocation.
+ // publishes usage (see the measureAgentRuntimeMs call below)
+ // and caps concurrent agentic chats the same as an explicit
+ // zero allocation.
var (
// A fixed issue time that predates any license issued with
// agent_runtime_hours_* claims, so a license that actually
@@ -792,9 +792,8 @@ func LicensesEntitlements(
// enterprise/coderd/usage.AgentRuntime* constants.
runtimeHours := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
if entitlements.HasLicense && runtimeHours.UsagePeriod != nil {
- runtimeMs, ok, err := measureUsage(ctx, &entitlements,
- featureArguments.Logger, featureArguments.AgentRuntimeMsFn, *runtimeHours.UsagePeriod,
- "agent runtime", codersdk.LicenseAgentRuntimeUsageUnavailableErrorText)
+ runtimeMs, ok, err := measureAgentRuntimeMs(ctx, &entitlements,
+ featureArguments.Logger, featureArguments.AgentRuntimeMsFn, *runtimeHours.UsagePeriod)
if err != nil {
return entitlements, err
}
@@ -951,21 +950,19 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs fn over the feature's usage period. A nil fn or a
-// failure with a dead context fails the whole call; any other failure logs
-// the cause and publishes unavailableText instead. It returns the measured
-// value and true only on success.
-func measureUsage(
+// measureAgentRuntimeMs runs fn over the feature's usage period. A nil fn
+// or a failure with a dead context fails the whole call; any other failure
+// logs the cause and publishes the stable unavailable text instead. It
+// returns the measured milliseconds and true only on success.
+func measureAgentRuntimeMs(
ctx context.Context,
entitlements *codersdk.Entitlements,
logger slog.Logger,
- fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
+ fn AgentRuntimeMsFn,
usagePeriod codersdk.UsagePeriod,
- what string,
- unavailableText string,
) (int64, bool, error) {
if fn == nil {
- return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
+ return 0, false, xerrors.New("developer error: no closure provided to measure agent runtime usage")
}
value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
switch {
@@ -975,10 +972,10 @@ func measureUsage(
// statement_timeout kills as well as client cancels, and aborting on
// those would fail every entitlements refresh on a deployment whose
// statement_timeout is shorter than a usage query.
- return 0, false, xerrors.Errorf("get %s: %w", what, err)
+ return 0, false, xerrors.Errorf("get agent runtime: %w", err)
case err != nil:
- logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
- entitlements.Errors = append(entitlements.Errors, unavailableText)
+ logger.Error(ctx, "get agent runtime for entitlements", slog.Error(err))
+ entitlements.Errors = append(entitlements.Errors, codersdk.LicenseAgentRuntimeUsageUnavailableErrorText)
return 0, false, nil
}
return value, true, nil
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 77efe51108d..e52eaab094d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -53,8 +53,10 @@ func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest
FeatureSet: codersdk.FeatureSetPremium,
IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
- GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
- ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
+ // GraceAt and ExpiresAt are far enough out that the license-expiry
+ // warning cannot pollute the callers' warning assertions.
+ GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second),
+ ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second),
// The addon marks AI Bridge as explicitly entitled, suppressing
// the unrelated "AI Governance add-on is required to use AI
// Gateway" warning that Premium would otherwise produce.
@@ -1091,7 +1093,7 @@ func TestEntitlements(t *testing.T) {
t.Parallel()
// Drive the real Entitlements closures with a mock database so
- // measureUsage's failure path is exercised end to end: the cause
+ // measureAgentRuntimeMs's failure path is exercised end to end: the cause
// must land in the coderd log, which the stable payload texts point
// at, and must not land on the unauthenticated entitlements payload.
mDB, _ := premiumRuntimeHoursFixture(t)
From 86c2c85c07ca640c23d94abdbf074f663a9f3d09 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:52:20 +0000
Subject: [PATCH 063/116] fix(site/src/modules/dashboard/LicenseBanner): render
notices heading for a lone diagnostic
A single claims-ignored diagnostic took the single-message path and
rendered without any heading, contradicting the UsageDiagnosticsOnlyHeading
story, which only the pixel snapshot job exercised in CI.
Classify each entitlements message once in toBannerMessage and carry the
result as a structured kind on LicenseBannerMessage, so the view branches
on data instead of re-matching display text. The view now keeps the
"License notices" heading for a lone diagnostic while lone advisories,
warnings, and errors stay heading-less.
---
.../dashboard/LicenseBanner/LicenseBanner.tsx | 55 ++++++++++---------
.../LicenseBannerView.stories.tsx | 3 +
.../LicenseBanner/LicenseBannerView.tsx | 25 ++++++---
3 files changed, 49 insertions(+), 34 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index a6c4cf072ce..2c33e9fcbf4 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -43,11 +43,10 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render muted to stay visually distinct from
-// warnings that demand action, such as exceeding a license limit.
-const isMutedWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
- isDiagnosticMessage(message);
+// Advisories render muted to stay visually distinct from warnings that
+// demand action, such as exceeding a license limit.
+const isAdvisoryMessage = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -140,11 +139,6 @@ const messageLink = (message: string): LicenseBannerLink | undefined => {
showExternalIcon: false,
};
}
- // Diagnostics point the operator at the logs or support, so they do not
- // get a sales link.
- if (isDiagnosticMessage(message)) {
- return undefined;
- }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -152,6 +146,29 @@ const messageLink = (message: string): LicenseBannerLink | undefined => {
};
};
+// Classifies a raw entitlements message once and carries the result as
+// structured message data, so rendering branches on the message's kind and
+// variant fields rather than re-matching display text.
+const toBannerMessage = (
+ message: string,
+ channel: "errors" | "warnings",
+): LicenseBannerMessage => {
+ // Measurement diagnostics travel in the errors channel but are not
+ // license errors. They render muted and without a sales link: they point
+ // the operator at the logs, not at sales.
+ if (isDiagnosticMessage(message)) {
+ return { message, variant: "warning", kind: "diagnostic" };
+ }
+ if (channel === "errors") {
+ return { message, variant: "error", link: messageLink(message) };
+ }
+ return {
+ message,
+ variant: isAdvisoryMessage(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ };
+};
+
export const LicenseBanner: FC = () => {
const { entitlements } = useDashboard();
const { errors } = entitlements;
@@ -174,21 +191,9 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map(
- (message): LicenseBannerMessage => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message) ? "warning" : "error",
- link: messageLink(message),
- }),
- ),
- ...normalizedWarnings.map(
- (message): LicenseBannerMessage => ({
- message,
- variant: isMutedWarning(message) ? "warning" : "warningProminent",
- link: messageLink(message),
- }),
+ ...errors.map((message) => toBannerMessage(message, "errors")),
+ ...normalizedWarnings.map((message) =>
+ toBannerMessage(message, "warnings"),
),
];
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index c351a417655..94b04f29e5d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -260,6 +260,9 @@ export const AIGovernanceNearLimit: Story = {
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
+ // A lone advisory is muted but not a diagnostic, so it renders
+ // without the notices heading.
+ await expect(canvas.queryByText("License notices")).not.toBeInTheDocument();
},
};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 2cef6164395..8212fe70736 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -25,6 +25,10 @@ export interface LicenseBannerLink {
export interface LicenseBannerMessage {
message: string;
variant: LicenseBannerVariant;
+ // Diagnostics about the license or the usage measurement rather than
+ // about usage itself. They keep the "License notices" heading even when
+ // they are the only message, since the muted text needs that context.
+ kind?: "diagnostic";
link?: LicenseBannerLink;
}
@@ -146,6 +150,9 @@ export const LicenseBannerView: React.FC = ({
const bannerVariant = getBannerVariant(messages);
const visibleMessages = messages.slice(0, 2);
const hiddenMessages = messages.slice(2);
+ // A lone diagnostic keeps the heading: without it the muted banner is an
+ // unexplained sentence. Other single messages stay heading-less.
+ const showHeading = !isSingleMessage || messages[0].kind === "diagnostic";
return (
= ({
/>
+ {showHeading && (
+
+ {bannerTitle(bannerVariant)}
+
+ )}
{isSingleMessage ? (
) : (
- <>
-
- {bannerTitle(bannerVariant)}
-
-
- >
+
)}
From e58a40d74123fdf082f9b5870069c35d326b0e11 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:55:40 +0000
Subject: [PATCH 064/116] chore(enterprise/coderd/license): document only
current zero-allocation behavior
Drop the speculative CODAGT-856 concurrency-mode sentence from the
decodeAgentRuntimeHours doc comment. The comment now states only the
implemented contract: a zero allocation grants the feature disabled while
Actual is still measured and published.
---
enterprise/coderd/license/license.go | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index c4fe1ebcf4e..366a853706e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -960,8 +960,7 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// look healthy.
//
// A zero allocation grants the feature disabled, but Actual is still
-// measured and published. CODAGT-856 will make a zero allocation force a
-// concurrency-limited mode; that mode does not exist yet.
+// measured and published.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
From f60be11415b9783390e4f05c6eead94d3c59235e Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 05:16:23 +0000
Subject: [PATCH 065/116] fix(coderd/database): renumber agent runtime
invariants migration to 000570
Main landed 000569_oauth2_scope_columns after the previous renumber to
000569, so the CI test merge contained two migrations with the same
number and every migration-dependent job failed. dump.sql is unchanged
because only the file number moves.
---
....sql => 000570_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000570_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000569_usage_events_agent_runtime_invariants.down.sql => 000570_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000569_usage_events_agent_runtime_invariants.up.sql => 000570_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000570_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000570_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000570_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000570_usage_events_agent_runtime_invariants.up.sql
From aa42e2cf486cfece06b7ccb90c3476a1da2c5c2c Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:37:13 +0000
Subject: [PATCH 066/116] chore(coderd/notifications): sync formatting and
rendered-template goldens with generators
make fmt/go (gci) regroups the imports in smtp_internal_test.go, and
make gen/golden-files re-renders the AI budget notification goldens with
HTML-escaped apostrophes. Both were committed out of sync with the
repo's pinned tools, so every local pre-commit run failed its unstaged
check. No behavior change.
---
coderd/notifications/dispatch/smtp_internal_test.go | 1 +
.../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ----
.../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++--
.../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++--
.../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++--
5 files changed, 7 insertions(+), 10 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 2e7dff8cbec..3557a42a0e3 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -9,6 +9,7 @@ import (
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
+ markdown "github.com/coder/coder/v2/coderd/render"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
index 550fd9b56fd..f64981add03 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
@@ -17,8 +17,6 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
Effective group: Engineering
-This limit is a per-user override.
-
AI budget period: July 1, 2026 - August 1, 2026
@@ -56,8 +54,6 @@ limit ($1000.00). Subsequent requests will be blocked.
Effective group: Engineering
-This limit is a per-user override.
-
AI budget period: July 1, 2026 - August 1, 2026
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
index 37db6f733cc..94d00686923 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+ Codestin Search App
- You've reached your monthly AI budget limit
+ You've reached your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
index 3927ab28e31..4d5ffdf4744 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+
Codestin Search App
- You're approaching your monthly AI budget limit
+ You're approaching your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
index 4315def7665..1523ba12285 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
@@ -24,6 +24,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
- "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
- "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
+ "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
+ "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
}
\ No newline at end of file
From bfacbaf64cade04bf9e7bf383178cbcecf8e2392 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:38:12 +0000
Subject: [PATCH 067/116] fix: enforce uniqueness and hour alignment for agent
runtime usage events
The usage generator writes hb_agent_runtime_v1 rows with created_at at
the UTC hourly bucket start and exactly one row per bucket, but nothing
in the schema enforced either invariant. A duplicate bucket row under a
different id would be double-counted by any consumer summing runtime_ms,
and a misaligned created_at would skew which usage period a bucket is
attributed to.
Replace the non-unique partial index idx_usage_events_agent_runtime with
a unique index of the same shape and add an hour-alignment CHECK
constraint. generateBucket treats a unique violation on the bucket index
as another replica having won the race, mirroring the existing
ON CONFLICT (id) no-op for committed rows.
---
coderd/database/check_constraint.go | 1 +
coderd/database/dump.sql | 5 ++-
...e_events_agent_runtime_invariants.down.sql | 9 ++++
...age_events_agent_runtime_invariants.up.sql | 26 +++++++++++
coderd/database/querier.go | 8 +++-
coderd/database/querier_test.go | 41 +++++++++++++++---
coderd/database/queries.sql.go | 8 +++-
coderd/database/queries/usageevents.sql | 8 +++-
coderd/database/unique_constraint.go | 1 +
enterprise/coderd/usage/generator.go | 38 ++++++++--------
.../coderd/usage/generator_internal_test.go | 43 +++++++++++++++++++
11 files changed, 155 insertions(+), 33 deletions(-)
create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
create mode 100644 enterprise/coderd/usage/generator_internal_test.go
diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go
index 0402e4b8ee7..2f791242f5e 100644
--- a/coderd/database/check_constraint.go
+++ b/coderd/database/check_constraint.go
@@ -57,6 +57,7 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
+ CheckUsageEventsAgentRuntimeHourAligned CheckConstraint = "usage_events_agent_runtime_hour_aligned" // usage_events
CheckUserAIBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
CheckUserAIProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
CheckUserSecretsEnabledRequiresTarget CheckConstraint = "user_secrets_enabled_requires_target" // user_secrets
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index d7595115c90..d70220b91ee 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -3552,7 +3552,8 @@ CREATE TABLE usage_events (
publish_started_at timestamp with time zone,
published_at timestamp with time zone,
failure_message text,
- CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text])))
+ CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text]))),
+ CONSTRAINT usage_events_agent_runtime_hour_aligned CHECK (((event_type <> 'hb_agent_runtime_v1'::text) OR (date_trunc('hour'::text, timezone('UTC'::text, created_at)) = timezone('UTC'::text, created_at))))
);
COMMENT ON TABLE usage_events IS 'usage_events contains usage data that is collected from the product and potentially shipped to the usage collector service.';
@@ -4899,7 +4900,7 @@ CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree
CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
-CREATE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
+CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
CREATE INDEX idx_usage_events_ai_seats ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_ai_seats_v1'::text);
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
new file mode 100644
index 00000000000..ed8b4ae78b8
--- /dev/null
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
@@ -0,0 +1,9 @@
+-- IF EXISTS matches 000561's down migration, so rolling back out of order
+-- (e.g. during an incident) is a no-op instead of a failure.
+DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
+CREATE INDEX idx_usage_events_agent_runtime
+ ON usage_events (event_type, created_at)
+ WHERE event_type = 'hb_agent_runtime_v1';
+
+ALTER TABLE usage_events
+ DROP CONSTRAINT IF EXISTS usage_events_agent_runtime_hour_aligned;
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
new file mode 100644
index 00000000000..33c0c28f1a7
--- /dev/null
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
@@ -0,0 +1,26 @@
+-- The usage generator writes hb_agent_runtime_v1 rows with created_at at
+-- the UTC hourly bucket start and exactly one row per bucket. Uniqueness
+-- keeps any consumer that sums runtime_ms from counting a bucket twice;
+-- the alignment CHECK protects the attribution model, which charges a
+-- bucket to the usage period containing its start.
+--
+-- Both statements validate existing rows. Every supported writer has always
+-- produced conforming data, so a pre-existing violator is anomalous and
+-- failing the migration loudly beats silently rewriting usage rows.
+ALTER TABLE usage_events
+ ADD CONSTRAINT usage_events_agent_runtime_hour_aligned
+ CHECK (
+ event_type <> 'hb_agent_runtime_v1'
+ OR date_trunc('hour', (created_at AT TIME ZONE 'UTC')) = (created_at AT TIME ZONE 'UTC')
+ );
+
+-- Replace the non-unique partial index with a unique one of the same shape,
+-- so reads are served identically. Inserts keep their (id) arbiter:
+-- re-inserting a bucket under its deterministic id stays a silent no-op,
+-- while a duplicate bucket row under a different id raises instead of being
+-- counted twice (generateBucket in enterprise/coderd/usage/generator.go
+-- handles the violation).
+DROP INDEX idx_usage_events_agent_runtime;
+CREATE UNIQUE INDEX idx_usage_events_agent_runtime
+ ON usage_events (event_type, created_at)
+ WHERE event_type = 'hb_agent_runtime_v1';
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index bbc90e02859..b90366b6096 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -1160,8 +1160,12 @@ type sqlcQuerier interface {
InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error
InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error)
InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error)
- // Duplicate events are ignored intentionally to allow for multiple replicas to
- // publish heartbeat events.
+ // Duplicate events are ignored intentionally to allow for multiple replicas
+ // to publish heartbeat events. The (id) arbiter scopes that tolerance to
+ // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+ // bucket under a different id raises on idx_usage_events_agent_runtime
+ // instead, which generateBucket in enterprise/coderd/usage/generator.go
+ // handles.
InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
// InsertUserGroupsByID adds a user to all provided groups, if they exist.
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index 44fc384b619..39d27c4475f 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -10911,11 +10911,10 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb_agent_runtime_v1:2025-01-02_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 250}`, day2)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
- // Re-inserting a bucket must not double-count it. The daily rollup
- // sums runtime_ms, so idempotency rests on the aggregate trigger
- // being AFTER INSERT: Postgres does not fire it for rows suppressed
- // by ON CONFLICT (id) DO NOTHING. Concurrent replicas and backfill
- // re-runs both take this path.
+ // Re-inserting a bucket under its deterministic id must not
+ // double-count it: the daily rollup's AFTER INSERT trigger does not
+ // fire for rows suppressed by the insert's ON CONFLICT (id)
+ // arbiter.
insert("hb_agent_runtime_v1:2025-01-01_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 1000}`, day1)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
@@ -10923,6 +10922,38 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb-seats-1", "hb_ai_seats_v1", `{"count": 3}`, day2)
rows := getDailyRows(ctx, sqlDB)
require.Len(t, rows, 3)
+
+ // The same bucket under a different id is not an idempotent
+ // re-insert but a duplicate that would double any aggregate summing
+ // runtime_ms; the unique partial index
+ // idx_usage_events_agent_runtime rejects it loudly instead of the
+ // (id) arbiter silently dropping it.
+ err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "different-id-same-bucket",
+ EventType: "hb_agent_runtime_v1",
+ EventData: []byte(`{"runtime_ms": 9999}`),
+ CreatedAt: day1,
+ })
+ require.True(t, database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime),
+ "expected unique violation on idx_usage_events_agent_runtime, got %v", err)
+ // The rejected row must not have reached the daily rollup either.
+ rows = getDailyRows(ctx, sqlDB)
+ require.Len(t, rows, 3)
+ require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
+
+ // created_at must be the exact UTC hourly bucket start;
+ // usage_events_agent_runtime_hour_aligned rejects a misaligned row
+ // so it cannot skew the period a bucket is attributed to.
+ err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "hb_agent_runtime_v1:misaligned",
+ EventType: "hb_agent_runtime_v1",
+ EventData: []byte(`{"runtime_ms": 100}`),
+ CreatedAt: day1.Add(30 * time.Minute),
+ })
+ require.ErrorContains(t, err, string(database.CheckUsageEventsAgentRuntimeHourAligned))
+ rows = getDailyRows(ctx, sqlDB)
+ require.Len(t, rows, 3)
+ require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
})
t.Run("UnknownEventType", func(t *testing.T) {
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index f1029f44a15..d2551a623db 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28708,8 +28708,12 @@ type InsertUsageEventParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
-// Duplicate events are ignored intentionally to allow for multiple replicas to
-// publish heartbeat events.
+// Duplicate events are ignored intentionally to allow for multiple replicas
+// to publish heartbeat events. The (id) arbiter scopes that tolerance to
+// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+// bucket under a different id raises on idx_usage_events_agent_runtime
+// instead, which generateBucket in enterprise/coderd/usage/generator.go
+// handles.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 8ba706b0c8a..03f435c5af7 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -1,6 +1,10 @@
-- name: InsertUsageEvent :exec
--- Duplicate events are ignored intentionally to allow for multiple replicas to
--- publish heartbeat events.
+-- Duplicate events are ignored intentionally to allow for multiple replicas
+-- to publish heartbeat events. The (id) arbiter scopes that tolerance to
+-- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+-- bucket under a different id raises on idx_usage_events_agent_runtime
+-- instead, which generateBucket in enterprise/coderd/usage/generator.go
+-- handles.
INSERT INTO
usage_events (
id,
diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go
index 4b1a4376f2d..23256ed3b86 100644
--- a/coderd/database/unique_constraint.go
+++ b/coderd/database/unique_constraint.go
@@ -160,6 +160,7 @@ const (
UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text)));
UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true);
UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
+ UniqueIndexUsageEventsAgentRuntime UniqueConstraint = "idx_usage_events_agent_runtime" // CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE ((deleted = false) AND (email <> ''::text));
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index 91d911b57a7..a5b6788a214 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -48,8 +48,11 @@ const (
// Cron jobs, which sample live state when they fire, the Generator derives
// events from data already persisted in the database, so it can
// deterministically backfill hours missed while the deployment was down,
-// zero-filling idle hours. Deterministic event IDs plus the database's
-// ON CONFLICT (id) DO NOTHING make concurrent replicas safe without locking.
+// zero-filling idle hours. Deterministic event IDs make concurrent replicas
+// safe without locking: a re-insert of a committed bucket is a no-op via the
+// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted
+// bucket surface a unique violation that generateBucket recognizes as the
+// other replica winning.
//
// Events are generated unconditionally in enterprise builds; the
// publish_usage_data license flag only gates publishing to Tallyman.
@@ -155,24 +158,12 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error {
return xerrors.Errorf("list existing agent runtime events: %w", err)
}
// A row marks its bucket complete regardless of publish outcome, so a
- // bucket whose event Tallyman permanently rejected is never
- // regenerated (re-inserting under the deterministic ID is a no-op via
- // ON CONFLICT (id) DO NOTHING).
- //
- // The runtime is not lost locally: the row still holds it, and the
- // event can be re-queued for publishing with
- //
- // UPDATE usage_events
- // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL
- // WHERE id = 'hb_agent_runtime_v1:';
- //
- // That re-arm only has an effect while the bucket is inside the
- // publisher's 30-day cutoff: SelectUsageEventsForPublishing also
- // filters created_at > now - INTERVAL '30 days', and created_at is the
- // bucket start, so past that the UPDATE reports success but the row is
- // never picked up again. The release gate (Tallyman must accept this
- // event type before coderd ships it) is what keeps permanent
- // rejections exceptional.
+ // bucket whose event Tallyman permanently rejected is never regenerated
+ // (re-inserting under the deterministic ID is a no-op via the insert's
+ // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row
+ // keeps it, and clearing the row's publish columns re-queues it while
+ // the bucket is within SelectUsageEventsForPublishing's 30-day
+ // created_at cutoff.
existing := make(map[time.Time]struct{}, len(existingTimes))
for _, ts := range existingTimes {
// created_at is always the exact bucket start for this event type;
@@ -235,6 +226,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
// time) so daily rollups attribute backfilled hours to the correct day.
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
+ if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
+ // The insert's ON CONFLICT (id) arbiter only sees committed rows, so
+ // a concurrent replica inserting the same bucket can trip the bucket
+ // unique index instead. Either way a row for this bucket already
+ // exists, which is all generateBucket needs.
+ return nil
+ }
if err != nil {
return xerrors.Errorf("insert usage event: %w", err)
}
diff --git a/enterprise/coderd/usage/generator_internal_test.go b/enterprise/coderd/usage/generator_internal_test.go
new file mode 100644
index 00000000000..fa6aeb63440
--- /dev/null
+++ b/enterprise/coderd/usage/generator_internal_test.go
@@ -0,0 +1,43 @@
+package usage
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ "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/testutil"
+ "github.com/coder/quartz"
+)
+
+// TestGenerateBucketUniqueViolation pins that a unique violation on the
+// bucket index resolves the bucket as complete: another writer already
+// recorded it. TestGeneratorConcurrentReplicas also reaches this path, but
+// only when its goroutines actually interleave; this case cannot pass by
+// scheduling accident.
+func TestGenerateBucketUniqueViolation(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitShort)
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+ gen := NewGenerator(quartz.NewMock(t), slogtest.Make(t, nil), mDB, NewDBInserter())
+
+ mDB.EXPECT().
+ GetTotalChatMessageRuntimeMsInRange(gomock.Any(), gomock.Any()).
+ Return(int64(1000), nil)
+ mDB.EXPECT().
+ InsertUsageEvent(gomock.Any(), gomock.Any()).
+ Return(&pq.Error{
+ Code: "23505", // unique_violation
+ Constraint: string(database.UniqueIndexUsageEventsAgentRuntime),
+ })
+
+ bucket := time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC)
+ require.NoError(t, gen.generateBucket(ctx, bucket))
+}
From f82a41ffe49bdca81edfcfda78a8b1b25b9a5d67 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 12:04:06 +0000
Subject: [PATCH 068/116] revert: "chore(coderd/notifications): sync formatting
and rendered-template goldens with generators"
This reverts commit ed531d9d365720b4e394f4b422ea9776886c56a3.
The gen/fmt drift it patched over came from main commit 07f79af65b2
(fix: markdown rendering improvements) having landed out of sync with
its generators; main has since reverted that commit entirely in
8c2f7adeb1f (#27979), so the sync is obsolete and conflicts with the
regenerated goldens on latest main.
---
coderd/notifications/dispatch/smtp_internal_test.go | 3 ++-
.../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ++++
.../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++--
.../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++--
.../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++--
5 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 3557a42a0e3..03324f78573 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -7,9 +7,10 @@ import (
"github.com/stretchr/testify/require"
+ markdown "github.com/coder/coder/v2/coderd/render"
+
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
- markdown "github.com/coder/coder/v2/coderd/render"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
index f64981add03..550fd9b56fd 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
@@ -17,6 +17,8 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
Effective group: Engineering
+This limit is a per-user override.
+
AI budget period: July 1, 2026 - August 1, 2026
@@ -54,6 +56,8 @@ limit ($1000.00). Subsequent requests will be blocked.
Effective group: Engineering
+This limit is a per-user override.
+
AI budget period: July 1, 2026 - August 1, 2026
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
index 94d00686923..37db6f733cc 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+ Codestin Search App
- You've reached your monthly AI budget limit
+ You've reached your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
index 4d5ffdf4744..3927ab28e31 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+
Codestin Search App
- You're approaching your monthly AI budget limit
+ You're approaching your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
index 1523ba12285..4315def7665 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
@@ -24,6 +24,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
- "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
- "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
+ "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
+ "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
}
\ No newline at end of file
From cd5540e78096bca96963196157f3f099e52568a3 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 13:21:33 +0000
Subject: [PATCH 069/116] updates for PR review
---
.../000565_usage_events_agent_runtime_invariants.down.sql | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
index ed8b4ae78b8..8a6a0240988 100644
--- a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
@@ -1,5 +1,6 @@
--- IF EXISTS matches 000561's down migration, so rolling back out of order
--- (e.g. during an incident) is a no-op instead of a failure.
+-- IF EXISTS matches 000561's down migration: if the index is already gone
+-- (e.g. rolling back out of order during an incident), tolerate the miss
+-- instead of failing.
DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
CREATE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
From 49ba654031793ed0f8c0e03db9fbe1ad27df5757 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:16:52 +0000
Subject: [PATCH 070/116] chore(coderd/database): restore original
InsertUsageEvent comment
The expanded arbiter-scoping commentary is not needed on the insert
query; the duplicate-bucket behavior is documented on the migration and
in generateBucket where it is handled.
---
coderd/database/querier.go | 8 ++------
coderd/database/queries.sql.go | 8 ++------
coderd/database/queries/usageevents.sql | 8 ++------
3 files changed, 6 insertions(+), 18 deletions(-)
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index b90366b6096..bbc90e02859 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -1160,12 +1160,8 @@ type sqlcQuerier interface {
InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error
InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error)
InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error)
- // Duplicate events are ignored intentionally to allow for multiple replicas
- // to publish heartbeat events. The (id) arbiter scopes that tolerance to
- // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
- // bucket under a different id raises on idx_usage_events_agent_runtime
- // instead, which generateBucket in enterprise/coderd/usage/generator.go
- // handles.
+ // Duplicate events are ignored intentionally to allow for multiple replicas to
+ // publish heartbeat events.
InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
// InsertUserGroupsByID adds a user to all provided groups, if they exist.
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index d2551a623db..f1029f44a15 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28708,12 +28708,8 @@ type InsertUsageEventParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
-// Duplicate events are ignored intentionally to allow for multiple replicas
-// to publish heartbeat events. The (id) arbiter scopes that tolerance to
-// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
-// bucket under a different id raises on idx_usage_events_agent_runtime
-// instead, which generateBucket in enterprise/coderd/usage/generator.go
-// handles.
+// Duplicate events are ignored intentionally to allow for multiple replicas to
+// publish heartbeat events.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 03f435c5af7..8ba706b0c8a 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -1,10 +1,6 @@
-- name: InsertUsageEvent :exec
--- Duplicate events are ignored intentionally to allow for multiple replicas
--- to publish heartbeat events. The (id) arbiter scopes that tolerance to
--- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
--- bucket under a different id raises on idx_usage_events_agent_runtime
--- instead, which generateBucket in enterprise/coderd/usage/generator.go
--- handles.
+-- Duplicate events are ignored intentionally to allow for multiple replicas to
+-- publish heartbeat events.
INSERT INTO
usage_events (
id,
From 52d8e5db83b7bc7b6d4fa07afa7f931a928c5def Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:46:36 +0000
Subject: [PATCH 071/116] fix(coderd/database): renumber agent runtime
invariants migration to 000567
Main landed its own 000565 and 000566 (oauth2 client type constraint and
auth method backfill) after this branch was cut, making the migration
number a duplicate that fails migrate setup.
---
....sql => 000567_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.down.sql => 000567_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
From 87c49d57d9c85b96080d95ba9421b8d5b2a7fba4 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 07:12:28 +0000
Subject: [PATCH 072/116] chore(enterprise/coderd/usage): restore agent runtime
requeue guidance comment
---
enterprise/coderd/usage/generator.go | 24 ++++++++++++++++++------
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index a5b6788a214..af7fcd713ee 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -158,12 +158,24 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error {
return xerrors.Errorf("list existing agent runtime events: %w", err)
}
// A row marks its bucket complete regardless of publish outcome, so a
- // bucket whose event Tallyman permanently rejected is never regenerated
- // (re-inserting under the deterministic ID is a no-op via the insert's
- // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row
- // keeps it, and clearing the row's publish columns re-queues it while
- // the bucket is within SelectUsageEventsForPublishing's 30-day
- // created_at cutoff.
+ // bucket whose event Tallyman permanently rejected is never
+ // regenerated (re-inserting under the deterministic ID is a no-op via
+ // the insert's ON CONFLICT (id) arbiter).
+ //
+ // The runtime is not lost locally: the row still holds it, and the
+ // event can be re-queued for publishing with
+ //
+ // UPDATE usage_events
+ // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL
+ // WHERE id = 'hb_agent_runtime_v1:';
+ //
+ // That re-arm only has an effect while the bucket is inside the
+ // publisher's 30-day cutoff: SelectUsageEventsForPublishing also
+ // filters created_at > now - INTERVAL '30 days', and created_at is the
+ // bucket start, so past that the UPDATE reports success but the row is
+ // never picked up again. The release gate (Tallyman must accept this
+ // event type before coderd ships it) is what keeps permanent
+ // rejections exceptional.
existing := make(map[time.Time]struct{}, len(existingTimes))
for _, ts := range existingTimes {
// created_at is always the exact bucket start for this event type;
From 41548baab64c300ccca87cc80f3942606106545c Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:32:48 +0000
Subject: [PATCH 073/116] fix(coderd/database): renumber agent runtime
invariants migration to 000569
Main landed its own 000567 and 000568 (chat file purge indexes and
service account notifications) after this branch was renumbered to
000567, making the migration number a duplicate that fails migrate
setup in the test merge.
---
....sql => 000569_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.down.sql => 000569_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
From 9f3828579e13aecc9d7aee564cdd8f265b32f3dd Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:45:24 +0000
Subject: [PATCH 074/116] chore(enterprise/coderd/usage): correct ON CONFLICT
concurrency comments
The (id) arbiter is not limited to committed rows: when a competing
row's arbiter index entry is visible, PostgreSQL waits on that
transaction and takes the DO NOTHING path if it commits. Only the
narrower speculative-insertion race, before the arbiter entry exists,
trips the bucket unique index.
---
enterprise/coderd/usage/generator.go | 22 ++++++++++++++--------
1 file changed, 14 insertions(+), 8 deletions(-)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index af7fcd713ee..cec94a1a511 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -49,10 +49,13 @@ const (
// events from data already persisted in the database, so it can
// deterministically backfill hours missed while the deployment was down,
// zero-filling idle hours. Deterministic event IDs make concurrent replicas
-// safe without locking: a re-insert of a committed bucket is a no-op via the
-// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted
-// bucket surface a unique violation that generateBucket recognizes as the
-// other replica winning.
+// safe without locking: the insert's ON CONFLICT (id) arbiter turns a
+// re-insert of a bucket into a no-op, even when the competing insert is
+// still in flight (once its arbiter index entry is visible, PostgreSQL
+// waits on that transaction and takes the DO NOTHING path if it commits).
+// Only the narrow speculative-insertion race, before the competing row's
+// arbiter entry exists, surfaces a bucket unique violation instead, which
+// generateBucket recognizes as the other replica winning.
//
// Events are generated unconditionally in enterprise builds; the
// publish_usage_data license flag only gates publishing to Tallyman.
@@ -239,10 +242,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
- // The insert's ON CONFLICT (id) arbiter only sees committed rows, so
- // a concurrent replica inserting the same bucket can trip the bucket
- // unique index instead. Either way a row for this bucket already
- // exists, which is all generateBucket needs.
+ // The insert's ON CONFLICT (id) arbiter absorbs most duplicate
+ // inserts, including in-flight ones: once a competing row's arbiter
+ // index entry is visible, PostgreSQL waits on that transaction and
+ // takes the DO NOTHING path if it commits. Only the narrow
+ // speculative-insertion race, before that entry exists, trips the
+ // bucket unique index instead. Either way a row for this bucket
+ // already exists, which is all generateBucket needs.
return nil
}
if err != nil {
From e9f3f2604ffde0afaf09dd7d43e15960e579439a Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Thu, 13 Aug 2026 04:37:09 +0000
Subject: [PATCH 075/116] fix(coderd/notifications/dispatch): drop unused
markdown import in smtp test
The revert in cb36cfae2c5 left behind an unused
"github.com/coder/coder/v2/coderd/render" import, breaking compilation
of the dispatch test package and failing fmt, lint, and every
test-go-pg job. Restore the import block to match main.
---
coderd/notifications/dispatch/smtp_internal_test.go | 2 --
1 file changed, 2 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 03324f78573..2e7dff8cbec 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -7,8 +7,6 @@ import (
"github.com/stretchr/testify/require"
- markdown "github.com/coder/coder/v2/coderd/render"
-
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
)
From be402c19df5e0d8974a5f952a297343165ea34f4 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:39:21 +0000
Subject: [PATCH 076/116] chore(coderd): trim historical framing from invariant
comments
Migration comments now state only the current invariant and rollback
reason, and generateBucket defers the full ON CONFLICT concurrency
contract to the Generator doc comment instead of repeating it.
---
...0569_usage_events_agent_runtime_invariants.down.sql | 5 ++---
...000569_usage_events_agent_runtime_invariants.up.sql | 10 ++++------
enterprise/coderd/usage/generator.go | 10 +++-------
3 files changed, 9 insertions(+), 16 deletions(-)
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
index 8a6a0240988..55d3b173185 100644
--- a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
+++ b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
@@ -1,6 +1,5 @@
--- IF EXISTS matches 000561's down migration: if the index is already gone
--- (e.g. rolling back out of order during an incident), tolerate the miss
--- instead of failing.
+-- IF EXISTS tolerates the index already being gone (e.g. rolling back out
+-- of order during an incident) instead of failing.
DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
CREATE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
index 33c0c28f1a7..7577b386f02 100644
--- a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
+++ b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
@@ -14,12 +14,10 @@ ALTER TABLE usage_events
OR date_trunc('hour', (created_at AT TIME ZONE 'UTC')) = (created_at AT TIME ZONE 'UTC')
);
--- Replace the non-unique partial index with a unique one of the same shape,
--- so reads are served identically. Inserts keep their (id) arbiter:
--- re-inserting a bucket under its deterministic id stays a silent no-op,
--- while a duplicate bucket row under a different id raises instead of being
--- counted twice (generateBucket in enterprise/coderd/usage/generator.go
--- handles the violation).
+-- Inserts keep their (id) arbiter: re-inserting a bucket under its
+-- deterministic id stays a silent no-op, while a duplicate bucket row under
+-- a different id raises a unique violation (generateBucket in
+-- enterprise/coderd/usage/generator.go handles it).
DROP INDEX idx_usage_events_agent_runtime;
CREATE UNIQUE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index cec94a1a511..74e8ae9ac19 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -242,13 +242,9 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
- // The insert's ON CONFLICT (id) arbiter absorbs most duplicate
- // inserts, including in-flight ones: once a competing row's arbiter
- // index entry is visible, PostgreSQL waits on that transaction and
- // takes the DO NOTHING path if it commits. Only the narrow
- // speculative-insertion race, before that entry exists, trips the
- // bucket unique index instead. Either way a row for this bucket
- // already exists, which is all generateBucket needs.
+ // Another replica already created this bucket's row. The Generator
+ // doc comment explains why this race reaches the bucket unique
+ // index instead of the insert's ON CONFLICT (id) arbiter.
return nil
}
if err != nil {
From 95021dfad2e0cfc2c4b19d4fb2577373d814e878 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 05:16:23 +0000
Subject: [PATCH 077/116] fix(coderd/database): renumber agent runtime
invariants migration to 000570
Main landed 000569_oauth2_scope_columns after the previous renumber to
000569, so the CI test merge contained two migrations with the same
number and every migration-dependent job failed. dump.sql is unchanged
because only the file number moves.
---
....sql => 000570_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000570_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000569_usage_events_agent_runtime_invariants.down.sql => 000570_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000569_usage_events_agent_runtime_invariants.up.sql => 000570_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000570_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000570_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000570_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000570_usage_events_agent_runtime_invariants.up.sql
From b468790d00dfafdd33652a27a68417c8944d29d2 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:58:22 +0000
Subject: [PATCH 078/116] feat: report stable license measurement diagnostics
and tolerate unusable runtime hours claims
Two coupled changes to the license/entitlements layer.
Measurement diagnostics: extract a measureUsage helper that owns the
usage-query failure policy. A query failure now publishes the stable
codersdk.LicenseManagedAgentUsageUnavailableErrorText on the
entitlements Errors channel (keeping the alertable coderd_license_errors
gauge counting) while the raw cause goes to the coderd log through the
new FeatureArguments.Logger. A nil usage closure becomes a hard
developer error, and a failure with a dead context still aborts the
whole refresh. The managed agent count closure switches from
AsSystemRestricted to the narrower AsUsagePublisher subject. The
dashboard recognizes diagnostic texts and renders them muted, without
the exceedance heading or a sales link.
Claim tolerance: unusable agent_runtime_hours_* claim combinations no
longer reject the whole license. decodeAgentRuntimeHours drops the
unusable claims, reports them in a stable
LicenseAgentRuntimeHoursClaimsIgnoredWarningText warning (deduplicated
across licenses), and logs the affected license and claims;
validateAgentRuntimeHours and its license-invalidating errors are
removed. Rejecting a signed license over a cosmetic threshold claim
would drop the deployment to unlicensed.
Part of the stack for coder/coder#27796.
---
codersdk/licenses.go | 13 +
enterprise/coderd/coderd_test.go | 6 +
.../coderd/coderdenttest/coderdenttest.go | 15 +
enterprise/coderd/license/license.go | 228 +++++----
enterprise/coderd/license/license_test.go | 476 +++++++++++++++---
enterprise/coderd/licenses_test.go | 35 +-
site/src/api/typesGenerated.ts | 23 +
.../dashboard/LicenseBanner/LicenseBanner.tsx | 54 +-
.../LicenseBannerView.stories.tsx | 113 ++++-
.../LicenseBanner/LicenseBannerView.tsx | 20 +-
10 files changed, 762 insertions(+), 221 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index f2ccf009842..7aecbaa3089 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,6 +17,19 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ // managed agent usage query fails while computing entitlements; the
+ // cause is logged server-side. It travels in the entitlements Errors
+ // channel so the alertable coderd_license_errors gauge counts
+ // measurement failures, but the dashboard recognizes the exact text and
+ // renders it as a muted diagnostic rather than a license error.
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
+ // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ // license carries unusable Coder Agent runtime hour claims (see
+ // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ // the license and the dropped claims. The dashboard renders the exact
+ // text as a muted diagnostic without a sales link.
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
type AddLicenseRequest struct {
diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go
index e023da7b1df..7be01405353 100644
--- a/enterprise/coderd/coderd_test.go
+++ b/enterprise/coderd/coderd_test.go
@@ -92,6 +92,12 @@ func TestEntitlements(t *testing.T) {
// Enable all features
features := make(license.Features)
for _, feature := range codersdk.FeatureNames {
+ if feature == codersdk.FeatureAgentRuntimeHours {
+ // The feature name is not a valid license claim; the
+ // feature is encoded as its allocation claim.
+ features[license.ClaimAgentRuntimeHoursAllocation] = 1
+ continue
+ }
features[feature] = 1
}
features[codersdk.FeatureUserLimit] = 100
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 1115ba12118..8f5f1e4a708 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,6 +237,21 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
+// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
+// or hardLimit omits that claim, which a license is allowed to do; a non-nil
+// value is emitted verbatim, so tests can express explicit zero or negative
+// claims.
+func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
+ opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
+ if softLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitSoft, *softLimit)
+ }
+ if hardLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitHard, *hardLimit)
+ }
+ return opts
+}
+
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
if opts.Features == nil {
opts.Features = license.Features{}
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8cce657de65..e6f3598475e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -93,6 +93,7 @@ func Entitlements(
}
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
+ Logger: logger,
ActiveUserCount: activeUserCount,
ActiveAISeatCount: activeAISeatCount,
ReplicaCount: replicaCount,
@@ -114,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Reading usage events requires the usage publisher subject.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -129,6 +130,9 @@ func Entitlements(
}
type FeatureArguments struct {
+ // Logger receives the causes behind operator-facing diagnostics whose
+ // published message is a stable text. The zero value discards them.
+ Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
ReplicaCount int
@@ -508,11 +512,8 @@ func LicensesEntitlements(
continue
}
- // Agent runtime hours are encoded as up to three claims and are
- // decoded together after this loop, see
- // decodeAgentRuntimeHours. The feature name itself is never a
- // valid claim. The allocation must come from the dedicated claim
- // so it is validated against the soft and hard limits.
+ // Agent runtime hour claims are decoded together after this
+ // loop; see decodeAgentRuntimeHours.
if featureName == codersdk.FeatureAgentRuntimeHours ||
isAgentRuntimeHoursClaim(featureName) {
continue
@@ -577,14 +578,25 @@ func LicensesEntitlements(
}
}
- // The loop above skips Agent runtime hours because the
- // three claims that encode them decode into a single feature.
- if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
+ runtimeFeature, granted, ignoredClaims := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
- }); ok {
- entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature)
+ })
+ if granted {
+ entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
+ }
+ if len(ignoredClaims) > 0 {
+ // The published warning is a stable text, so the details a
+ // support case needs go to the log.
+ featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
+ slog.F("license_id", license.UUID),
+ slog.F("ignored_claims", ignoredClaims),
+ )
+ if !slices.Contains(entitlements.Warnings, codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) {
+ entitlements.Warnings = append(entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ }
}
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
@@ -701,24 +713,18 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- var (
- managedAgentCount int64
- err = xerrors.New("dev error: managed agent count function is not set")
- )
- if featureArguments.ManagedAgentCountFn != nil {
- managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
- }
- if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
- // If the context is canceled, we want to bail the entire
- // LicensesEntitlements call.
- return entitlements, xerrors.Errorf("get managed agent count: %w", err)
- }
+ managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
+ featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
+ "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
if err != nil {
- entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
- // no return
- } else {
+ return entitlements, err
+ }
+ if ok {
agentLimit.Actual = &managedAgentCount
- entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
+ // Written back directly: the feature contest is already
+ // settled, so AddFeature's Compare must not get a chance to
+ // drop the write.
+ entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -857,6 +863,42 @@ func LicensesEntitlements(
return entitlements, nil
}
+// measureUsage runs one usage query over the feature's usage period and owns
+// the shared failure policy: a nil fn is a wiring bug and fails the whole
+// LicensesEntitlements call; a failure with a dead context fails the call
+// without logging; any other failure logs the cause and publishes the stable
+// unavailableText instead. It returns the measured value and true only on
+// success.
+func measureUsage(
+ ctx context.Context,
+ entitlements *codersdk.Entitlements,
+ logger slog.Logger,
+ fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
+ usagePeriod codersdk.UsagePeriod,
+ what string,
+ unavailableText string,
+) (int64, bool, error) {
+ if fn == nil {
+ return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
+ }
+ value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
+ switch {
+ case err != nil && ctx.Err() != nil:
+ // The computation's own context is dead, so abort the whole call
+ // without logging. Do not classify by error shape instead: Postgres
+ // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
+ // as well as client cancels, and aborting on those would fail every
+ // entitlements refresh on a deployment whose statement_timeout is
+ // shorter than a usage query.
+ return 0, false, xerrors.Errorf("get %s: %w", what, err)
+ case err != nil:
+ logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
+ entitlements.Errors = append(entitlements.Errors, unavailableText)
+ return 0, false, nil
+ }
+ return value, true, nil
+}
+
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
@@ -885,23 +927,22 @@ const (
VersionClaim = "version"
)
-// Agent runtime hour license claims. These are the canonical claim names
-// minted by github.com/coder/license. All three claims map to the single
-// codersdk.FeatureAgentRuntimeHours feature and are validated together when
-// the license is parsed, see validateClaims.
-//
-// The unit for all three claims is hours.
+// Agent runtime hour license claims, minted by github.com/coder/license.
+// All three are in hours and decode together into the single
+// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit.
+ // allocation for the license term. It becomes the feature's Limit. A
+ // negative allocation is ignored, in which case the license does not
+ // grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // must satisfy 0 <= soft < allocation, so it may only be set when the
- // allocation is greater than 0. It becomes the feature's SoftLimit.
+ // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
- // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be
- // absent or >= allocation, and may only be set when the allocation is
- // greater than 0. It becomes the feature's HardLimit.
+ // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
+ // the feature's HardLimit when the allocation is greater than 0 and
+ // hard >= allocation, and is ignored otherwise.
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
@@ -917,19 +958,12 @@ var (
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
ErrMissingAccountType = xerrors.New("license must contain valid account type")
ErrMissingAccountID = xerrors.New("license must contain valid account ID")
-
- ErrMissingAgentRuntimeHoursAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation)
- ErrAgentRuntimeHoursLimitsWithZeroAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but the %s claim is 0", ClaimAgentRuntimeHoursAllocation)
)
type Features map[codersdk.FeatureName]int64
-// isAgentRuntimeHoursClaim reports whether the claim name is one of the three
-// claims that encode the codersdk.FeatureAgentRuntimeHours feature. These
-// claims are decoded together, see decodeAgentRuntimeHours.
+// isAgentRuntimeHoursClaim reports whether name is one of the three claims
+// decoded by decodeAgentRuntimeHours.
func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
switch name {
case ClaimAgentRuntimeHoursAllocation,
@@ -941,62 +975,65 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
}
}
-// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours feature
-// from the claims that encode it. It reports false when the license carries no
-// allocation claim, in which case the license does not grant the feature.
+// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours
+// feature from its claims. granted is false when there is no usable
+// allocation claim; per-claim validity rules live on the Claim* constants
+// above.
+//
+// Unusable claims are dropped, never license-invalidating: rejecting a
+// signed license over a cosmetic threshold claim would drop the deployment
+// to unlicensed. ignoredClaims names each dropped claim (including the
+// feature name itself minted as a claim, the most plausible issuer mistake)
+// so the caller can warn and log instead of letting an incorrectly issued
+// license look healthy.
//
-// The claim combination is validated when the license is parsed, see
-// Features.validateAgentRuntimeHours. The allocation is never negative here
-// and the soft and hard limits are only present alongside a positive
-// allocation.
-func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (codersdk.Feature, bool) {
- allocation, ok := features[ClaimAgentRuntimeHoursAllocation]
- if !ok {
- return codersdk.Feature{}, false
+// A zero allocation grants the feature disabled and drops both threshold
+// claims, but Actual is still measured and published. CODAGT-856 will make a
+// zero allocation force a concurrency-limited mode; that mode does not exist
+// yet.
+func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
+ if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
+ ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
}
- feature := codersdk.Feature{
+ allocation, allocOk := features[ClaimAgentRuntimeHoursAllocation]
+ soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
+ hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+
+ if !allocOk || allocation < 0 {
+ if allocOk && allocation < 0 {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
+ }
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{}, false, ignoredClaims
+ }
+
+ feature = codersdk.Feature{
Enabled: allocation > 0,
Entitlement: entitlement,
Limit: &allocation,
UsagePeriod: &usagePeriod,
}
- if soft, ok := features[ClaimAgentRuntimeHoursLimitSoft]; ok {
- feature.SoftLimit = &soft
- }
- if hard, ok := features[ClaimAgentRuntimeHoursLimitHard]; ok {
- feature.HardLimit = &hard
- }
- return feature, true
-}
-
-// validateAgentRuntimeHours validates the relationship between the agent
-// runtime hour claims. Invalid combinations reject the entire license.
-func (f Features) validateAgentRuntimeHours() error {
- allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation]
- soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft]
- hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard]
- if !hasAllocation {
- if hasSoft || hasHard {
- return ErrMissingAgentRuntimeHoursAllocation
+ if softOk {
+ if soft > 0 && soft < allocation {
+ feature.SoftLimit = &soft
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
}
- return nil
- }
- if allocation < 0 {
- return ErrInvalidAgentRuntimeHoursAllocation
- }
- // A zero allocation disables the feature.
- // A zero hard limit is not permitted.
- if allocation == 0 && (hasSoft || hasHard) {
- return ErrAgentRuntimeHoursLimitsWithZeroAllocation
}
- if hasSoft && (soft < 0 || soft >= allocation) {
- return ErrInvalidAgentRuntimeHoursSoftLimit
- }
- if hasHard && hard < allocation {
- return ErrInvalidAgentRuntimeHoursHardLimit
+ if hardOk {
+ if allocation > 0 && hard >= allocation {
+ feature.HardLimit = &hard
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
}
- return nil
+ return feature, true, ignoredClaims
}
// Claims is the full set of claims in a license.
@@ -1089,9 +1126,6 @@ func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims.AccountID == "" {
return nil, ErrMissingAccountID
}
- if err := claims.Features.validateAgentRuntimeHours(); err != nil {
- return nil, err
- }
return claims, nil
}
return nil, xerrors.New("unable to parse Claims")
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 6fe50902850..db68fb8b60a 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -1,9 +1,11 @@
package license_test
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "maps"
"slices"
"testing"
"time"
@@ -13,12 +15,17 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
+ "golang.org/x/xerrors"
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "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/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -30,6 +37,42 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
+// premiumRuntimeHoursFixture returns a mock store primed with a Premium
+// license carrying runtime hour claims (allocation 100, soft limit 80, hard
+// limit 120) plus the store expectations every entitlements refresh consumes
+// before usage is measured. Callers add expectations for the usage queries
+// under test.
+func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
+ t.Helper()
+
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+
+ licenseOpts := (&coderdenttest.LicenseOptions{
+ FeatureSet: codersdk.FeatureSetPremium,
+ IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
+ NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
+ GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
+ ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
+ // The addon marks AI Bridge as explicitly entitled, suppressing
+ // the unrelated "AI Governance add-on is required to use AI
+ // Gateway" warning that Premium would otherwise produce.
+ }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
+
+ lic := database.License{
+ ID: 1,
+ JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
+ Exp: licenseOpts.ExpiresAt,
+ }
+
+ mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
+ mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
+ mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
+ mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
+
+ return mDB, licenseOpts
+}
+
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -920,6 +963,63 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
+ t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
+ t.Parallel()
+
+ // Drive the real Entitlements closure with a mock database so
+ // measureUsage's failure path is exercised end to end: the cause
+ // must land in the coderd log, which the stable payload text points
+ // at, and must not land on the unauthenticated entitlements payload.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), xerrors.New("kaboom managed"))
+
+ // The error-level logs are the behavior under test, so the default
+ // failing test logger cannot be used.
+ var logBuf bytes.Buffer
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
+ AppendSinks(sloghuman.Sink(&logBuf))
+
+ entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+
+ // The failure surfaces its stable text without the raw cause,
+ // on the channel the codersdk constant docs prescribe.
+ require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
+ for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
+ require.NotContains(t, entry, "kaboom")
+ }
+
+ logs := logBuf.String()
+ require.Contains(t, logs, "get managed agent count for entitlements")
+ require.Contains(t, logs, "kaboom managed")
+ })
+
+ t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
+ t.Parallel()
+
+ // A query failing while the refresh's own context is canceled,
+ // e.g. during shutdown, aborts the whole entitlements refresh and
+ // must not log a false query-failure alarm at error level.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), context.Canceled)
+
+ var logBuf bytes.Buffer
+ logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.ErrorContains(t, err, "get managed agent count")
+ require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
+ })
+
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1299,6 +1399,12 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
+ // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
+ // injection below so the nil dev-error path can be exercised.
+ KeepNilManagedAgentCountFn bool
+ // CancelContext cancels the context passed to LicensesEntitlements
+ // before the call, exercising the usage-measurement abort policy.
+ CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1550,6 +1656,59 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
+ {
+ // A query failure is surfaced as a stable text in Errors (see
+ // the codersdk constant docs for the channel choice) and
+ // leaves Actual unset without aborting the rest of the
+ // entitlements.
+ Name: "ManagedAgentLimit/QueryError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, xerrors.New("kaboom")
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoWarnings(t, entitlements)
+ require.Len(t, entitlements.Errors, 1)
+ assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
+ // The raw error is logged rather than exposed on the
+ // unauthenticated entitlements payload.
+ assert.NotContains(t, entitlements.Errors[0], "kaboom")
+ feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
+ assert.Nil(t, feature.Actual)
+ },
+ },
+ {
+ // Forgetting to wire ManagedAgentCountFn is a dev error:
+ // production always provides the closure, so it fails the whole
+ // call loudly instead of degrading into an operator-facing
+ // message.
+ Name: "ManagedAgentLimit/NilFnDevError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ KeepNilManagedAgentCountFn: true,
+ ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
+ },
+ {
+ // A failure while the computation's own context is canceled
+ // aborts the whole call rather than degrading to an
+ // entitlements error.
+ Name: "ManagedAgentLimit/ContextCanceled",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ CancelContext: true,
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, context.Canceled
+ },
+ },
+ ExpectedErrorContains: "get managed agent count",
+ },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1581,13 +1740,18 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil {
+ if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
-
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+ ctx := context.Background()
+ if tc.CancelContext {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithCancel(ctx)
+ cancel()
+ }
+ entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1612,6 +1776,15 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
+ // A Premium license grants a managed agent limit by default, and a nil
+ // usage closure is a hard developer error, so these subtests wire a
+ // zero-usage measurement closure.
+ zeroUsageArgs := license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
+ }
+
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1631,7 +1804,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1658,7 +1831,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1690,7 +1863,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1721,7 +1894,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1734,7 +1907,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -2535,15 +2708,22 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
})
}
-// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the
-// agent runtime hour claims reject the entire license.
-func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
+// TestAgentRuntimeHoursClaimTolerance pins decodeAgentRuntimeHours's
+// tolerate-and-warn contract; see that function's doc for the rationale.
+func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
t.Parallel()
testCases := []struct {
- name string
- features license.Features
- expectedErr error
+ name string
+ features license.Features
+
+ // expectFeature is nil when the feature must be absent.
+ expectFeature *codersdk.Feature
+ // expectClaimsIgnored is true when at least one present claim is
+ // dropped, which must surface the claims-ignored warning: tolerating
+ // a claim and signaling nothing would make an incorrectly issued license
+ // undetectable from the deployment.
+ expectClaimsIgnored bool
}{
{
name: "AllClaims",
@@ -2552,109 +2732,172 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](80),
+ HardLimit: ptr.Ref[int64](120),
+ },
},
{
name: "AllocationOnly",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
},
{
+ // A zero soft limit would warn at zero usage forever, so it is
+ // dropped rather than rejecting the license. The canonical way
+ // to express "no soft limit" is omitting the claim, so a
+ // present-but-dropped zero still warns.
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "HardEqualsAllocation",
+ name: "NegativeSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocation",
+ // A soft limit at or above the allocation could never fire
+ // before the allocation warning supersedes it.
+ name: "SoftEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithZeroHard",
+ name: "SoftAboveAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithPositiveHard",
+ name: "HardEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 100,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ HardLimit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
- name: "SoftWithoutAllocation",
+ name: "HardBelowAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 99,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardWithoutAllocation",
+ name: "ZeroAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitHard: 120,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
- name: "NegativeAllocation",
+ // A zero allocation has no hour budget, so threshold claims
+ // alongside it are dropped, with the warning.
+ name: "ZeroAllocationWithLimits",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "NegativeSoft",
+ name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -1,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftEqualsAllocation",
+ name: "SoftWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftAboveAllocation",
+ name: "HardWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftWithZeroAllocation",
+ // The feature name itself is never a valid claim: the
+ // allocation must come from the dedicated claim. It is the
+ // shape every other metered feature uses, so a license minting
+ // it is the most plausible issuer mistake and must warn
+ // rather than being dropped silently.
+ name: "FeatureNameAsClaim",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitSoft: 0,
+ codersdk.FeatureAgentRuntimeHours: 100,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardBelowAllocation",
+ // The feature name claim is dropped (with the warning) even
+ // when a usable allocation claim grants the feature.
+ name: "FeatureNameAlongsideAllocation",
features: license.Features{
+ codersdk.FeatureAgentRuntimeHours: 50,
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 99,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
}
@@ -2662,46 +2905,115 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
- jwt := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: tc.features,
- })
- _, err := license.ParseClaims(jwt, coderdenttest.Keys)
- if tc.expectedErr == nil {
- require.NoError(t, err)
+ features := license.Features{
+ codersdk.FeatureUserLimit: 100,
+ }
+ maps.Copy(features, tc.features)
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: features,
+ }),
+ }
+
+ var logBuf bytes.Buffer
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
+ )
+ require.NoError(t, err)
+
+ // The license as a whole survives: unrelated paid features are
+ // unaffected by an unusable runtime hour claim.
+ require.Empty(t, entitlements.Errors)
+ require.True(t, entitlements.HasLicense)
+ userLimit := entitlements.Features[codersdk.FeatureUserLimit]
+ require.NotNil(t, userLimit.Limit)
+ require.EqualValues(t, 100, *userLimit.Limit)
+
+ // Dropped claims are tolerated but never silent: the operator
+ // sees the stable warning, and the log names the license and
+ // the dropped claims for support.
+ if tc.expectClaimsIgnored {
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ logs := logBuf.String()
+ require.Contains(t, logs, "ignored unusable Coder Agent runtime hour claims in license")
+ require.Contains(t, logs, lic.UUID.String())
+ } else {
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ require.Empty(t, logBuf.String())
+ }
+
+ // Every known feature name has a default entry in the map, so
+ // "the license does not grant the feature" surfaces as the
+ // default: no limit, no usage period, not enabled.
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ if tc.expectFeature == nil {
+ require.Nil(t, feature.Limit, "feature must not be granted")
+ require.Nil(t, feature.UsagePeriod, "feature must not be granted")
+ require.False(t, feature.Enabled)
return
}
- require.ErrorIs(t, err, tc.expectedErr)
+ require.NotNil(t, feature.UsagePeriod, "feature must be granted")
+ require.Equal(t, tc.expectFeature.Enabled, feature.Enabled)
+ require.Equal(t, tc.expectFeature.Limit, feature.Limit)
+ require.Equal(t, tc.expectFeature.SoftLimit, feature.SoftLimit)
+ require.Equal(t, tc.expectFeature.HardLimit, feature.HardLimit)
})
}
- // An invalid license already stored in the database is rejected entirely
- // and produces an entitlements error.
- t.Run("EntitlementsError", func(t *testing.T) {
+ t.Run("WarningDeduplicatedAcrossLicenses", func(t *testing.T) {
t.Parallel()
- lic := database.License{
- ID: 1,
- UploadedAt: time.Now(),
- Exp: time.Now().Add(time.Hour),
- UUID: uuid.New(),
- JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
- },
- }),
+ // Two licenses with unusable claims must publish the stable warning
+ // once, or the banner would stack identical texts, while the log
+ // names each affected license so the operator can tell which ones
+ // need re-issuing.
+ newLicense := func(id int32) database.License {
+ return database.License{
+ ID: id,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
+ // A threshold without an allocation is unusable.
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ }),
+ }
}
+ licenses := []database.License{newLicense(1), newLicense(2)}
+ var logBuf bytes.Buffer
entitlements, err := license.LicensesEntitlements(
- context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ context.Background(), time.Now(), licenses,
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
)
require.NoError(t, err)
- require.Len(t, entitlements.Errors, 1)
- require.Contains(t, entitlements.Errors[0], fmt.Sprintf("Invalid license (%s) parsing claims", lic.UUID))
- require.False(t, entitlements.HasLicense)
- feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
- require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
+
+ warningCount := 0
+ for _, warning := range entitlements.Warnings {
+ if warning == codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText {
+ warningCount++
+ }
+ }
+ require.Equal(t, 1, warningCount, "the claims-ignored warning must appear exactly once")
+
+ logs := logBuf.String()
+ for _, lic := range licenses {
+ require.Contains(t, logs, lic.UUID.String())
+ }
})
}
diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go
index c88f975f2c0..811929f0939 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -105,35 +106,41 @@ func TestPostLicense(t *testing.T) {
require.Contains(t, errResp.Message, "Invalid license")
})
- t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) {
+ t.Run("UnusableAgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- // A soft limit claim without an allocation claim rejects the whole
- // license.
+ // A soft limit claim without an allocation claim is unusable, but it
+ // never rejects the whole license: the license stays valid, the
+ // runtime hours feature is simply not granted, and the dropped claim
+ // is surfaced as a warning. See decodeAgentRuntimeHours.
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: lic,
})
- errResp := &codersdk.Error{}
- require.ErrorAs(t, err, &errResp)
- require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
- require.Contains(t, errResp.Message, "Invalid license")
+ require.NoError(t, err)
+ // The claims round-trip through GET /api/v2/entitlements.
+ //nolint:gocritic // This test asserts license state, not authz behavior.
+ entitlements, err := client.Entitlements(context.Background())
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+ require.Empty(t, entitlements.Errors)
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.UsagePeriod)
})
t.Run("AgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
- license.ClaimAgentRuntimeHoursLimitHard: 120,
- },
- })
+ coderdenttest.AddLicense(t, client,
+ *(&coderdenttest.LicenseOptions{}).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120)))
// The claims round-trip through GET /api/v2/entitlements.
//nolint:gocritic // This test asserts license state, not authz behavior.
entitlements, err := client.Entitlements(context.Background())
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index cfc4fc94650..802565dbaf7 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5757,6 +5757,17 @@ export const LicenseAIGovernance90PercentWarningText =
export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
+// From codersdk/licenses.go
+/**
+ * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ * license carries unusable Coder Agent runtime hour claims (see
+ * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ * the license and the dropped claims. The dashboard renders the exact
+ * text as a muted diagnostic without a sales link.
+ */
+export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
+ "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
+
// From codersdk/licenses.go
export const LicenseExpiryClaim = "license_expires";
@@ -5764,6 +5775,18 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
+// From codersdk/licenses.go
+/**
+ * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ * managed agent usage query fails while computing entitlements; the
+ * cause is logged server-side. It travels in the entitlements Errors
+ * channel so the alertable coderd_license_errors gauge counts
+ * measurement failures, but the dashboard recognizes the exact text and
+ * renders it as a muted diagnostic rather than a license error.
+ */
+export const LicenseManagedAgentUsageUnavailableErrorText =
+ "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
+
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index ba5f739d93d..9ef4dda8bb1 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -1,8 +1,10 @@
import type { FC } from "react";
import {
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -24,8 +26,32 @@ const isAIGovernanceWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
message.startsWith(aiGovernanceOverLimitWarningPrefix);
-const isAIGovernanceNearLimitWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix);
+// Substitutes the given values into the template's %d placeholders in order.
+// No other fmt verb, width, or flag is implemented.
+const formatLicenseMessage = (template: string, ...values: number[]): string =>
+ values.reduce(
+ (message, value) => message.replace("%d", `${value}`),
+ template,
+ );
+
+// Diagnostics about the license or the usage measurement rather than about
+// usage itself. They render muted, without the exceedance heading or a sales
+// link. The "unavailable" pair arrives via entitlements.errors but must not
+// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+const diagnosticMessages: readonly string[] = [
+ LicenseManagedAgentUsageUnavailableErrorText,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
+];
+
+const isDiagnosticMessage = (message: string): boolean =>
+ diagnosticMessages.includes(message);
+
+// Advisories and diagnostics render in the muted variant: nothing is wrong
+// yet, so they must be visually distinct from warnings that demand action,
+// such as exceeding a license limit.
+const isMutedWarning = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
+ isDiagnosticMessage(message);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -48,9 +74,12 @@ const aiGovernanceOverLimitMessage = (
}
const overLimitSeats = actual - limit;
- return LicenseAIGovernanceOverLimitWarningText.replace("%d", `${actual}`)
- .replace("%d", `${limit}`)
- .replace("%d", `${overLimitSeats}`);
+ return formatLicenseMessage(
+ LicenseAIGovernanceOverLimitWarningText,
+ actual,
+ limit,
+ overLimitSeats,
+ );
};
const aiGovernanceNearLimitMessage = (
@@ -99,7 +128,7 @@ const normalizeAIGovernanceWarning = (
);
};
-const messageLink = (message: string): LicenseBannerLink => {
+const messageLink = (message: string): LicenseBannerLink | undefined => {
if (message === LicenseManagedAgentLimitExceededWarningText) {
return {
href: docs("/ai-coder/ai-governance"),
@@ -115,6 +144,11 @@ const messageLink = (message: string): LicenseBannerLink => {
showExternalIcon: false,
};
}
+ // Diagnostics point the operator at the logs or support, so they do not
+ // get a sales link.
+ if (isDiagnosticMessage(message)) {
+ return undefined;
+ }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -146,12 +180,16 @@ export const LicenseBanner: FC = () => {
const messages: LicenseBannerMessage[] = [
...errors.map((message) => ({
message,
- variant: "error" as const,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message)
+ ? ("warning" as const)
+ : ("error" as const),
link: messageLink(message),
})),
...normalizedWarnings.map((message) => ({
message,
- variant: isAIGovernanceNearLimitWarning(message)
+ variant: isMutedWarning(message)
? ("warning" as const)
: ("warningProminent" as const),
link: messageLink(message),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 582493a4eff..6c3dd4e662b 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import {
+ type Entitlements,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -180,30 +183,24 @@ export const ManagedAgentLimitExceededWithOtherWarnings: Story = {
},
};
-const renderLicenseBannerWithAIGovernance = ({
- actual,
- entitlement = "entitled",
- limit,
+const renderLicenseBanner = ({
+ errors = [],
warnings = [],
+ features = {},
}: {
- actual: number;
- entitlement?: "entitled" | "grace_period" | "not_entitled";
- limit?: number;
+ errors?: string[];
warnings?: string[];
+ features?: Partial;
}) => {
const mockDashboardValue: DashboardValue = {
entitlements: {
...MockEntitlements,
has_license: true,
+ errors,
warnings,
features: {
...MockEntitlements.features,
- ai_governance_user_limit: {
- enabled: true,
- entitlement,
- actual,
- ...(limit !== undefined ? { limit } : {}),
- },
+ ...features,
},
},
experiments: MockExperiments,
@@ -215,12 +212,39 @@ const renderLicenseBannerWithAIGovernance = ({
};
return (
-
+
-
+
);
};
+const renderLicenseBannerWithAIGovernance = ({
+ actual,
+ entitlement = "entitled",
+ limit,
+ warnings = [],
+}: {
+ actual: number;
+ entitlement?: "entitled" | "grace_period" | "not_entitled";
+ limit?: number;
+ warnings?: string[];
+}) =>
+ renderLicenseBanner({
+ warnings,
+ features: {
+ ai_governance_user_limit: {
+ enabled: true,
+ entitlement,
+ actual,
+ ...(limit !== undefined ? { limit } : {}),
+ },
+ },
+ });
+
+// Without the data-variant assertions, every story would keep passing with
+// the muted/prominent classifier disabled.
+const mutedVariant = "warning";
+
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -230,9 +254,13 @@ export const AIGovernanceNearLimit: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByRole("status")).toHaveTextContent(
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
+ // Pins the AI Governance near-limit branch of isMutedWarning,
+ // independently of the runtime soft-limit branch below.
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -267,3 +295,56 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
);
},
};
+
+// Each entry of the frontend's diagnosticMessages set is pinned on both
+// properties the set drives: the muted variant and the suppressed sales
+// link. The "unavailable" message arrives on the errors channel; see the
+// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+const playMutedDiagnostic =
+ (message: string): Story["play"] =>
+ async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(message);
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(
+ canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
+ ).not.toBeInTheDocument();
+ };
+
+export const ManagedAgentUsageUnavailable: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ }),
+ play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
+};
+
+export const AgentRuntimeHoursClaimsIgnored: Story = {
+ render: () =>
+ renderLicenseBanner({
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText),
+};
+
+// An all-diagnostic banner must not claim license limits were exceeded.
+export const UsageDiagnosticsOnlyHeading: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByText("License notices")).toBeInTheDocument();
+ await expect(
+ canvas.queryByText("Your license limits have been exceeded"),
+ ).not.toBeInTheDocument();
+ await expect(
+ canvas.queryByText("License errors require attention"),
+ ).not.toBeInTheDocument();
+ },
+};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 80674fcbb7e..bd278dc591c 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,10 +70,19 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-const bannerTitle = (variant: LicenseBannerVariant): string =>
- variant === "error"
- ? "License errors require attention"
- : "Your license limits have been exceeded";
+// The muted variant only wins when every message is muted (see
+// getBannerVariant), which means advisories and diagnostics: nothing has
+// been exceeded, so the heading must not assert exceedance.
+const bannerTitle = (variant: LicenseBannerVariant): string => {
+ switch (variant) {
+ case "error":
+ return "License errors require attention";
+ case "warningProminent":
+ return "Your license limits have been exceeded";
+ case "warning":
+ return "License notices";
+ }
+};
const bannerRole = (variant: LicenseBannerVariant): "alert" | "status" =>
variant === "error" ? "alert" : "status";
@@ -142,6 +151,9 @@ export const LicenseBannerView: React.FC = ({
return (
From 8ce55e24bd48f880594329efdfae3825676b1436 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 05:47:20 +0000
Subject: [PATCH 079/116] feat(enterprise/coderd/license): decode -1 runtime
hours allocation as unlimited
An agent_runtime_hours_allocation claim of exactly -1 now grants the
agent_runtime_hours feature enabled with a nil Limit, which the
entitlements API serves as an omitted "limit" field, the shape the UI
already renders as Unlimited. Threshold claims alongside it are dropped
with the existing claims-ignored warning, and any other negative
allocation remains unusable.
---
enterprise/coderd/license/license.go | 36 ++++++++-
enterprise/coderd/license/license_test.go | 90 ++++++++++++++++++++++-
2 files changed, 122 insertions(+), 4 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index e6f3598475e..8911f695d3d 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -932,9 +932,10 @@ const (
// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit. A
- // negative allocation is ignored, in which case the license does not
- // grant the feature.
+ // allocation for the license term. It becomes the feature's Limit.
+ // AgentRuntimeHoursUnlimitedAllocation (-1) is reserved to mean
+ // unlimited; any other negative allocation is ignored, in which case
+ // the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
// becomes the feature's SoftLimit when 0 < soft < allocation and is
@@ -946,6 +947,12 @@ const (
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
+// AgentRuntimeHoursUnlimitedAllocation is the reserved
+// ClaimAgentRuntimeHoursAllocation value meaning the license grants
+// unlimited runtime hours. It decodes to an enabled feature with a nil
+// Limit. Mirrored in github.com/coder/license.
+const AgentRuntimeHoursUnlimitedAllocation int64 = -1
+
var (
ValidMethods = []string{"EdDSA"}
@@ -991,6 +998,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// claims, but Actual is still measured and published. CODAGT-856 will make a
// zero allocation force a concurrency-limited mode; that mode does not exist
// yet.
+//
+// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
+// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
+// have nothing to threshold against, so they are dropped with the warning,
+// keeping an incorrectly issued license visible. Note that
+// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
+// issued-at and expiry tie a metered license outranks an unlimited one; ties
+// never happen for separately issued licenses, so this edge is documented
+// rather than special-cased.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
@@ -1000,6 +1016,20 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+ if allocOk && allocation == AgentRuntimeHoursUnlimitedAllocation {
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{
+ Enabled: true,
+ Entitlement: entitlement,
+ UsagePeriod: &usagePeriod,
+ }, true, ignoredClaims
+ }
+
if !allocOk || allocation < 0 {
if allocOk && allocation < 0 {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index db68fb8b60a..0e393939c5d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2501,6 +2501,55 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
require.NotNil(t, feature.UsagePeriod)
})
+ // An unlimited (-1) allocation grants the feature enabled with no Limit,
+ // which the API serves as an omitted "limit" field, the shape the UI
+ // already renders as "Unlimited".
+ t.Run("UnlimitedAllocation", func(t *testing.T) {
+ t.Parallel()
+
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ )
+ require.NoError(t, err)
+ require.Empty(t, entitlements.Errors)
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+
+ // The entitlements JSON served by GET /api/v2/entitlements omits
+ // "limit" entirely for the unlimited feature.
+ data, err := json.Marshal(entitlements)
+ require.NoError(t, err)
+ var raw struct {
+ Features map[codersdk.FeatureName]map[string]any `json:"features"`
+ }
+ require.NoError(t, json.Unmarshal(data, &raw))
+ rawFeature := raw.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, true, rawFeature["enabled"])
+ require.NotContains(t, rawFeature, "limit")
+ require.Contains(t, rawFeature, "usage_period")
+ })
+
// The license with the newest issued-at claim wins, even if another
// license was loaded first or has a larger allocation. The soft and hard
// limits come from the winning license.
@@ -2853,9 +2902,48 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
expectClaimsIgnored: true,
},
{
+ // An unlimited allocation grants the feature with no Limit and
+ // no warning: -1 is the canonical unlimited encoding, not an
+ // issuance mistake.
+ name: "UnlimitedAllocation",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ },
+ {
+ // Threshold claims alongside an unlimited allocation have
+ // nothing to threshold against; the grant survives but the
+ // issuance mistake must stay visible via the warning.
+ name: "UnlimitedWithSoft",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ name: "UnlimitedWithHard",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ // Only exactly -1 is the unlimited sentinel; any other negative
+ // allocation stays unusable.
name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -2,
},
expectClaimsIgnored: true,
},
From 9a9d240bf55199778d1fe752813b12b1d547d4c6 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:07:05 +0000
Subject: [PATCH 080/116] chore: remove data-variant
---
.../LicenseBannerView.stories.tsx | 21 +++++++------------
.../LicenseBanner/LicenseBannerView.tsx | 3 ---
2 files changed, 8 insertions(+), 16 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 6c3dd4e662b..4f2030a3080 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -59,6 +59,10 @@ export const TwoWarnings: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
+ await expect(
+ canvas.getByText("Your license limits have been exceeded"),
+ ).toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: "Show more" }),
).not.toBeInTheDocument();
@@ -241,10 +245,6 @@ const renderLicenseBannerWithAIGovernance = ({
},
});
-// Without the data-variant assertions, every story would keep passing with
-// the muted/prominent classifier disabled.
-const mutedVariant = "warning";
-
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -258,9 +258,6 @@ export const AIGovernanceNearLimit: Story = {
await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
- // Pins the AI Governance near-limit branch of isMutedWarning,
- // independently of the runtime soft-limit branch below.
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -296,17 +293,16 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
},
};
-// Each entry of the frontend's diagnosticMessages set is pinned on both
-// properties the set drives: the muted variant and the suppressed sales
+// Each diagnostic pins role=status (not alert) and a suppressed sales
// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
+// mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
const canvas = within(canvasElement);
const banner = canvas.getByRole("status");
await expect(banner).toHaveTextContent(message);
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
).not.toBeInTheDocument();
@@ -337,8 +333,7 @@ export const UsageDiagnosticsOnlyHeading: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const banner = canvas.getByRole("status");
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(canvas.getByText("License notices")).toBeInTheDocument();
await expect(
canvas.queryByText("Your license limits have been exceeded"),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index bd278dc591c..453b925fe6d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -151,9 +151,6 @@ export const LicenseBannerView: React.FC = ({
return (
From 3fb5f3962338a94bfb7729e0cfed78183daf9af7 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:42:51 +0000
Subject: [PATCH 081/116] chore: remove and simplify explanatory comments
---
codersdk/licenses.go | 23 ++------
.../coderd/coderdenttest/coderdenttest.go | 4 --
enterprise/coderd/license/license.go | 57 +++++++------------
enterprise/coderd/license/license_test.go | 6 +-
site/src/api/typesGenerated.ts | 15 -----
.../dashboard/LicenseBanner/LicenseBanner.tsx | 8 +--
.../LicenseBannerView.stories.tsx | 4 +-
.../LicenseBanner/LicenseBannerView.tsx | 5 +-
8 files changed, 33 insertions(+), 89 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 7aecbaa3089..6f277f17510 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -12,23 +12,12 @@ import (
)
const (
- LicenseExpiryClaim = "license_expires"
- LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
- LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
- LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
- LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- // managed agent usage query fails while computing entitlements; the
- // cause is logged server-side. It travels in the entitlements Errors
- // channel so the alertable coderd_license_errors gauge counts
- // measurement failures, but the dashboard recognizes the exact text and
- // renders it as a muted diagnostic rather than a license error.
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
- // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- // license carries unusable Coder Agent runtime hour claims (see
- // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- // the license and the dropped claims. The dashboard renders the exact
- // text as a muted diagnostic without a sales link.
+ LicenseExpiryClaim = "license_expires"
+ LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
+ LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
+ LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
+ LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 8f5f1e4a708..1d2678fd787 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,10 +237,6 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
-// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
-// or hardLimit omits that claim, which a license is allowed to do; a non-nil
-// value is emitted verbatim, so tests can express explicit zero or negative
-// claims.
func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
if softLimit != nil {
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8911f695d3d..66b55f1feea 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -130,8 +130,6 @@ func Entitlements(
}
type FeatureArguments struct {
- // Logger receives the causes behind operator-facing diagnostics whose
- // published message is a stable text. The zero value discards them.
Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
@@ -587,8 +585,6 @@ func LicensesEntitlements(
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
}
if len(ignoredClaims) > 0 {
- // The published warning is a stable text, so the details a
- // support case needs go to the log.
featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
slog.F("license_id", license.UUID),
slog.F("ignored_claims", ignoredClaims),
@@ -721,9 +717,8 @@ func LicensesEntitlements(
}
if ok {
agentLimit.Actual = &managedAgentCount
- // Written back directly: the feature contest is already
- // settled, so AddFeature's Compare must not get a chance to
- // drop the write.
+ // Write directly rather than via AddFeature so its Compare
+ // cannot drop the update.
entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
@@ -863,12 +858,10 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs one usage query over the feature's usage period and owns
-// the shared failure policy: a nil fn is a wiring bug and fails the whole
-// LicensesEntitlements call; a failure with a dead context fails the call
-// without logging; any other failure logs the cause and publishes the stable
-// unavailableText instead. It returns the measured value and true only on
-// success.
+// measureUsage runs fn over the feature's usage period. A nil fn or a
+// failure with a dead context fails the whole call; any other failure logs
+// the cause and publishes unavailableText instead. It returns the measured
+// value and true only on success.
func measureUsage(
ctx context.Context,
entitlements *codersdk.Entitlements,
@@ -884,12 +877,11 @@ func measureUsage(
value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
switch {
case err != nil && ctx.Err() != nil:
- // The computation's own context is dead, so abort the whole call
- // without logging. Do not classify by error shape instead: Postgres
- // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
- // as well as client cancels, and aborting on those would fail every
- // entitlements refresh on a deployment whose statement_timeout is
- // shorter than a usage query.
+ // Do not classify cancellation by error shape instead of ctx.Err():
+ // Postgres raises SQLSTATE 57014 (query_canceled) for
+ // statement_timeout kills as well as client cancels, and aborting on
+ // those would fail every entitlements refresh on a deployment whose
+ // statement_timeout is shorter than a usage query.
return 0, false, xerrors.Errorf("get %s: %w", what, err)
case err != nil:
logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
@@ -987,26 +979,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// allocation claim; per-claim validity rules live on the Claim* constants
// above.
//
-// Unusable claims are dropped, never license-invalidating: rejecting a
-// signed license over a cosmetic threshold claim would drop the deployment
-// to unlicensed. ignoredClaims names each dropped claim (including the
-// feature name itself minted as a claim, the most plausible issuer mistake)
-// so the caller can warn and log instead of letting an incorrectly issued
-// license look healthy.
+// Unusable claims are dropped rather than invalidating the license, since
+// rejecting a signed license over a cosmetic claim would drop the deployment
+// to unlicensed. Each dropped claim is returned in ignoredClaims so the
+// caller can warn and log instead of letting an incorrectly issued license
+// look healthy.
//
-// A zero allocation grants the feature disabled and drops both threshold
-// claims, but Actual is still measured and published. CODAGT-856 will make a
-// zero allocation force a concurrency-limited mode; that mode does not exist
-// yet.
-//
-// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
-// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
-// have nothing to threshold against, so they are dropped with the warning,
-// keeping an incorrectly issued license visible. Note that
-// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
-// issued-at and expiry tie a metered license outranks an unlimited one; ties
-// never happen for separately issued licenses, so this edge is documented
-// rather than special-cased.
+// A zero allocation grants the feature disabled, but Actual is still
+// measured and published. CODAGT-856 will make a zero allocation force a
+// concurrency-limited mode; that mode does not exist yet.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 0e393939c5d..ec8e06b2f8d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -986,8 +986,7 @@ func TestEntitlements(t *testing.T) {
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
- // The failure surfaces its stable text without the raw cause,
- // on the channel the codersdk constant docs prescribe.
+ // The failure surfaces its stable text without the raw cause.
require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
require.NotContains(t, entry, "kaboom")
@@ -1657,8 +1656,7 @@ func TestLicenseEntitlements(t *testing.T) {
},
},
{
- // A query failure is surfaced as a stable text in Errors (see
- // the codersdk constant docs for the channel choice) and
+ // A query failure is surfaced as a stable text in Errors and
// leaves Actual unset without aborting the rest of the
// entitlements.
Name: "ManagedAgentLimit/QueryError",
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 802565dbaf7..ca43735132e 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5758,13 +5758,6 @@ export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
// From codersdk/licenses.go
-/**
- * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- * license carries unusable Coder Agent runtime hour claims (see
- * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- * the license and the dropped claims. The dashboard renders the exact
- * text as a muted diagnostic without a sales link.
- */
export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
"A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
@@ -5776,14 +5769,6 @@ export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
// From codersdk/licenses.go
-/**
- * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- * managed agent usage query fails while computing entitlements; the
- * cause is logged server-side. It travels in the entitlements Errors
- * channel so the alertable coderd_license_errors gauge counts
- * measurement failures, but the dashboard recognizes the exact text and
- * renders it as a muted diagnostic rather than a license error.
- */
export const LicenseManagedAgentUsageUnavailableErrorText =
"Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 9ef4dda8bb1..74ce21b89fa 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -36,8 +36,7 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// Diagnostics about the license or the usage measurement rather than about
// usage itself. They render muted, without the exceedance heading or a sales
-// link. The "unavailable" pair arrives via entitlements.errors but must not
-// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
@@ -46,9 +45,8 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render in the muted variant: nothing is wrong
-// yet, so they must be visually distinct from warnings that demand action,
-// such as exceeding a license limit.
+// Advisories and diagnostics render muted to stay visually distinct from
+// warnings that demand action, such as exceeding a license limit.
const isMutedWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
isDiagnosticMessage(message);
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 4f2030a3080..7c2e67c9a71 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -294,9 +294,7 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
};
// Each diagnostic pins role=status (not alert) and a suppressed sales
-// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
-// mutedness is covered by the visual snapshot.
+// link. Background mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 453b925fe6d..2cef6164395 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,9 +70,8 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-// The muted variant only wins when every message is muted (see
-// getBannerVariant), which means advisories and diagnostics: nothing has
-// been exceeded, so the heading must not assert exceedance.
+// The muted "warning" variant means every message is an advisory or
+// diagnostic, so the heading must not assert exceedance.
const bannerTitle = (variant: LicenseBannerVariant): string => {
switch (variant) {
case "error":
From ba2f0ae0d4dff45fedf781d5bc543214abac495a Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 09:40:53 +0000
Subject: [PATCH 082/116] fix: make unlimited usage period features outrank
metered ones on ties
A nil Limit on a usage period feature means unlimited, so Feature.Compare now ranks it above any set limit when issued-at and end dates tie. Also types the LicenseBanner map callbacks as LicenseBannerMessage instead of using as const assertions.
---
codersdk/deployment.go | 12 +++-
codersdk/deployment_test.go | 25 ++++++++
enterprise/coderd/license/license_test.go | 60 +++++++++++++++++++
.../dashboard/LicenseBanner/LicenseBanner.tsx | 32 +++++-----
4 files changed, 111 insertions(+), 18 deletions(-)
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index bba55f81bec..8a60e11415f 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -425,7 +425,7 @@ type UsagePeriod struct {
// 2. The usage period has a greater end date (note: only certain features use usage periods)
// 3. Graceful & capable > Entitled & not capable (only if both have "Actual" values)
// 4. The entitlement is greater
-// 5. The limit is greater
+// 5. The limit is greater (except a nil limit on a usage period feature means unlimited, outranking any set limit)
// 6. Enabled is greater than disabled
// 7. The actual is greater
//
@@ -469,11 +469,19 @@ func (f Feature) Compare(b Feature) int {
return entitlementDifference
}
- // If the entitlement is the same, then we can compare the limits.
+ // If the entitlement is the same, then we can compare the limits. A nil
+ // limit on a usage period feature means unlimited, so it outranks any set
+ // limit; on other features a nil limit loses to a set one.
if f.Limit == nil && b.Limit != nil {
+ if bothHaveUsagePeriod {
+ return 1
+ }
return -1
}
if f.Limit != nil && b.Limit == nil {
+ if bothHaveUsagePeriod {
+ return -1
+ }
return 1
}
if f.Limit != nil && b.Limit != nil {
diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go
index de3395aac84..2abdff578e9 100644
--- a/codersdk/deployment_test.go
+++ b/codersdk/deployment_test.go
@@ -1225,6 +1225,31 @@ func TestFeatureComparison(t *testing.T) {
},
Expected: 1,
},
+ {
+ // A nil limit on a usage period feature means unlimited, so it
+ // outranks a set limit on an exact usage period tie.
+ Name: "UnlimitedUsagePeriodOutranksMeteredOnTie",
+ A: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ B: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ Limit: ptr.Ref(int64(100)),
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ Expected: 1,
+ },
}
for _, tc := range testCases {
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index ec8e06b2f8d..68bff19da49 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2616,6 +2616,66 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
}
})
+ // When an unlimited and a metered license are minted with identical
+ // issued-at and expiry claims, the unlimited grant must win the tie,
+ // regardless of load order.
+ t.Run("UnlimitedOutranksMeteredOnTie", func(t *testing.T) {
+ t.Parallel()
+
+ // JWT NumericDate claims have second granularity, so truncate to
+ // keep the round-tripped issued-at values identical.
+ iat := time.Now().Add(-time.Minute).Truncate(time.Second)
+ nbf := iat
+ exp := iat.Add(time.Hour).Truncate(time.Second)
+ unlimited := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+ metered := database.License{
+ ID: 2,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ }),
+ }
+
+ for _, order := range [][]database.License{
+ {unlimited, metered},
+ {metered, unlimited},
+ } {
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
+ require.NoError(t, err)
+
+ feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+ }
+ })
+
// A newer license without soft/hard limits must fully replace an older
// license that carried them; the limits must not merge across licenses.
t.Run("SoftHardRideAlongWithWinner", func(t *testing.T) {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 74ce21b89fa..2218aa5cf35 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -176,22 +176,22 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map((message) => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message)
- ? ("warning" as const)
- : ("error" as const),
- link: messageLink(message),
- })),
- ...normalizedWarnings.map((message) => ({
- message,
- variant: isMutedWarning(message)
- ? ("warning" as const)
- : ("warningProminent" as const),
- link: messageLink(message),
- })),
+ ...errors.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message) ? "warning" : "error",
+ link: messageLink(message),
+ }),
+ ),
+ ...normalizedWarnings.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ variant: isMutedWarning(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ }),
+ ),
];
if (messages.length === 0) {
From 489a6079cc206cfae547eb8c2d985e7071d8bc25 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:10:13 +0000
Subject: [PATCH 083/116] fix(enterprise/coderd/license): accept issuer-valid
zero soft limit for agent runtime hours
The license issuer validates the soft limit as 0 <= soft < allocation,
but this decoder dropped soft=0 as an unusable claim and warned. Accept
zero so every license the issuer mints decodes cleanly; omitting the
claim remains the way to express no soft limit.
---
enterprise/coderd/license/license.go | 4 ++--
enterprise/coderd/license/license_test.go | 13 ++++++-------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 66b55f1feea..f2a6924bf57 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -930,7 +930,7 @@ const (
// the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // becomes the feature's SoftLimit when 0 <= soft < allocation and is
// ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
// ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
@@ -1031,7 +1031,7 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
UsagePeriod: &usagePeriod,
}
if softOk {
- if soft > 0 && soft < allocation {
+ if soft >= 0 && soft < allocation {
feature.SoftLimit = &soft
} else {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 68bff19da49..66c46b3b691 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2857,20 +2857,19 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
},
},
{
- // A zero soft limit would warn at zero usage forever, so it is
- // dropped rather than rejecting the license. The canonical way
- // to express "no soft limit" is omitting the claim, so a
- // present-but-dropped zero still warns.
+ // A zero soft limit is valid (0 <= soft < allocation) and warns
+ // from the start of the usage period. Omitting the claim is the
+ // way to express "no soft limit".
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
expectFeature: &codersdk.Feature{
- Enabled: true,
- Limit: ptr.Ref[int64](100),
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](0),
},
- expectClaimsIgnored: true,
},
{
name: "NegativeSoft",
From daf90f51003110ff3335132ceb0b353829b5dcce Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Thu, 13 Aug 2026 12:20:55 +0000
Subject: [PATCH 084/116] chore: leave managed agent measurement untouched by
license diagnostics
Managed agents are deprecated and slated for removal, so the stable
measurement diagnostics no longer convert that path: the measureUsage
helper, the stable LicenseManagedAgentUsageUnavailableErrorText, the
AsUsagePublisher subject switch, and the direct Features map write are
dropped, restoring the original managed agent count handling. The
runtime hours claim tolerance, the -1 unlimited allocation, and the
muted diagnostics rendering (now seeded only with the claims-ignored
warning) are unchanged.
---
codersdk/licenses.go | 1 -
enterprise/coderd/license/license.go | 62 ++----
enterprise/coderd/license/license_test.go | 182 +-----------------
site/src/api/typesGenerated.ts | 4 -
.../dashboard/LicenseBanner/LicenseBanner.tsx | 2 -
.../LicenseBannerView.stories.tsx | 10 -
6 files changed, 26 insertions(+), 235 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 6f277f17510..414cfbcf04b 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,7 +17,6 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index f2a6924bf57..c4fe1ebcf4e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -115,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Reading usage events requires the usage publisher subject.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -709,17 +709,24 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
- featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
- "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
- if err != nil {
- return entitlements, err
+ var (
+ managedAgentCount int64
+ err = xerrors.New("dev error: managed agent count function is not set")
+ )
+ if featureArguments.ManagedAgentCountFn != nil {
+ managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
+ }
+ if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
+ // If the context is canceled, we want to bail the entire
+ // LicensesEntitlements call.
+ return entitlements, xerrors.Errorf("get managed agent count: %w", err)
}
- if ok {
+ if err != nil {
+ entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
+ // no return
+ } else {
agentLimit.Actual = &managedAgentCount
- // Write directly rather than via AddFeature so its Compare
- // cannot drop the update.
- entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
+ entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -858,39 +865,6 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs fn over the feature's usage period. A nil fn or a
-// failure with a dead context fails the whole call; any other failure logs
-// the cause and publishes unavailableText instead. It returns the measured
-// value and true only on success.
-func measureUsage(
- ctx context.Context,
- entitlements *codersdk.Entitlements,
- logger slog.Logger,
- fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
- usagePeriod codersdk.UsagePeriod,
- what string,
- unavailableText string,
-) (int64, bool, error) {
- if fn == nil {
- return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
- }
- value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
- switch {
- case err != nil && ctx.Err() != nil:
- // Do not classify cancellation by error shape instead of ctx.Err():
- // Postgres raises SQLSTATE 57014 (query_canceled) for
- // statement_timeout kills as well as client cancels, and aborting on
- // those would fail every entitlements refresh on a deployment whose
- // statement_timeout is shorter than a usage query.
- return 0, false, xerrors.Errorf("get %s: %w", what, err)
- case err != nil:
- logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
- entitlements.Errors = append(entitlements.Errors, unavailableText)
- return 0, false, nil
- }
- return value, true, nil
-}
-
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 66c46b3b691..24bd86b326c 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -15,11 +15,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
- "golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
- "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/database/dbtestutil"
@@ -37,42 +35,6 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
-// premiumRuntimeHoursFixture returns a mock store primed with a Premium
-// license carrying runtime hour claims (allocation 100, soft limit 80, hard
-// limit 120) plus the store expectations every entitlements refresh consumes
-// before usage is measured. Callers add expectations for the usage queries
-// under test.
-func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
- t.Helper()
-
- ctrl := gomock.NewController(t)
- mDB := dbmock.NewMockStore(ctrl)
-
- licenseOpts := (&coderdenttest.LicenseOptions{
- FeatureSet: codersdk.FeatureSetPremium,
- IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
- NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
- GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
- ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
- // The addon marks AI Bridge as explicitly entitled, suppressing
- // the unrelated "AI Governance add-on is required to use AI
- // Gateway" warning that Premium would otherwise produce.
- }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
-
- lic := database.License{
- ID: 1,
- JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
- Exp: licenseOpts.ExpiresAt,
- }
-
- mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
- mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
- mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
- mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
-
- return mDB, licenseOpts
-}
-
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -963,62 +925,6 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
- t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
- t.Parallel()
-
- // Drive the real Entitlements closure with a mock database so
- // measureUsage's failure path is exercised end to end: the cause
- // must land in the coderd log, which the stable payload text points
- // at, and must not land on the unauthenticated entitlements payload.
- mDB, _ := premiumRuntimeHoursFixture(t)
-
- mDB.EXPECT().
- GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
- Return(int64(0), xerrors.New("kaboom managed"))
-
- // The error-level logs are the behavior under test, so the default
- // failing test logger cannot be used.
- var logBuf bytes.Buffer
- logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
- AppendSinks(sloghuman.Sink(&logBuf))
-
- entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
- require.NoError(t, err)
- require.True(t, entitlements.HasLicense)
-
- // The failure surfaces its stable text without the raw cause.
- require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
- for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
- require.NotContains(t, entry, "kaboom")
- }
-
- logs := logBuf.String()
- require.Contains(t, logs, "get managed agent count for entitlements")
- require.Contains(t, logs, "kaboom managed")
- })
-
- t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
- t.Parallel()
-
- // A query failing while the refresh's own context is canceled,
- // e.g. during shutdown, aborts the whole entitlements refresh and
- // must not log a false query-failure alarm at error level.
- mDB, _ := premiumRuntimeHoursFixture(t)
-
- mDB.EXPECT().
- GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
- Return(int64(0), context.Canceled)
-
- var logBuf bytes.Buffer
- logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
-
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
- require.ErrorContains(t, err, "get managed agent count")
- require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
- })
-
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1398,12 +1304,6 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
- // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
- // injection below so the nil dev-error path can be exercised.
- KeepNilManagedAgentCountFn bool
- // CancelContext cancels the context passed to LicensesEntitlements
- // before the call, exercising the usage-measurement abort policy.
- CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1655,58 +1555,6 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
- {
- // A query failure is surfaced as a stable text in Errors and
- // leaves Actual unset without aborting the rest of the
- // entitlements.
- Name: "ManagedAgentLimit/QueryError",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- Arguments: license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, xerrors.New("kaboom")
- },
- },
- AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
- assertNoWarnings(t, entitlements)
- require.Len(t, entitlements.Errors, 1)
- assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
- // The raw error is logged rather than exposed on the
- // unauthenticated entitlements payload.
- assert.NotContains(t, entitlements.Errors[0], "kaboom")
- feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
- assert.Nil(t, feature.Actual)
- },
- },
- {
- // Forgetting to wire ManagedAgentCountFn is a dev error:
- // production always provides the closure, so it fails the whole
- // call loudly instead of degrading into an operator-facing
- // message.
- Name: "ManagedAgentLimit/NilFnDevError",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- KeepNilManagedAgentCountFn: true,
- ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
- },
- {
- // A failure while the computation's own context is canceled
- // aborts the whole call rather than degrading to an
- // entitlements error.
- Name: "ManagedAgentLimit/ContextCanceled",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- CancelContext: true,
- Arguments: license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, context.Canceled
- },
- },
- ExpectedErrorContains: "get managed agent count",
- },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1738,18 +1586,13 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
+ if tc.Arguments.ManagedAgentCountFn == nil {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
- ctx := context.Background()
- if tc.CancelContext {
- var cancel context.CancelFunc
- ctx, cancel = context.WithCancel(ctx)
- cancel()
- }
- entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1774,15 +1617,6 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
- // A Premium license grants a managed agent limit by default, and a nil
- // usage closure is a hard developer error, so these subtests wire a
- // zero-usage measurement closure.
- zeroUsageArgs := license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, nil
- },
- }
-
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1802,7 +1636,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1829,7 +1663,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1861,7 +1695,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1892,7 +1726,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1905,7 +1739,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index ca43735132e..177f4c28af8 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5768,10 +5768,6 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
-// From codersdk/licenses.go
-export const LicenseManagedAgentUsageUnavailableErrorText =
- "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
-
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 2218aa5cf35..a6c4cf072ce 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -4,7 +4,6 @@ import {
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -38,7 +37,6 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// usage itself. They render muted, without the exceedance heading or a sales
// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
];
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 7c2e67c9a71..c351a417655 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -5,7 +5,6 @@ import {
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -306,14 +305,6 @@ const playMutedDiagnostic =
).not.toBeInTheDocument();
};
-export const ManagedAgentUsageUnavailable: Story = {
- render: () =>
- renderLicenseBanner({
- errors: [LicenseManagedAgentUsageUnavailableErrorText],
- }),
- play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
-};
-
export const AgentRuntimeHoursClaimsIgnored: Story = {
render: () =>
renderLicenseBanner({
@@ -326,7 +317,6 @@ export const AgentRuntimeHoursClaimsIgnored: Story = {
export const UsageDiagnosticsOnlyHeading: Story = {
render: () =>
renderLicenseBanner({
- errors: [LicenseManagedAgentUsageUnavailableErrorText],
warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
}),
play: async ({ canvasElement }) => {
From ae15084fd9eca05928f9107da9d541344448f2a0 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:52:20 +0000
Subject: [PATCH 085/116] fix(site/src/modules/dashboard/LicenseBanner): render
notices heading for a lone diagnostic
A single claims-ignored diagnostic took the single-message path and
rendered without any heading, contradicting the UsageDiagnosticsOnlyHeading
story, which only the pixel snapshot job exercised in CI.
Classify each entitlements message once in toBannerMessage and carry the
result as a structured kind on LicenseBannerMessage, so the view branches
on data instead of re-matching display text. The view now keeps the
"License notices" heading for a lone diagnostic while lone advisories,
warnings, and errors stay heading-less.
---
.../dashboard/LicenseBanner/LicenseBanner.tsx | 55 ++++++++++---------
.../LicenseBannerView.stories.tsx | 3 +
.../LicenseBanner/LicenseBannerView.tsx | 25 ++++++---
3 files changed, 49 insertions(+), 34 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index a6c4cf072ce..2c33e9fcbf4 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -43,11 +43,10 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render muted to stay visually distinct from
-// warnings that demand action, such as exceeding a license limit.
-const isMutedWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
- isDiagnosticMessage(message);
+// Advisories render muted to stay visually distinct from warnings that
+// demand action, such as exceeding a license limit.
+const isAdvisoryMessage = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -140,11 +139,6 @@ const messageLink = (message: string): LicenseBannerLink | undefined => {
showExternalIcon: false,
};
}
- // Diagnostics point the operator at the logs or support, so they do not
- // get a sales link.
- if (isDiagnosticMessage(message)) {
- return undefined;
- }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -152,6 +146,29 @@ const messageLink = (message: string): LicenseBannerLink | undefined => {
};
};
+// Classifies a raw entitlements message once and carries the result as
+// structured message data, so rendering branches on the message's kind and
+// variant fields rather than re-matching display text.
+const toBannerMessage = (
+ message: string,
+ channel: "errors" | "warnings",
+): LicenseBannerMessage => {
+ // Measurement diagnostics travel in the errors channel but are not
+ // license errors. They render muted and without a sales link: they point
+ // the operator at the logs, not at sales.
+ if (isDiagnosticMessage(message)) {
+ return { message, variant: "warning", kind: "diagnostic" };
+ }
+ if (channel === "errors") {
+ return { message, variant: "error", link: messageLink(message) };
+ }
+ return {
+ message,
+ variant: isAdvisoryMessage(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ };
+};
+
export const LicenseBanner: FC = () => {
const { entitlements } = useDashboard();
const { errors } = entitlements;
@@ -174,21 +191,9 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map(
- (message): LicenseBannerMessage => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message) ? "warning" : "error",
- link: messageLink(message),
- }),
- ),
- ...normalizedWarnings.map(
- (message): LicenseBannerMessage => ({
- message,
- variant: isMutedWarning(message) ? "warning" : "warningProminent",
- link: messageLink(message),
- }),
+ ...errors.map((message) => toBannerMessage(message, "errors")),
+ ...normalizedWarnings.map((message) =>
+ toBannerMessage(message, "warnings"),
),
];
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index c351a417655..94b04f29e5d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -260,6 +260,9 @@ export const AIGovernanceNearLimit: Story = {
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
+ // A lone advisory is muted but not a diagnostic, so it renders
+ // without the notices heading.
+ await expect(canvas.queryByText("License notices")).not.toBeInTheDocument();
},
};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 2cef6164395..8212fe70736 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -25,6 +25,10 @@ export interface LicenseBannerLink {
export interface LicenseBannerMessage {
message: string;
variant: LicenseBannerVariant;
+ // Diagnostics about the license or the usage measurement rather than
+ // about usage itself. They keep the "License notices" heading even when
+ // they are the only message, since the muted text needs that context.
+ kind?: "diagnostic";
link?: LicenseBannerLink;
}
@@ -146,6 +150,9 @@ export const LicenseBannerView: React.FC = ({
const bannerVariant = getBannerVariant(messages);
const visibleMessages = messages.slice(0, 2);
const hiddenMessages = messages.slice(2);
+ // A lone diagnostic keeps the heading: without it the muted banner is an
+ // unexplained sentence. Other single messages stay heading-less.
+ const showHeading = !isSingleMessage || messages[0].kind === "diagnostic";
return (
= ({
/>
+ {showHeading && (
+
+ {bannerTitle(bannerVariant)}
+
+ )}
{isSingleMessage ? (
) : (
- <>
-
- {bannerTitle(bannerVariant)}
-
-
- >
+
)}
From 13b7fd195aa0a23125f926444a63617fa2c07b4f Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:55:40 +0000
Subject: [PATCH 086/116] chore(enterprise/coderd/license): document only
current zero-allocation behavior
Drop the speculative CODAGT-856 concurrency-mode sentence from the
decodeAgentRuntimeHours doc comment. The comment now states only the
implemented contract: a zero allocation grants the feature disabled while
Actual is still measured and published.
---
enterprise/coderd/license/license.go | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index c4fe1ebcf4e..366a853706e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -960,8 +960,7 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// look healthy.
//
// A zero allocation grants the feature disabled, but Actual is still
-// measured and published. CODAGT-856 will make a zero allocation force a
-// concurrency-limited mode; that mode does not exist yet.
+// measured and published.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
From 1cec4c4631dc9d6c0aca01a2787b4442008e85f0 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:37:13 +0000
Subject: [PATCH 087/116] chore(coderd/notifications): sync formatting and
rendered-template goldens with generators
make fmt/go (gci) regroups the imports in smtp_internal_test.go, and
make gen/golden-files re-renders the AI budget notification goldens with
HTML-escaped apostrophes. Both were committed out of sync with the
repo's pinned tools, so every local pre-commit run failed its unstaged
check. No behavior change.
---
coderd/notifications/dispatch/smtp_internal_test.go | 1 +
.../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ----
.../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++--
.../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++--
.../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++--
5 files changed, 7 insertions(+), 10 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 2e7dff8cbec..3557a42a0e3 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -9,6 +9,7 @@ import (
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
+ markdown "github.com/coder/coder/v2/coderd/render"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
index 550fd9b56fd..f64981add03 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
@@ -17,8 +17,6 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
Effective group: Engineering
-This limit is a per-user override.
-
AI budget period: July 1, 2026 - August 1, 2026
@@ -56,8 +54,6 @@ limit ($1000.00). Subsequent requests will be blocked.
Effective group: Engineering
-This limit is a per-user override.
-
AI budget period: July 1, 2026 - August 1, 2026
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
index 37db6f733cc..94d00686923 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+ Codestin Search App
- You've reached your monthly AI budget limit
+ You've reached your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
index 3927ab28e31..4d5ffdf4744 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+
Codestin Search App
- You're approaching your monthly AI budget limit
+ You're approaching your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
index 4315def7665..1523ba12285 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
@@ -24,6 +24,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
- "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
- "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
+ "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
+ "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
}
\ No newline at end of file
From 06c925f097b11d3ed0243267b15bcca048a0587f Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:38:12 +0000
Subject: [PATCH 088/116] fix: enforce uniqueness and hour alignment for agent
runtime usage events
The usage generator writes hb_agent_runtime_v1 rows with created_at at
the UTC hourly bucket start and exactly one row per bucket, but nothing
in the schema enforced either invariant. A duplicate bucket row under a
different id would be double-counted by any consumer summing runtime_ms,
and a misaligned created_at would skew which usage period a bucket is
attributed to.
Replace the non-unique partial index idx_usage_events_agent_runtime with
a unique index of the same shape and add an hour-alignment CHECK
constraint. generateBucket treats a unique violation on the bucket index
as another replica having won the race, mirroring the existing
ON CONFLICT (id) no-op for committed rows.
---
coderd/database/check_constraint.go | 1 +
coderd/database/dump.sql | 5 ++-
...e_events_agent_runtime_invariants.down.sql | 9 ++++
...age_events_agent_runtime_invariants.up.sql | 26 +++++++++++
coderd/database/querier.go | 8 +++-
coderd/database/querier_test.go | 41 +++++++++++++++---
coderd/database/queries.sql.go | 8 +++-
coderd/database/queries/usageevents.sql | 8 +++-
coderd/database/unique_constraint.go | 1 +
enterprise/coderd/usage/generator.go | 38 ++++++++--------
.../coderd/usage/generator_internal_test.go | 43 +++++++++++++++++++
11 files changed, 155 insertions(+), 33 deletions(-)
create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
create mode 100644 coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
create mode 100644 enterprise/coderd/usage/generator_internal_test.go
diff --git a/coderd/database/check_constraint.go b/coderd/database/check_constraint.go
index 0402e4b8ee7..2f791242f5e 100644
--- a/coderd/database/check_constraint.go
+++ b/coderd/database/check_constraint.go
@@ -57,6 +57,7 @@ const (
CheckTelemetryLockEventTypeConstraint CheckConstraint = "telemetry_lock_event_type_constraint" // telemetry_locks
CheckValidationMonotonicOrder CheckConstraint = "validation_monotonic_order" // template_version_parameters
CheckUsageEventTypeCheck CheckConstraint = "usage_event_type_check" // usage_events
+ CheckUsageEventsAgentRuntimeHourAligned CheckConstraint = "usage_events_agent_runtime_hour_aligned" // usage_events
CheckUserAIBudgetOverridesSpendLimitMicrosCheck CheckConstraint = "user_ai_budget_overrides_spend_limit_micros_check" // user_ai_budget_overrides
CheckUserAIProviderKeysAPIKeyCheck CheckConstraint = "user_ai_provider_keys_api_key_check" // user_ai_provider_keys
CheckUserSecretsEnabledRequiresTarget CheckConstraint = "user_secrets_enabled_requires_target" // user_secrets
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index d7595115c90..d70220b91ee 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -3552,7 +3552,8 @@ CREATE TABLE usage_events (
publish_started_at timestamp with time zone,
published_at timestamp with time zone,
failure_message text,
- CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text])))
+ CONSTRAINT usage_event_type_check CHECK ((event_type = ANY (ARRAY['dc_managed_agents_v1'::text, 'hb_ai_seats_v1'::text, 'hb_agent_runtime_v1'::text]))),
+ CONSTRAINT usage_events_agent_runtime_hour_aligned CHECK (((event_type <> 'hb_agent_runtime_v1'::text) OR (date_trunc('hour'::text, timezone('UTC'::text, created_at)) = timezone('UTC'::text, created_at))))
);
COMMENT ON TABLE usage_events IS 'usage_events contains usage data that is collected from the product and potentially shipped to the usage collector service.';
@@ -4899,7 +4900,7 @@ CREATE INDEX idx_template_versions_has_ai_task ON template_versions USING btree
CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
-CREATE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
+CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
CREATE INDEX idx_usage_events_ai_seats ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_ai_seats_v1'::text);
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
new file mode 100644
index 00000000000..ed8b4ae78b8
--- /dev/null
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
@@ -0,0 +1,9 @@
+-- IF EXISTS matches 000561's down migration, so rolling back out of order
+-- (e.g. during an incident) is a no-op instead of a failure.
+DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
+CREATE INDEX idx_usage_events_agent_runtime
+ ON usage_events (event_type, created_at)
+ WHERE event_type = 'hb_agent_runtime_v1';
+
+ALTER TABLE usage_events
+ DROP CONSTRAINT IF EXISTS usage_events_agent_runtime_hour_aligned;
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
new file mode 100644
index 00000000000..33c0c28f1a7
--- /dev/null
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
@@ -0,0 +1,26 @@
+-- The usage generator writes hb_agent_runtime_v1 rows with created_at at
+-- the UTC hourly bucket start and exactly one row per bucket. Uniqueness
+-- keeps any consumer that sums runtime_ms from counting a bucket twice;
+-- the alignment CHECK protects the attribution model, which charges a
+-- bucket to the usage period containing its start.
+--
+-- Both statements validate existing rows. Every supported writer has always
+-- produced conforming data, so a pre-existing violator is anomalous and
+-- failing the migration loudly beats silently rewriting usage rows.
+ALTER TABLE usage_events
+ ADD CONSTRAINT usage_events_agent_runtime_hour_aligned
+ CHECK (
+ event_type <> 'hb_agent_runtime_v1'
+ OR date_trunc('hour', (created_at AT TIME ZONE 'UTC')) = (created_at AT TIME ZONE 'UTC')
+ );
+
+-- Replace the non-unique partial index with a unique one of the same shape,
+-- so reads are served identically. Inserts keep their (id) arbiter:
+-- re-inserting a bucket under its deterministic id stays a silent no-op,
+-- while a duplicate bucket row under a different id raises instead of being
+-- counted twice (generateBucket in enterprise/coderd/usage/generator.go
+-- handles the violation).
+DROP INDEX idx_usage_events_agent_runtime;
+CREATE UNIQUE INDEX idx_usage_events_agent_runtime
+ ON usage_events (event_type, created_at)
+ WHERE event_type = 'hb_agent_runtime_v1';
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index bbc90e02859..b90366b6096 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -1160,8 +1160,12 @@ type sqlcQuerier interface {
InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error
InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error)
InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error)
- // Duplicate events are ignored intentionally to allow for multiple replicas to
- // publish heartbeat events.
+ // Duplicate events are ignored intentionally to allow for multiple replicas
+ // to publish heartbeat events. The (id) arbiter scopes that tolerance to
+ // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+ // bucket under a different id raises on idx_usage_events_agent_runtime
+ // instead, which generateBucket in enterprise/coderd/usage/generator.go
+ // handles.
InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
// InsertUserGroupsByID adds a user to all provided groups, if they exist.
diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go
index 44fc384b619..39d27c4475f 100644
--- a/coderd/database/querier_test.go
+++ b/coderd/database/querier_test.go
@@ -10911,11 +10911,10 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb_agent_runtime_v1:2025-01-02_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 250}`, day2)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
- // Re-inserting a bucket must not double-count it. The daily rollup
- // sums runtime_ms, so idempotency rests on the aggregate trigger
- // being AFTER INSERT: Postgres does not fire it for rows suppressed
- // by ON CONFLICT (id) DO NOTHING. Concurrent replicas and backfill
- // re-runs both take this path.
+ // Re-inserting a bucket under its deterministic id must not
+ // double-count it: the daily rollup's AFTER INSERT trigger does not
+ // fire for rows suppressed by the insert's ON CONFLICT (id)
+ // arbiter.
insert("hb_agent_runtime_v1:2025-01-01_00:00:00", "hb_agent_runtime_v1", `{"runtime_ms": 1000}`, day1)
requireDaily(`{"runtime_ms": 1500}`, `{"runtime_ms": 250}`)
@@ -10923,6 +10922,38 @@ func TestUsageEventsTrigger(t *testing.T) {
insert("hb-seats-1", "hb_ai_seats_v1", `{"count": 3}`, day2)
rows := getDailyRows(ctx, sqlDB)
require.Len(t, rows, 3)
+
+ // The same bucket under a different id is not an idempotent
+ // re-insert but a duplicate that would double any aggregate summing
+ // runtime_ms; the unique partial index
+ // idx_usage_events_agent_runtime rejects it loudly instead of the
+ // (id) arbiter silently dropping it.
+ err := db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "different-id-same-bucket",
+ EventType: "hb_agent_runtime_v1",
+ EventData: []byte(`{"runtime_ms": 9999}`),
+ CreatedAt: day1,
+ })
+ require.True(t, database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime),
+ "expected unique violation on idx_usage_events_agent_runtime, got %v", err)
+ // The rejected row must not have reached the daily rollup either.
+ rows = getDailyRows(ctx, sqlDB)
+ require.Len(t, rows, 3)
+ require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
+
+ // created_at must be the exact UTC hourly bucket start;
+ // usage_events_agent_runtime_hour_aligned rejects a misaligned row
+ // so it cannot skew the period a bucket is attributed to.
+ err = db.InsertUsageEvent(ctx, database.InsertUsageEventParams{
+ ID: "hb_agent_runtime_v1:misaligned",
+ EventType: "hb_agent_runtime_v1",
+ EventData: []byte(`{"runtime_ms": 100}`),
+ CreatedAt: day1.Add(30 * time.Minute),
+ })
+ require.ErrorContains(t, err, string(database.CheckUsageEventsAgentRuntimeHourAligned))
+ rows = getDailyRows(ctx, sqlDB)
+ require.Len(t, rows, 3)
+ require.JSONEq(t, `{"runtime_ms": 1500}`, string(rows[0].UsageData))
})
t.Run("UnknownEventType", func(t *testing.T) {
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index f1029f44a15..d2551a623db 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28708,8 +28708,12 @@ type InsertUsageEventParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
-// Duplicate events are ignored intentionally to allow for multiple replicas to
-// publish heartbeat events.
+// Duplicate events are ignored intentionally to allow for multiple replicas
+// to publish heartbeat events. The (id) arbiter scopes that tolerance to
+// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+// bucket under a different id raises on idx_usage_events_agent_runtime
+// instead, which generateBucket in enterprise/coderd/usage/generator.go
+// handles.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 8ba706b0c8a..03f435c5af7 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -1,6 +1,10 @@
-- name: InsertUsageEvent :exec
--- Duplicate events are ignored intentionally to allow for multiple replicas to
--- publish heartbeat events.
+-- Duplicate events are ignored intentionally to allow for multiple replicas
+-- to publish heartbeat events. The (id) arbiter scopes that tolerance to
+-- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
+-- bucket under a different id raises on idx_usage_events_agent_runtime
+-- instead, which generateBucket in enterprise/coderd/usage/generator.go
+-- handles.
INSERT INTO
usage_events (
id,
diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go
index 4b1a4376f2d..23256ed3b86 100644
--- a/coderd/database/unique_constraint.go
+++ b/coderd/database/unique_constraint.go
@@ -160,6 +160,7 @@ const (
UniqueIndexProvisionerDaemonsOrgNameOwnerKey UniqueConstraint = "idx_provisioner_daemons_org_name_owner_key" // CREATE UNIQUE INDEX idx_provisioner_daemons_org_name_owner_key ON provisioner_daemons USING btree (organization_id, name, lower(COALESCE((tags ->> 'owner'::text), ''::text)));
UniqueIndexTemplateVersionPresetsDefault UniqueConstraint = "idx_template_version_presets_default" // CREATE UNIQUE INDEX idx_template_version_presets_default ON template_version_presets USING btree (template_version_id) WHERE (is_default = true);
UniqueIndexUniquePresetName UniqueConstraint = "idx_unique_preset_name" // CREATE UNIQUE INDEX idx_unique_preset_name ON template_version_presets USING btree (name, template_version_id);
+ UniqueIndexUsageEventsAgentRuntime UniqueConstraint = "idx_usage_events_agent_runtime" // CREATE UNIQUE INDEX idx_usage_events_agent_runtime ON usage_events USING btree (event_type, created_at) WHERE (event_type = 'hb_agent_runtime_v1'::text);
UniqueIndexUsersEmail UniqueConstraint = "idx_users_email" // CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE ((deleted = false) AND (email <> ''::text));
UniqueIndexUsersUsername UniqueConstraint = "idx_users_username" // CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false);
UniqueNotificationMessagesDedupeHashIndex UniqueConstraint = "notification_messages_dedupe_hash_idx" // CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash);
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index 91d911b57a7..a5b6788a214 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -48,8 +48,11 @@ const (
// Cron jobs, which sample live state when they fire, the Generator derives
// events from data already persisted in the database, so it can
// deterministically backfill hours missed while the deployment was down,
-// zero-filling idle hours. Deterministic event IDs plus the database's
-// ON CONFLICT (id) DO NOTHING make concurrent replicas safe without locking.
+// zero-filling idle hours. Deterministic event IDs make concurrent replicas
+// safe without locking: a re-insert of a committed bucket is a no-op via the
+// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted
+// bucket surface a unique violation that generateBucket recognizes as the
+// other replica winning.
//
// Events are generated unconditionally in enterprise builds; the
// publish_usage_data license flag only gates publishing to Tallyman.
@@ -155,24 +158,12 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error {
return xerrors.Errorf("list existing agent runtime events: %w", err)
}
// A row marks its bucket complete regardless of publish outcome, so a
- // bucket whose event Tallyman permanently rejected is never
- // regenerated (re-inserting under the deterministic ID is a no-op via
- // ON CONFLICT (id) DO NOTHING).
- //
- // The runtime is not lost locally: the row still holds it, and the
- // event can be re-queued for publishing with
- //
- // UPDATE usage_events
- // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL
- // WHERE id = 'hb_agent_runtime_v1:';
- //
- // That re-arm only has an effect while the bucket is inside the
- // publisher's 30-day cutoff: SelectUsageEventsForPublishing also
- // filters created_at > now - INTERVAL '30 days', and created_at is the
- // bucket start, so past that the UPDATE reports success but the row is
- // never picked up again. The release gate (Tallyman must accept this
- // event type before coderd ships it) is what keeps permanent
- // rejections exceptional.
+ // bucket whose event Tallyman permanently rejected is never regenerated
+ // (re-inserting under the deterministic ID is a no-op via the insert's
+ // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row
+ // keeps it, and clearing the row's publish columns re-queues it while
+ // the bucket is within SelectUsageEventsForPublishing's 30-day
+ // created_at cutoff.
existing := make(map[time.Time]struct{}, len(existingTimes))
for _, ts := range existingTimes {
// created_at is always the exact bucket start for this event type;
@@ -235,6 +226,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
// time) so daily rollups attribute backfilled hours to the correct day.
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
+ if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
+ // The insert's ON CONFLICT (id) arbiter only sees committed rows, so
+ // a concurrent replica inserting the same bucket can trip the bucket
+ // unique index instead. Either way a row for this bucket already
+ // exists, which is all generateBucket needs.
+ return nil
+ }
if err != nil {
return xerrors.Errorf("insert usage event: %w", err)
}
diff --git a/enterprise/coderd/usage/generator_internal_test.go b/enterprise/coderd/usage/generator_internal_test.go
new file mode 100644
index 00000000000..fa6aeb63440
--- /dev/null
+++ b/enterprise/coderd/usage/generator_internal_test.go
@@ -0,0 +1,43 @@
+package usage
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ "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/testutil"
+ "github.com/coder/quartz"
+)
+
+// TestGenerateBucketUniqueViolation pins that a unique violation on the
+// bucket index resolves the bucket as complete: another writer already
+// recorded it. TestGeneratorConcurrentReplicas also reaches this path, but
+// only when its goroutines actually interleave; this case cannot pass by
+// scheduling accident.
+func TestGenerateBucketUniqueViolation(t *testing.T) {
+ t.Parallel()
+
+ ctx := testutil.Context(t, testutil.WaitShort)
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+ gen := NewGenerator(quartz.NewMock(t), slogtest.Make(t, nil), mDB, NewDBInserter())
+
+ mDB.EXPECT().
+ GetTotalChatMessageRuntimeMsInRange(gomock.Any(), gomock.Any()).
+ Return(int64(1000), nil)
+ mDB.EXPECT().
+ InsertUsageEvent(gomock.Any(), gomock.Any()).
+ Return(&pq.Error{
+ Code: "23505", // unique_violation
+ Constraint: string(database.UniqueIndexUsageEventsAgentRuntime),
+ })
+
+ bucket := time.Date(2025, 3, 10, 10, 0, 0, 0, time.UTC)
+ require.NoError(t, gen.generateBucket(ctx, bucket))
+}
From 5ef08aafa51ccc53e35e55fef71b0ce40344d884 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 12:04:06 +0000
Subject: [PATCH 089/116] revert: "chore(coderd/notifications): sync formatting
and rendered-template goldens with generators"
This reverts commit ed531d9d365720b4e394f4b422ea9776886c56a3.
The gen/fmt drift it patched over came from main commit 07f79af65b2
(fix: markdown rendering improvements) having landed out of sync with
its generators; main has since reverted that commit entirely in
8c2f7adeb1f (#27979), so the sync is obsolete and conflicts with the
regenerated goldens on latest main.
---
coderd/notifications/dispatch/smtp_internal_test.go | 3 ++-
.../smtp/TemplateAIBudgetLimitReachedAdmin.html.golden | 4 ++++
.../smtp/TemplateAIBudgetLimitReachedUser.html.golden | 4 ++--
.../smtp/TemplateAIBudgetWarningUser.html.golden | 4 ++--
.../webhook/TemplateAIBudgetLimitReachedAdmin.json.golden | 4 ++--
5 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 3557a42a0e3..03324f78573 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -7,9 +7,10 @@ import (
"github.com/stretchr/testify/require"
+ markdown "github.com/coder/coder/v2/coderd/render"
+
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
- markdown "github.com/coder/coder/v2/coderd/render"
)
func TestSMTPHTMLTemplateEscapesAppearanceHelpers(t *testing.T) {
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
index f64981add03..550fd9b56fd 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedAdmin.html.golden
@@ -17,6 +17,8 @@ User alice has reached their monthly AI budget limit ($1000.00). Subsequent=
Effective group: Engineering
+This limit is a per-user override.
+
AI budget period: July 1, 2026 - August 1, 2026
@@ -54,6 +56,8 @@ limit ($1000.00). Subsequent requests will be blocked.
Effective group: Engineering
+This limit is a per-user override.
+
AI budget period: July 1, 2026 - August 1, 2026
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
index 94d00686923..37db6f733cc 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetLimitReachedUser.html.golden
@@ -30,7 +30,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+ Codestin Search App
- You've reached your monthly AI budget limit
+ You've reached your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
index 4d5ffdf4744..3927ab28e31 100644
--- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
+++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateAIBudgetWarningUser.html.golden
@@ -29,7 +29,7 @@ Content-Type: text/html; charset=UTF-8
-
Codestin Search App
+
Codestin Search App
- You're approaching your monthly AI budget limit
+ You're approaching your monthly AI budget limit
Hi Bobby,
diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
index 1523ba12285..4315def7665 100644
--- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
+++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateAIBudgetLimitReachedAdmin.json.golden
@@ -24,6 +24,6 @@
},
"title": "alice has reached their monthly AI budget limit",
"title_markdown": "alice has reached their monthly AI budget limit",
- "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nAI budget period: July 1, 2026 - August 1, 2026",
- "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nAI budget period: July 1, 2026 - August 1, 2026"
+ "body": "User alice has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: Engineering\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026",
+ "body_markdown": "User **alice** has reached their monthly AI budget limit ($1000.00). Subsequent requests will be blocked.\n\nEffective group: **Engineering**\n\nThis limit is a per-user override.\n\nAI budget period: July 1, 2026 - August 1, 2026"
}
\ No newline at end of file
From 41e1a2d3eecc05bef3f68aba31826b8827fd22ba Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 13:21:33 +0000
Subject: [PATCH 090/116] updates for PR review
---
.../000565_usage_events_agent_runtime_invariants.down.sql | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
index ed8b4ae78b8..8a6a0240988 100644
--- a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
+++ b/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
@@ -1,5 +1,6 @@
--- IF EXISTS matches 000561's down migration, so rolling back out of order
--- (e.g. during an incident) is a no-op instead of a failure.
+-- IF EXISTS matches 000561's down migration: if the index is already gone
+-- (e.g. rolling back out of order during an incident), tolerate the miss
+-- instead of failing.
DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
CREATE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
From 0c25db9ecd4ee693e86bf5fb22f4f297de353b2d Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:16:52 +0000
Subject: [PATCH 091/116] chore(coderd/database): restore original
InsertUsageEvent comment
The expanded arbiter-scoping commentary is not needed on the insert
query; the duplicate-bucket behavior is documented on the migration and
in generateBucket where it is handled.
---
coderd/database/querier.go | 8 ++------
coderd/database/queries.sql.go | 8 ++------
coderd/database/queries/usageevents.sql | 8 ++------
3 files changed, 6 insertions(+), 18 deletions(-)
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index b90366b6096..bbc90e02859 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -1160,12 +1160,8 @@ type sqlcQuerier interface {
InsertTemplateVersionTerraformValuesByJobID(ctx context.Context, arg InsertTemplateVersionTerraformValuesByJobIDParams) error
InsertTemplateVersionVariable(ctx context.Context, arg InsertTemplateVersionVariableParams) (TemplateVersionVariable, error)
InsertTemplateVersionWorkspaceTag(ctx context.Context, arg InsertTemplateVersionWorkspaceTagParams) (TemplateVersionWorkspaceTag, error)
- // Duplicate events are ignored intentionally to allow for multiple replicas
- // to publish heartbeat events. The (id) arbiter scopes that tolerance to
- // exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
- // bucket under a different id raises on idx_usage_events_agent_runtime
- // instead, which generateBucket in enterprise/coderd/usage/generator.go
- // handles.
+ // Duplicate events are ignored intentionally to allow for multiple replicas to
+ // publish heartbeat events.
InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error
InsertUser(ctx context.Context, arg InsertUserParams) (User, error)
// InsertUserGroupsByID adds a user to all provided groups, if they exist.
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index d2551a623db..f1029f44a15 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28708,12 +28708,8 @@ type InsertUsageEventParams struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
-// Duplicate events are ignored intentionally to allow for multiple replicas
-// to publish heartbeat events. The (id) arbiter scopes that tolerance to
-// exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
-// bucket under a different id raises on idx_usage_events_agent_runtime
-// instead, which generateBucket in enterprise/coderd/usage/generator.go
-// handles.
+// Duplicate events are ignored intentionally to allow for multiple replicas to
+// publish heartbeat events.
func (q *sqlQuerier) InsertUsageEvent(ctx context.Context, arg InsertUsageEventParams) error {
_, err := q.db.ExecContext(ctx, insertUsageEvent,
arg.ID,
diff --git a/coderd/database/queries/usageevents.sql b/coderd/database/queries/usageevents.sql
index 03f435c5af7..8ba706b0c8a 100644
--- a/coderd/database/queries/usageevents.sql
+++ b/coderd/database/queries/usageevents.sql
@@ -1,10 +1,6 @@
-- name: InsertUsageEvent :exec
--- Duplicate events are ignored intentionally to allow for multiple replicas
--- to publish heartbeat events. The (id) arbiter scopes that tolerance to
--- exact re-inserts of the same event: a duplicate hb_agent_runtime_v1
--- bucket under a different id raises on idx_usage_events_agent_runtime
--- instead, which generateBucket in enterprise/coderd/usage/generator.go
--- handles.
+-- Duplicate events are ignored intentionally to allow for multiple replicas to
+-- publish heartbeat events.
INSERT INTO
usage_events (
id,
From 6982b2fe68612b193f12a45ab4ae98be465ad8cd Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:46:36 +0000
Subject: [PATCH 092/116] fix(coderd/database): renumber agent runtime
invariants migration to 000567
Main landed its own 000565 and 000566 (oauth2 client type constraint and
auth method backfill) after this branch was cut, making the migration
number a duplicate that fails migrate setup.
---
....sql => 000567_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.down.sql => 000567_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000565_usage_events_agent_runtime_invariants.up.sql => 000567_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000565_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
From 99ab2350485b5bd483a47dc408788680b4c774c3 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 07:12:28 +0000
Subject: [PATCH 093/116] chore(enterprise/coderd/usage): restore agent runtime
requeue guidance comment
---
enterprise/coderd/usage/generator.go | 24 ++++++++++++++++++------
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index a5b6788a214..af7fcd713ee 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -158,12 +158,24 @@ func (g *Generator) generateAgentRuntimeEvents(ctx context.Context) error {
return xerrors.Errorf("list existing agent runtime events: %w", err)
}
// A row marks its bucket complete regardless of publish outcome, so a
- // bucket whose event Tallyman permanently rejected is never regenerated
- // (re-inserting under the deterministic ID is a no-op via the insert's
- // ON CONFLICT (id) arbiter). The runtime is not lost locally: the row
- // keeps it, and clearing the row's publish columns re-queues it while
- // the bucket is within SelectUsageEventsForPublishing's 30-day
- // created_at cutoff.
+ // bucket whose event Tallyman permanently rejected is never
+ // regenerated (re-inserting under the deterministic ID is a no-op via
+ // the insert's ON CONFLICT (id) arbiter).
+ //
+ // The runtime is not lost locally: the row still holds it, and the
+ // event can be re-queued for publishing with
+ //
+ // UPDATE usage_events
+ // SET published_at = NULL, publish_started_at = NULL, failure_message = NULL
+ // WHERE id = 'hb_agent_runtime_v1:';
+ //
+ // That re-arm only has an effect while the bucket is inside the
+ // publisher's 30-day cutoff: SelectUsageEventsForPublishing also
+ // filters created_at > now - INTERVAL '30 days', and created_at is the
+ // bucket start, so past that the UPDATE reports success but the row is
+ // never picked up again. The release gate (Tallyman must accept this
+ // event type before coderd ships it) is what keeps permanent
+ // rejections exceptional.
existing := make(map[time.Time]struct{}, len(existingTimes))
for _, ts := range existingTimes {
// created_at is always the exact bucket start for this event type;
From 92a01e2d31991925be48eaed6ccee01003e4995e Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:32:48 +0000
Subject: [PATCH 094/116] fix(coderd/database): renumber agent runtime
invariants migration to 000569
Main landed its own 000567 and 000568 (chat file purge indexes and
service account notifications) after this branch was renumbered to
000567, making the migration number a duplicate that fails migrate
setup in the test merge.
---
....sql => 000569_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.down.sql => 000569_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000567_usage_events_agent_runtime_invariants.up.sql => 000569_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000567_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
From fd1be2fba30fca0dbc8c122298078a4c88bc5a70 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:45:24 +0000
Subject: [PATCH 095/116] chore(enterprise/coderd/usage): correct ON CONFLICT
concurrency comments
The (id) arbiter is not limited to committed rows: when a competing
row's arbiter index entry is visible, PostgreSQL waits on that
transaction and takes the DO NOTHING path if it commits. Only the
narrower speculative-insertion race, before the arbiter entry exists,
trips the bucket unique index.
---
enterprise/coderd/usage/generator.go | 22 ++++++++++++++--------
1 file changed, 14 insertions(+), 8 deletions(-)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index af7fcd713ee..cec94a1a511 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -49,10 +49,13 @@ const (
// events from data already persisted in the database, so it can
// deterministically backfill hours missed while the deployment was down,
// zero-filling idle hours. Deterministic event IDs make concurrent replicas
-// safe without locking: a re-insert of a committed bucket is a no-op via the
-// insert's ON CONFLICT (id) arbiter, and two replicas racing an uncommitted
-// bucket surface a unique violation that generateBucket recognizes as the
-// other replica winning.
+// safe without locking: the insert's ON CONFLICT (id) arbiter turns a
+// re-insert of a bucket into a no-op, even when the competing insert is
+// still in flight (once its arbiter index entry is visible, PostgreSQL
+// waits on that transaction and takes the DO NOTHING path if it commits).
+// Only the narrow speculative-insertion race, before the competing row's
+// arbiter entry exists, surfaces a bucket unique violation instead, which
+// generateBucket recognizes as the other replica winning.
//
// Events are generated unconditionally in enterprise builds; the
// publish_usage_data license flag only gates publishing to Tallyman.
@@ -239,10 +242,13 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
- // The insert's ON CONFLICT (id) arbiter only sees committed rows, so
- // a concurrent replica inserting the same bucket can trip the bucket
- // unique index instead. Either way a row for this bucket already
- // exists, which is all generateBucket needs.
+ // The insert's ON CONFLICT (id) arbiter absorbs most duplicate
+ // inserts, including in-flight ones: once a competing row's arbiter
+ // index entry is visible, PostgreSQL waits on that transaction and
+ // takes the DO NOTHING path if it commits. Only the narrow
+ // speculative-insertion race, before that entry exists, trips the
+ // bucket unique index instead. Either way a row for this bucket
+ // already exists, which is all generateBucket needs.
return nil
}
if err != nil {
From 33ff1b730e990b856575b5070b39d1ac55784d3e Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Thu, 13 Aug 2026 04:37:09 +0000
Subject: [PATCH 096/116] fix(coderd/notifications/dispatch): drop unused
markdown import in smtp test
The revert in cb36cfae2c5 left behind an unused
"github.com/coder/coder/v2/coderd/render" import, breaking compilation
of the dispatch test package and failing fmt, lint, and every
test-go-pg job. Restore the import block to match main.
---
coderd/notifications/dispatch/smtp_internal_test.go | 2 --
1 file changed, 2 deletions(-)
diff --git a/coderd/notifications/dispatch/smtp_internal_test.go b/coderd/notifications/dispatch/smtp_internal_test.go
index 03324f78573..2e7dff8cbec 100644
--- a/coderd/notifications/dispatch/smtp_internal_test.go
+++ b/coderd/notifications/dispatch/smtp_internal_test.go
@@ -7,8 +7,6 @@ import (
"github.com/stretchr/testify/require"
- markdown "github.com/coder/coder/v2/coderd/render"
-
"github.com/coder/coder/v2/coderd/notifications/render"
"github.com/coder/coder/v2/coderd/notifications/types"
)
From 92bfe85a2a89f9438262f31e17c2cd59b465f516 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:39:21 +0000
Subject: [PATCH 097/116] chore(coderd): trim historical framing from invariant
comments
Migration comments now state only the current invariant and rollback
reason, and generateBucket defers the full ON CONFLICT concurrency
contract to the Generator doc comment instead of repeating it.
---
...0569_usage_events_agent_runtime_invariants.down.sql | 5 ++---
...000569_usage_events_agent_runtime_invariants.up.sql | 10 ++++------
enterprise/coderd/usage/generator.go | 10 +++-------
3 files changed, 9 insertions(+), 16 deletions(-)
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
index 8a6a0240988..55d3b173185 100644
--- a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
+++ b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
@@ -1,6 +1,5 @@
--- IF EXISTS matches 000561's down migration: if the index is already gone
--- (e.g. rolling back out of order during an incident), tolerate the miss
--- instead of failing.
+-- IF EXISTS tolerates the index already being gone (e.g. rolling back out
+-- of order during an incident) instead of failing.
DROP INDEX IF EXISTS idx_usage_events_agent_runtime;
CREATE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
index 33c0c28f1a7..7577b386f02 100644
--- a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
+++ b/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
@@ -14,12 +14,10 @@ ALTER TABLE usage_events
OR date_trunc('hour', (created_at AT TIME ZONE 'UTC')) = (created_at AT TIME ZONE 'UTC')
);
--- Replace the non-unique partial index with a unique one of the same shape,
--- so reads are served identically. Inserts keep their (id) arbiter:
--- re-inserting a bucket under its deterministic id stays a silent no-op,
--- while a duplicate bucket row under a different id raises instead of being
--- counted twice (generateBucket in enterprise/coderd/usage/generator.go
--- handles the violation).
+-- Inserts keep their (id) arbiter: re-inserting a bucket under its
+-- deterministic id stays a silent no-op, while a duplicate bucket row under
+-- a different id raises a unique violation (generateBucket in
+-- enterprise/coderd/usage/generator.go handles it).
DROP INDEX idx_usage_events_agent_runtime;
CREATE UNIQUE INDEX idx_usage_events_agent_runtime
ON usage_events (event_type, created_at)
diff --git a/enterprise/coderd/usage/generator.go b/enterprise/coderd/usage/generator.go
index cec94a1a511..74e8ae9ac19 100644
--- a/enterprise/coderd/usage/generator.go
+++ b/enterprise/coderd/usage/generator.go
@@ -242,13 +242,9 @@ func (g *Generator) generateBucket(ctx context.Context, bucket time.Time) error
stableID := string(usagetypes.UsageEventTypeHBAgentRuntimeV1) + ":" + bucket.Format(usageEventIDTimeFormat)
err = g.ins.InsertHeartbeatUsageEvent(ctx, g.db, stableID, bucket, usagetypes.HBAgentRuntime{RuntimeMs: runtimeMs})
if database.IsUniqueViolation(err, database.UniqueIndexUsageEventsAgentRuntime) {
- // The insert's ON CONFLICT (id) arbiter absorbs most duplicate
- // inserts, including in-flight ones: once a competing row's arbiter
- // index entry is visible, PostgreSQL waits on that transaction and
- // takes the DO NOTHING path if it commits. Only the narrow
- // speculative-insertion race, before that entry exists, trips the
- // bucket unique index instead. Either way a row for this bucket
- // already exists, which is all generateBucket needs.
+ // Another replica already created this bucket's row. The Generator
+ // doc comment explains why this race reaches the bucket unique
+ // index instead of the insert's ON CONFLICT (id) arbiter.
return nil
}
if err != nil {
From f67ff0120f190d29f5473fd9b316ac1e39a210d9 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 05:16:23 +0000
Subject: [PATCH 098/116] fix(coderd/database): renumber agent runtime
invariants migration to 000570
Main landed 000569_oauth2_scope_columns after the previous renumber to
000569, so the CI test merge contained two migrations with the same
number and every migration-dependent job failed. dump.sql is unchanged
because only the file number moves.
---
....sql => 000570_usage_events_agent_runtime_invariants.down.sql} | 0
...up.sql => 000570_usage_events_agent_runtime_invariants.up.sql} | 0
2 files changed, 0 insertions(+), 0 deletions(-)
rename coderd/database/migrations/{000569_usage_events_agent_runtime_invariants.down.sql => 000570_usage_events_agent_runtime_invariants.down.sql} (100%)
rename coderd/database/migrations/{000569_usage_events_agent_runtime_invariants.up.sql => 000570_usage_events_agent_runtime_invariants.up.sql} (100%)
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql b/coderd/database/migrations/000570_usage_events_agent_runtime_invariants.down.sql
similarity index 100%
rename from coderd/database/migrations/000569_usage_events_agent_runtime_invariants.down.sql
rename to coderd/database/migrations/000570_usage_events_agent_runtime_invariants.down.sql
diff --git a/coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql b/coderd/database/migrations/000570_usage_events_agent_runtime_invariants.up.sql
similarity index 100%
rename from coderd/database/migrations/000569_usage_events_agent_runtime_invariants.up.sql
rename to coderd/database/migrations/000570_usage_events_agent_runtime_invariants.up.sql
From 3790a2d9f6990ab5ec3694654e8da8ab25123784 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:58:22 +0000
Subject: [PATCH 099/116] feat: report stable license measurement diagnostics
and tolerate unusable runtime hours claims
Two coupled changes to the license/entitlements layer.
Measurement diagnostics: extract a measureUsage helper that owns the
usage-query failure policy. A query failure now publishes the stable
codersdk.LicenseManagedAgentUsageUnavailableErrorText on the
entitlements Errors channel (keeping the alertable coderd_license_errors
gauge counting) while the raw cause goes to the coderd log through the
new FeatureArguments.Logger. A nil usage closure becomes a hard
developer error, and a failure with a dead context still aborts the
whole refresh. The managed agent count closure switches from
AsSystemRestricted to the narrower AsUsagePublisher subject. The
dashboard recognizes diagnostic texts and renders them muted, without
the exceedance heading or a sales link.
Claim tolerance: unusable agent_runtime_hours_* claim combinations no
longer reject the whole license. decodeAgentRuntimeHours drops the
unusable claims, reports them in a stable
LicenseAgentRuntimeHoursClaimsIgnoredWarningText warning (deduplicated
across licenses), and logs the affected license and claims;
validateAgentRuntimeHours and its license-invalidating errors are
removed. Rejecting a signed license over a cosmetic threshold claim
would drop the deployment to unlicensed.
Part of the stack for coder/coder#27796.
---
codersdk/licenses.go | 13 +
enterprise/coderd/coderd_test.go | 6 +
.../coderd/coderdenttest/coderdenttest.go | 15 +
enterprise/coderd/license/license.go | 228 +++++----
enterprise/coderd/license/license_test.go | 476 +++++++++++++++---
enterprise/coderd/licenses_test.go | 35 +-
site/src/api/typesGenerated.ts | 23 +
.../dashboard/LicenseBanner/LicenseBanner.tsx | 54 +-
.../LicenseBannerView.stories.tsx | 113 ++++-
.../LicenseBanner/LicenseBannerView.tsx | 20 +-
10 files changed, 762 insertions(+), 221 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index f2ccf009842..7aecbaa3089 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,6 +17,19 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ // managed agent usage query fails while computing entitlements; the
+ // cause is logged server-side. It travels in the entitlements Errors
+ // channel so the alertable coderd_license_errors gauge counts
+ // measurement failures, but the dashboard recognizes the exact text and
+ // renders it as a muted diagnostic rather than a license error.
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
+ // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ // license carries unusable Coder Agent runtime hour claims (see
+ // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ // the license and the dropped claims. The dashboard renders the exact
+ // text as a muted diagnostic without a sales link.
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
type AddLicenseRequest struct {
diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go
index e023da7b1df..7be01405353 100644
--- a/enterprise/coderd/coderd_test.go
+++ b/enterprise/coderd/coderd_test.go
@@ -92,6 +92,12 @@ func TestEntitlements(t *testing.T) {
// Enable all features
features := make(license.Features)
for _, feature := range codersdk.FeatureNames {
+ if feature == codersdk.FeatureAgentRuntimeHours {
+ // The feature name is not a valid license claim; the
+ // feature is encoded as its allocation claim.
+ features[license.ClaimAgentRuntimeHoursAllocation] = 1
+ continue
+ }
features[feature] = 1
}
features[codersdk.FeatureUserLimit] = 100
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 1115ba12118..8f5f1e4a708 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,6 +237,21 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
+// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
+// or hardLimit omits that claim, which a license is allowed to do; a non-nil
+// value is emitted verbatim, so tests can express explicit zero or negative
+// claims.
+func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
+ opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
+ if softLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitSoft, *softLimit)
+ }
+ if hardLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitHard, *hardLimit)
+ }
+ return opts
+}
+
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
if opts.Features == nil {
opts.Features = license.Features{}
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8cce657de65..e6f3598475e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -93,6 +93,7 @@ func Entitlements(
}
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
+ Logger: logger,
ActiveUserCount: activeUserCount,
ActiveAISeatCount: activeAISeatCount,
ReplicaCount: replicaCount,
@@ -114,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Reading usage events requires the usage publisher subject.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -129,6 +130,9 @@ func Entitlements(
}
type FeatureArguments struct {
+ // Logger receives the causes behind operator-facing diagnostics whose
+ // published message is a stable text. The zero value discards them.
+ Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
ReplicaCount int
@@ -508,11 +512,8 @@ func LicensesEntitlements(
continue
}
- // Agent runtime hours are encoded as up to three claims and are
- // decoded together after this loop, see
- // decodeAgentRuntimeHours. The feature name itself is never a
- // valid claim. The allocation must come from the dedicated claim
- // so it is validated against the soft and hard limits.
+ // Agent runtime hour claims are decoded together after this
+ // loop; see decodeAgentRuntimeHours.
if featureName == codersdk.FeatureAgentRuntimeHours ||
isAgentRuntimeHoursClaim(featureName) {
continue
@@ -577,14 +578,25 @@ func LicensesEntitlements(
}
}
- // The loop above skips Agent runtime hours because the
- // three claims that encode them decode into a single feature.
- if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
+ runtimeFeature, granted, ignoredClaims := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
- }); ok {
- entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature)
+ })
+ if granted {
+ entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
+ }
+ if len(ignoredClaims) > 0 {
+ // The published warning is a stable text, so the details a
+ // support case needs go to the log.
+ featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
+ slog.F("license_id", license.UUID),
+ slog.F("ignored_claims", ignoredClaims),
+ )
+ if !slices.Contains(entitlements.Warnings, codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) {
+ entitlements.Warnings = append(entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ }
}
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
@@ -701,24 +713,18 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- var (
- managedAgentCount int64
- err = xerrors.New("dev error: managed agent count function is not set")
- )
- if featureArguments.ManagedAgentCountFn != nil {
- managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
- }
- if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
- // If the context is canceled, we want to bail the entire
- // LicensesEntitlements call.
- return entitlements, xerrors.Errorf("get managed agent count: %w", err)
- }
+ managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
+ featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
+ "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
if err != nil {
- entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
- // no return
- } else {
+ return entitlements, err
+ }
+ if ok {
agentLimit.Actual = &managedAgentCount
- entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
+ // Written back directly: the feature contest is already
+ // settled, so AddFeature's Compare must not get a chance to
+ // drop the write.
+ entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -857,6 +863,42 @@ func LicensesEntitlements(
return entitlements, nil
}
+// measureUsage runs one usage query over the feature's usage period and owns
+// the shared failure policy: a nil fn is a wiring bug and fails the whole
+// LicensesEntitlements call; a failure with a dead context fails the call
+// without logging; any other failure logs the cause and publishes the stable
+// unavailableText instead. It returns the measured value and true only on
+// success.
+func measureUsage(
+ ctx context.Context,
+ entitlements *codersdk.Entitlements,
+ logger slog.Logger,
+ fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
+ usagePeriod codersdk.UsagePeriod,
+ what string,
+ unavailableText string,
+) (int64, bool, error) {
+ if fn == nil {
+ return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
+ }
+ value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
+ switch {
+ case err != nil && ctx.Err() != nil:
+ // The computation's own context is dead, so abort the whole call
+ // without logging. Do not classify by error shape instead: Postgres
+ // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
+ // as well as client cancels, and aborting on those would fail every
+ // entitlements refresh on a deployment whose statement_timeout is
+ // shorter than a usage query.
+ return 0, false, xerrors.Errorf("get %s: %w", what, err)
+ case err != nil:
+ logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
+ entitlements.Errors = append(entitlements.Errors, unavailableText)
+ return 0, false, nil
+ }
+ return value, true, nil
+}
+
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
@@ -885,23 +927,22 @@ const (
VersionClaim = "version"
)
-// Agent runtime hour license claims. These are the canonical claim names
-// minted by github.com/coder/license. All three claims map to the single
-// codersdk.FeatureAgentRuntimeHours feature and are validated together when
-// the license is parsed, see validateClaims.
-//
-// The unit for all three claims is hours.
+// Agent runtime hour license claims, minted by github.com/coder/license.
+// All three are in hours and decode together into the single
+// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit.
+ // allocation for the license term. It becomes the feature's Limit. A
+ // negative allocation is ignored, in which case the license does not
+ // grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // must satisfy 0 <= soft < allocation, so it may only be set when the
- // allocation is greater than 0. It becomes the feature's SoftLimit.
+ // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
- // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be
- // absent or >= allocation, and may only be set when the allocation is
- // greater than 0. It becomes the feature's HardLimit.
+ // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
+ // the feature's HardLimit when the allocation is greater than 0 and
+ // hard >= allocation, and is ignored otherwise.
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
@@ -917,19 +958,12 @@ var (
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
ErrMissingAccountType = xerrors.New("license must contain valid account type")
ErrMissingAccountID = xerrors.New("license must contain valid account ID")
-
- ErrMissingAgentRuntimeHoursAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation)
- ErrAgentRuntimeHoursLimitsWithZeroAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but the %s claim is 0", ClaimAgentRuntimeHoursAllocation)
)
type Features map[codersdk.FeatureName]int64
-// isAgentRuntimeHoursClaim reports whether the claim name is one of the three
-// claims that encode the codersdk.FeatureAgentRuntimeHours feature. These
-// claims are decoded together, see decodeAgentRuntimeHours.
+// isAgentRuntimeHoursClaim reports whether name is one of the three claims
+// decoded by decodeAgentRuntimeHours.
func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
switch name {
case ClaimAgentRuntimeHoursAllocation,
@@ -941,62 +975,65 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
}
}
-// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours feature
-// from the claims that encode it. It reports false when the license carries no
-// allocation claim, in which case the license does not grant the feature.
+// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours
+// feature from its claims. granted is false when there is no usable
+// allocation claim; per-claim validity rules live on the Claim* constants
+// above.
+//
+// Unusable claims are dropped, never license-invalidating: rejecting a
+// signed license over a cosmetic threshold claim would drop the deployment
+// to unlicensed. ignoredClaims names each dropped claim (including the
+// feature name itself minted as a claim, the most plausible issuer mistake)
+// so the caller can warn and log instead of letting an incorrectly issued
+// license look healthy.
//
-// The claim combination is validated when the license is parsed, see
-// Features.validateAgentRuntimeHours. The allocation is never negative here
-// and the soft and hard limits are only present alongside a positive
-// allocation.
-func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (codersdk.Feature, bool) {
- allocation, ok := features[ClaimAgentRuntimeHoursAllocation]
- if !ok {
- return codersdk.Feature{}, false
+// A zero allocation grants the feature disabled and drops both threshold
+// claims, but Actual is still measured and published. CODAGT-856 will make a
+// zero allocation force a concurrency-limited mode; that mode does not exist
+// yet.
+func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
+ if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
+ ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
}
- feature := codersdk.Feature{
+ allocation, allocOk := features[ClaimAgentRuntimeHoursAllocation]
+ soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
+ hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+
+ if !allocOk || allocation < 0 {
+ if allocOk && allocation < 0 {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
+ }
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{}, false, ignoredClaims
+ }
+
+ feature = codersdk.Feature{
Enabled: allocation > 0,
Entitlement: entitlement,
Limit: &allocation,
UsagePeriod: &usagePeriod,
}
- if soft, ok := features[ClaimAgentRuntimeHoursLimitSoft]; ok {
- feature.SoftLimit = &soft
- }
- if hard, ok := features[ClaimAgentRuntimeHoursLimitHard]; ok {
- feature.HardLimit = &hard
- }
- return feature, true
-}
-
-// validateAgentRuntimeHours validates the relationship between the agent
-// runtime hour claims. Invalid combinations reject the entire license.
-func (f Features) validateAgentRuntimeHours() error {
- allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation]
- soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft]
- hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard]
- if !hasAllocation {
- if hasSoft || hasHard {
- return ErrMissingAgentRuntimeHoursAllocation
+ if softOk {
+ if soft > 0 && soft < allocation {
+ feature.SoftLimit = &soft
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
}
- return nil
- }
- if allocation < 0 {
- return ErrInvalidAgentRuntimeHoursAllocation
- }
- // A zero allocation disables the feature.
- // A zero hard limit is not permitted.
- if allocation == 0 && (hasSoft || hasHard) {
- return ErrAgentRuntimeHoursLimitsWithZeroAllocation
}
- if hasSoft && (soft < 0 || soft >= allocation) {
- return ErrInvalidAgentRuntimeHoursSoftLimit
- }
- if hasHard && hard < allocation {
- return ErrInvalidAgentRuntimeHoursHardLimit
+ if hardOk {
+ if allocation > 0 && hard >= allocation {
+ feature.HardLimit = &hard
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
}
- return nil
+ return feature, true, ignoredClaims
}
// Claims is the full set of claims in a license.
@@ -1089,9 +1126,6 @@ func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims.AccountID == "" {
return nil, ErrMissingAccountID
}
- if err := claims.Features.validateAgentRuntimeHours(); err != nil {
- return nil, err
- }
return claims, nil
}
return nil, xerrors.New("unable to parse Claims")
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 6fe50902850..db68fb8b60a 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -1,9 +1,11 @@
package license_test
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "maps"
"slices"
"testing"
"time"
@@ -13,12 +15,17 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
+ "golang.org/x/xerrors"
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "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/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -30,6 +37,42 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
+// premiumRuntimeHoursFixture returns a mock store primed with a Premium
+// license carrying runtime hour claims (allocation 100, soft limit 80, hard
+// limit 120) plus the store expectations every entitlements refresh consumes
+// before usage is measured. Callers add expectations for the usage queries
+// under test.
+func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
+ t.Helper()
+
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+
+ licenseOpts := (&coderdenttest.LicenseOptions{
+ FeatureSet: codersdk.FeatureSetPremium,
+ IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
+ NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
+ GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
+ ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
+ // The addon marks AI Bridge as explicitly entitled, suppressing
+ // the unrelated "AI Governance add-on is required to use AI
+ // Gateway" warning that Premium would otherwise produce.
+ }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
+
+ lic := database.License{
+ ID: 1,
+ JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
+ Exp: licenseOpts.ExpiresAt,
+ }
+
+ mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
+ mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
+ mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
+ mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
+
+ return mDB, licenseOpts
+}
+
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -920,6 +963,63 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
+ t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
+ t.Parallel()
+
+ // Drive the real Entitlements closure with a mock database so
+ // measureUsage's failure path is exercised end to end: the cause
+ // must land in the coderd log, which the stable payload text points
+ // at, and must not land on the unauthenticated entitlements payload.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), xerrors.New("kaboom managed"))
+
+ // The error-level logs are the behavior under test, so the default
+ // failing test logger cannot be used.
+ var logBuf bytes.Buffer
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
+ AppendSinks(sloghuman.Sink(&logBuf))
+
+ entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+
+ // The failure surfaces its stable text without the raw cause,
+ // on the channel the codersdk constant docs prescribe.
+ require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
+ for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
+ require.NotContains(t, entry, "kaboom")
+ }
+
+ logs := logBuf.String()
+ require.Contains(t, logs, "get managed agent count for entitlements")
+ require.Contains(t, logs, "kaboom managed")
+ })
+
+ t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
+ t.Parallel()
+
+ // A query failing while the refresh's own context is canceled,
+ // e.g. during shutdown, aborts the whole entitlements refresh and
+ // must not log a false query-failure alarm at error level.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), context.Canceled)
+
+ var logBuf bytes.Buffer
+ logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.ErrorContains(t, err, "get managed agent count")
+ require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
+ })
+
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1299,6 +1399,12 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
+ // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
+ // injection below so the nil dev-error path can be exercised.
+ KeepNilManagedAgentCountFn bool
+ // CancelContext cancels the context passed to LicensesEntitlements
+ // before the call, exercising the usage-measurement abort policy.
+ CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1550,6 +1656,59 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
+ {
+ // A query failure is surfaced as a stable text in Errors (see
+ // the codersdk constant docs for the channel choice) and
+ // leaves Actual unset without aborting the rest of the
+ // entitlements.
+ Name: "ManagedAgentLimit/QueryError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, xerrors.New("kaboom")
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoWarnings(t, entitlements)
+ require.Len(t, entitlements.Errors, 1)
+ assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
+ // The raw error is logged rather than exposed on the
+ // unauthenticated entitlements payload.
+ assert.NotContains(t, entitlements.Errors[0], "kaboom")
+ feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
+ assert.Nil(t, feature.Actual)
+ },
+ },
+ {
+ // Forgetting to wire ManagedAgentCountFn is a dev error:
+ // production always provides the closure, so it fails the whole
+ // call loudly instead of degrading into an operator-facing
+ // message.
+ Name: "ManagedAgentLimit/NilFnDevError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ KeepNilManagedAgentCountFn: true,
+ ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
+ },
+ {
+ // A failure while the computation's own context is canceled
+ // aborts the whole call rather than degrading to an
+ // entitlements error.
+ Name: "ManagedAgentLimit/ContextCanceled",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ CancelContext: true,
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, context.Canceled
+ },
+ },
+ ExpectedErrorContains: "get managed agent count",
+ },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1581,13 +1740,18 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil {
+ if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
-
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+ ctx := context.Background()
+ if tc.CancelContext {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithCancel(ctx)
+ cancel()
+ }
+ entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1612,6 +1776,15 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
+ // A Premium license grants a managed agent limit by default, and a nil
+ // usage closure is a hard developer error, so these subtests wire a
+ // zero-usage measurement closure.
+ zeroUsageArgs := license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
+ }
+
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1631,7 +1804,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1658,7 +1831,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1690,7 +1863,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1721,7 +1894,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1734,7 +1907,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -2535,15 +2708,22 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
})
}
-// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the
-// agent runtime hour claims reject the entire license.
-func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
+// TestAgentRuntimeHoursClaimTolerance pins decodeAgentRuntimeHours's
+// tolerate-and-warn contract; see that function's doc for the rationale.
+func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
t.Parallel()
testCases := []struct {
- name string
- features license.Features
- expectedErr error
+ name string
+ features license.Features
+
+ // expectFeature is nil when the feature must be absent.
+ expectFeature *codersdk.Feature
+ // expectClaimsIgnored is true when at least one present claim is
+ // dropped, which must surface the claims-ignored warning: tolerating
+ // a claim and signaling nothing would make an incorrectly issued license
+ // undetectable from the deployment.
+ expectClaimsIgnored bool
}{
{
name: "AllClaims",
@@ -2552,109 +2732,172 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](80),
+ HardLimit: ptr.Ref[int64](120),
+ },
},
{
name: "AllocationOnly",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
},
{
+ // A zero soft limit would warn at zero usage forever, so it is
+ // dropped rather than rejecting the license. The canonical way
+ // to express "no soft limit" is omitting the claim, so a
+ // present-but-dropped zero still warns.
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "HardEqualsAllocation",
+ name: "NegativeSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocation",
+ // A soft limit at or above the allocation could never fire
+ // before the allocation warning supersedes it.
+ name: "SoftEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithZeroHard",
+ name: "SoftAboveAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithPositiveHard",
+ name: "HardEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 100,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ HardLimit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
- name: "SoftWithoutAllocation",
+ name: "HardBelowAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 99,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardWithoutAllocation",
+ name: "ZeroAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitHard: 120,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
- name: "NegativeAllocation",
+ // A zero allocation has no hour budget, so threshold claims
+ // alongside it are dropped, with the warning.
+ name: "ZeroAllocationWithLimits",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "NegativeSoft",
+ name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -1,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftEqualsAllocation",
+ name: "SoftWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftAboveAllocation",
+ name: "HardWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftWithZeroAllocation",
+ // The feature name itself is never a valid claim: the
+ // allocation must come from the dedicated claim. It is the
+ // shape every other metered feature uses, so a license minting
+ // it is the most plausible issuer mistake and must warn
+ // rather than being dropped silently.
+ name: "FeatureNameAsClaim",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitSoft: 0,
+ codersdk.FeatureAgentRuntimeHours: 100,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardBelowAllocation",
+ // The feature name claim is dropped (with the warning) even
+ // when a usable allocation claim grants the feature.
+ name: "FeatureNameAlongsideAllocation",
features: license.Features{
+ codersdk.FeatureAgentRuntimeHours: 50,
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 99,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
}
@@ -2662,46 +2905,115 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
- jwt := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: tc.features,
- })
- _, err := license.ParseClaims(jwt, coderdenttest.Keys)
- if tc.expectedErr == nil {
- require.NoError(t, err)
+ features := license.Features{
+ codersdk.FeatureUserLimit: 100,
+ }
+ maps.Copy(features, tc.features)
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: features,
+ }),
+ }
+
+ var logBuf bytes.Buffer
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
+ )
+ require.NoError(t, err)
+
+ // The license as a whole survives: unrelated paid features are
+ // unaffected by an unusable runtime hour claim.
+ require.Empty(t, entitlements.Errors)
+ require.True(t, entitlements.HasLicense)
+ userLimit := entitlements.Features[codersdk.FeatureUserLimit]
+ require.NotNil(t, userLimit.Limit)
+ require.EqualValues(t, 100, *userLimit.Limit)
+
+ // Dropped claims are tolerated but never silent: the operator
+ // sees the stable warning, and the log names the license and
+ // the dropped claims for support.
+ if tc.expectClaimsIgnored {
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ logs := logBuf.String()
+ require.Contains(t, logs, "ignored unusable Coder Agent runtime hour claims in license")
+ require.Contains(t, logs, lic.UUID.String())
+ } else {
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ require.Empty(t, logBuf.String())
+ }
+
+ // Every known feature name has a default entry in the map, so
+ // "the license does not grant the feature" surfaces as the
+ // default: no limit, no usage period, not enabled.
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ if tc.expectFeature == nil {
+ require.Nil(t, feature.Limit, "feature must not be granted")
+ require.Nil(t, feature.UsagePeriod, "feature must not be granted")
+ require.False(t, feature.Enabled)
return
}
- require.ErrorIs(t, err, tc.expectedErr)
+ require.NotNil(t, feature.UsagePeriod, "feature must be granted")
+ require.Equal(t, tc.expectFeature.Enabled, feature.Enabled)
+ require.Equal(t, tc.expectFeature.Limit, feature.Limit)
+ require.Equal(t, tc.expectFeature.SoftLimit, feature.SoftLimit)
+ require.Equal(t, tc.expectFeature.HardLimit, feature.HardLimit)
})
}
- // An invalid license already stored in the database is rejected entirely
- // and produces an entitlements error.
- t.Run("EntitlementsError", func(t *testing.T) {
+ t.Run("WarningDeduplicatedAcrossLicenses", func(t *testing.T) {
t.Parallel()
- lic := database.License{
- ID: 1,
- UploadedAt: time.Now(),
- Exp: time.Now().Add(time.Hour),
- UUID: uuid.New(),
- JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
- },
- }),
+ // Two licenses with unusable claims must publish the stable warning
+ // once, or the banner would stack identical texts, while the log
+ // names each affected license so the operator can tell which ones
+ // need re-issuing.
+ newLicense := func(id int32) database.License {
+ return database.License{
+ ID: id,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
+ // A threshold without an allocation is unusable.
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ }),
+ }
}
+ licenses := []database.License{newLicense(1), newLicense(2)}
+ var logBuf bytes.Buffer
entitlements, err := license.LicensesEntitlements(
- context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ context.Background(), time.Now(), licenses,
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
)
require.NoError(t, err)
- require.Len(t, entitlements.Errors, 1)
- require.Contains(t, entitlements.Errors[0], fmt.Sprintf("Invalid license (%s) parsing claims", lic.UUID))
- require.False(t, entitlements.HasLicense)
- feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
- require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
+
+ warningCount := 0
+ for _, warning := range entitlements.Warnings {
+ if warning == codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText {
+ warningCount++
+ }
+ }
+ require.Equal(t, 1, warningCount, "the claims-ignored warning must appear exactly once")
+
+ logs := logBuf.String()
+ for _, lic := range licenses {
+ require.Contains(t, logs, lic.UUID.String())
+ }
})
}
diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go
index c88f975f2c0..811929f0939 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -105,35 +106,41 @@ func TestPostLicense(t *testing.T) {
require.Contains(t, errResp.Message, "Invalid license")
})
- t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) {
+ t.Run("UnusableAgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- // A soft limit claim without an allocation claim rejects the whole
- // license.
+ // A soft limit claim without an allocation claim is unusable, but it
+ // never rejects the whole license: the license stays valid, the
+ // runtime hours feature is simply not granted, and the dropped claim
+ // is surfaced as a warning. See decodeAgentRuntimeHours.
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: lic,
})
- errResp := &codersdk.Error{}
- require.ErrorAs(t, err, &errResp)
- require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
- require.Contains(t, errResp.Message, "Invalid license")
+ require.NoError(t, err)
+ // The claims round-trip through GET /api/v2/entitlements.
+ //nolint:gocritic // This test asserts license state, not authz behavior.
+ entitlements, err := client.Entitlements(context.Background())
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+ require.Empty(t, entitlements.Errors)
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.UsagePeriod)
})
t.Run("AgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
- license.ClaimAgentRuntimeHoursLimitHard: 120,
- },
- })
+ coderdenttest.AddLicense(t, client,
+ *(&coderdenttest.LicenseOptions{}).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120)))
// The claims round-trip through GET /api/v2/entitlements.
//nolint:gocritic // This test asserts license state, not authz behavior.
entitlements, err := client.Entitlements(context.Background())
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index cfc4fc94650..802565dbaf7 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5757,6 +5757,17 @@ export const LicenseAIGovernance90PercentWarningText =
export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
+// From codersdk/licenses.go
+/**
+ * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ * license carries unusable Coder Agent runtime hour claims (see
+ * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ * the license and the dropped claims. The dashboard renders the exact
+ * text as a muted diagnostic without a sales link.
+ */
+export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
+ "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
+
// From codersdk/licenses.go
export const LicenseExpiryClaim = "license_expires";
@@ -5764,6 +5775,18 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
+// From codersdk/licenses.go
+/**
+ * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ * managed agent usage query fails while computing entitlements; the
+ * cause is logged server-side. It travels in the entitlements Errors
+ * channel so the alertable coderd_license_errors gauge counts
+ * measurement failures, but the dashboard recognizes the exact text and
+ * renders it as a muted diagnostic rather than a license error.
+ */
+export const LicenseManagedAgentUsageUnavailableErrorText =
+ "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
+
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index ba5f739d93d..9ef4dda8bb1 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -1,8 +1,10 @@
import type { FC } from "react";
import {
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -24,8 +26,32 @@ const isAIGovernanceWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
message.startsWith(aiGovernanceOverLimitWarningPrefix);
-const isAIGovernanceNearLimitWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix);
+// Substitutes the given values into the template's %d placeholders in order.
+// No other fmt verb, width, or flag is implemented.
+const formatLicenseMessage = (template: string, ...values: number[]): string =>
+ values.reduce(
+ (message, value) => message.replace("%d", `${value}`),
+ template,
+ );
+
+// Diagnostics about the license or the usage measurement rather than about
+// usage itself. They render muted, without the exceedance heading or a sales
+// link. The "unavailable" pair arrives via entitlements.errors but must not
+// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+const diagnosticMessages: readonly string[] = [
+ LicenseManagedAgentUsageUnavailableErrorText,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
+];
+
+const isDiagnosticMessage = (message: string): boolean =>
+ diagnosticMessages.includes(message);
+
+// Advisories and diagnostics render in the muted variant: nothing is wrong
+// yet, so they must be visually distinct from warnings that demand action,
+// such as exceeding a license limit.
+const isMutedWarning = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
+ isDiagnosticMessage(message);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -48,9 +74,12 @@ const aiGovernanceOverLimitMessage = (
}
const overLimitSeats = actual - limit;
- return LicenseAIGovernanceOverLimitWarningText.replace("%d", `${actual}`)
- .replace("%d", `${limit}`)
- .replace("%d", `${overLimitSeats}`);
+ return formatLicenseMessage(
+ LicenseAIGovernanceOverLimitWarningText,
+ actual,
+ limit,
+ overLimitSeats,
+ );
};
const aiGovernanceNearLimitMessage = (
@@ -99,7 +128,7 @@ const normalizeAIGovernanceWarning = (
);
};
-const messageLink = (message: string): LicenseBannerLink => {
+const messageLink = (message: string): LicenseBannerLink | undefined => {
if (message === LicenseManagedAgentLimitExceededWarningText) {
return {
href: docs("/ai-coder/ai-governance"),
@@ -115,6 +144,11 @@ const messageLink = (message: string): LicenseBannerLink => {
showExternalIcon: false,
};
}
+ // Diagnostics point the operator at the logs or support, so they do not
+ // get a sales link.
+ if (isDiagnosticMessage(message)) {
+ return undefined;
+ }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -146,12 +180,16 @@ export const LicenseBanner: FC = () => {
const messages: LicenseBannerMessage[] = [
...errors.map((message) => ({
message,
- variant: "error" as const,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message)
+ ? ("warning" as const)
+ : ("error" as const),
link: messageLink(message),
})),
...normalizedWarnings.map((message) => ({
message,
- variant: isAIGovernanceNearLimitWarning(message)
+ variant: isMutedWarning(message)
? ("warning" as const)
: ("warningProminent" as const),
link: messageLink(message),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 582493a4eff..6c3dd4e662b 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import {
+ type Entitlements,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -180,30 +183,24 @@ export const ManagedAgentLimitExceededWithOtherWarnings: Story = {
},
};
-const renderLicenseBannerWithAIGovernance = ({
- actual,
- entitlement = "entitled",
- limit,
+const renderLicenseBanner = ({
+ errors = [],
warnings = [],
+ features = {},
}: {
- actual: number;
- entitlement?: "entitled" | "grace_period" | "not_entitled";
- limit?: number;
+ errors?: string[];
warnings?: string[];
+ features?: Partial;
}) => {
const mockDashboardValue: DashboardValue = {
entitlements: {
...MockEntitlements,
has_license: true,
+ errors,
warnings,
features: {
...MockEntitlements.features,
- ai_governance_user_limit: {
- enabled: true,
- entitlement,
- actual,
- ...(limit !== undefined ? { limit } : {}),
- },
+ ...features,
},
},
experiments: MockExperiments,
@@ -215,12 +212,39 @@ const renderLicenseBannerWithAIGovernance = ({
};
return (
-
+
-
+
);
};
+const renderLicenseBannerWithAIGovernance = ({
+ actual,
+ entitlement = "entitled",
+ limit,
+ warnings = [],
+}: {
+ actual: number;
+ entitlement?: "entitled" | "grace_period" | "not_entitled";
+ limit?: number;
+ warnings?: string[];
+}) =>
+ renderLicenseBanner({
+ warnings,
+ features: {
+ ai_governance_user_limit: {
+ enabled: true,
+ entitlement,
+ actual,
+ ...(limit !== undefined ? { limit } : {}),
+ },
+ },
+ });
+
+// Without the data-variant assertions, every story would keep passing with
+// the muted/prominent classifier disabled.
+const mutedVariant = "warning";
+
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -230,9 +254,13 @@ export const AIGovernanceNearLimit: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByRole("status")).toHaveTextContent(
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
+ // Pins the AI Governance near-limit branch of isMutedWarning,
+ // independently of the runtime soft-limit branch below.
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -267,3 +295,56 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
);
},
};
+
+// Each entry of the frontend's diagnosticMessages set is pinned on both
+// properties the set drives: the muted variant and the suppressed sales
+// link. The "unavailable" message arrives on the errors channel; see the
+// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+const playMutedDiagnostic =
+ (message: string): Story["play"] =>
+ async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(message);
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(
+ canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
+ ).not.toBeInTheDocument();
+ };
+
+export const ManagedAgentUsageUnavailable: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ }),
+ play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
+};
+
+export const AgentRuntimeHoursClaimsIgnored: Story = {
+ render: () =>
+ renderLicenseBanner({
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText),
+};
+
+// An all-diagnostic banner must not claim license limits were exceeded.
+export const UsageDiagnosticsOnlyHeading: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByText("License notices")).toBeInTheDocument();
+ await expect(
+ canvas.queryByText("Your license limits have been exceeded"),
+ ).not.toBeInTheDocument();
+ await expect(
+ canvas.queryByText("License errors require attention"),
+ ).not.toBeInTheDocument();
+ },
+};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 80674fcbb7e..bd278dc591c 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,10 +70,19 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-const bannerTitle = (variant: LicenseBannerVariant): string =>
- variant === "error"
- ? "License errors require attention"
- : "Your license limits have been exceeded";
+// The muted variant only wins when every message is muted (see
+// getBannerVariant), which means advisories and diagnostics: nothing has
+// been exceeded, so the heading must not assert exceedance.
+const bannerTitle = (variant: LicenseBannerVariant): string => {
+ switch (variant) {
+ case "error":
+ return "License errors require attention";
+ case "warningProminent":
+ return "Your license limits have been exceeded";
+ case "warning":
+ return "License notices";
+ }
+};
const bannerRole = (variant: LicenseBannerVariant): "alert" | "status" =>
variant === "error" ? "alert" : "status";
@@ -142,6 +151,9 @@ export const LicenseBannerView: React.FC = ({
return (
From fb2c9366eb791a67f15e71f26389b2d378430a78 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 05:47:20 +0000
Subject: [PATCH 100/116] feat(enterprise/coderd/license): decode -1 runtime
hours allocation as unlimited
An agent_runtime_hours_allocation claim of exactly -1 now grants the
agent_runtime_hours feature enabled with a nil Limit, which the
entitlements API serves as an omitted "limit" field, the shape the UI
already renders as Unlimited. Threshold claims alongside it are dropped
with the existing claims-ignored warning, and any other negative
allocation remains unusable.
---
enterprise/coderd/license/license.go | 36 ++++++++-
enterprise/coderd/license/license_test.go | 90 ++++++++++++++++++++++-
2 files changed, 122 insertions(+), 4 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index e6f3598475e..8911f695d3d 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -932,9 +932,10 @@ const (
// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit. A
- // negative allocation is ignored, in which case the license does not
- // grant the feature.
+ // allocation for the license term. It becomes the feature's Limit.
+ // AgentRuntimeHoursUnlimitedAllocation (-1) is reserved to mean
+ // unlimited; any other negative allocation is ignored, in which case
+ // the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
// becomes the feature's SoftLimit when 0 < soft < allocation and is
@@ -946,6 +947,12 @@ const (
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
+// AgentRuntimeHoursUnlimitedAllocation is the reserved
+// ClaimAgentRuntimeHoursAllocation value meaning the license grants
+// unlimited runtime hours. It decodes to an enabled feature with a nil
+// Limit. Mirrored in github.com/coder/license.
+const AgentRuntimeHoursUnlimitedAllocation int64 = -1
+
var (
ValidMethods = []string{"EdDSA"}
@@ -991,6 +998,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// claims, but Actual is still measured and published. CODAGT-856 will make a
// zero allocation force a concurrency-limited mode; that mode does not exist
// yet.
+//
+// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
+// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
+// have nothing to threshold against, so they are dropped with the warning,
+// keeping an incorrectly issued license visible. Note that
+// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
+// issued-at and expiry tie a metered license outranks an unlimited one; ties
+// never happen for separately issued licenses, so this edge is documented
+// rather than special-cased.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
@@ -1000,6 +1016,20 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+ if allocOk && allocation == AgentRuntimeHoursUnlimitedAllocation {
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{
+ Enabled: true,
+ Entitlement: entitlement,
+ UsagePeriod: &usagePeriod,
+ }, true, ignoredClaims
+ }
+
if !allocOk || allocation < 0 {
if allocOk && allocation < 0 {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index db68fb8b60a..0e393939c5d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2501,6 +2501,55 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
require.NotNil(t, feature.UsagePeriod)
})
+ // An unlimited (-1) allocation grants the feature enabled with no Limit,
+ // which the API serves as an omitted "limit" field, the shape the UI
+ // already renders as "Unlimited".
+ t.Run("UnlimitedAllocation", func(t *testing.T) {
+ t.Parallel()
+
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ )
+ require.NoError(t, err)
+ require.Empty(t, entitlements.Errors)
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+
+ // The entitlements JSON served by GET /api/v2/entitlements omits
+ // "limit" entirely for the unlimited feature.
+ data, err := json.Marshal(entitlements)
+ require.NoError(t, err)
+ var raw struct {
+ Features map[codersdk.FeatureName]map[string]any `json:"features"`
+ }
+ require.NoError(t, json.Unmarshal(data, &raw))
+ rawFeature := raw.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, true, rawFeature["enabled"])
+ require.NotContains(t, rawFeature, "limit")
+ require.Contains(t, rawFeature, "usage_period")
+ })
+
// The license with the newest issued-at claim wins, even if another
// license was loaded first or has a larger allocation. The soft and hard
// limits come from the winning license.
@@ -2853,9 +2902,48 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
expectClaimsIgnored: true,
},
{
+ // An unlimited allocation grants the feature with no Limit and
+ // no warning: -1 is the canonical unlimited encoding, not an
+ // issuance mistake.
+ name: "UnlimitedAllocation",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ },
+ {
+ // Threshold claims alongside an unlimited allocation have
+ // nothing to threshold against; the grant survives but the
+ // issuance mistake must stay visible via the warning.
+ name: "UnlimitedWithSoft",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ name: "UnlimitedWithHard",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ // Only exactly -1 is the unlimited sentinel; any other negative
+ // allocation stays unusable.
name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -2,
},
expectClaimsIgnored: true,
},
From 01bdfd077e5d563469d22cf483ddb3d15c232dd6 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:07:05 +0000
Subject: [PATCH 101/116] chore: remove data-variant
---
.../LicenseBannerView.stories.tsx | 21 +++++++------------
.../LicenseBanner/LicenseBannerView.tsx | 3 ---
2 files changed, 8 insertions(+), 16 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 6c3dd4e662b..4f2030a3080 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -59,6 +59,10 @@ export const TwoWarnings: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
+ await expect(
+ canvas.getByText("Your license limits have been exceeded"),
+ ).toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: "Show more" }),
).not.toBeInTheDocument();
@@ -241,10 +245,6 @@ const renderLicenseBannerWithAIGovernance = ({
},
});
-// Without the data-variant assertions, every story would keep passing with
-// the muted/prominent classifier disabled.
-const mutedVariant = "warning";
-
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -258,9 +258,6 @@ export const AIGovernanceNearLimit: Story = {
await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
- // Pins the AI Governance near-limit branch of isMutedWarning,
- // independently of the runtime soft-limit branch below.
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -296,17 +293,16 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
},
};
-// Each entry of the frontend's diagnosticMessages set is pinned on both
-// properties the set drives: the muted variant and the suppressed sales
+// Each diagnostic pins role=status (not alert) and a suppressed sales
// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
+// mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
const canvas = within(canvasElement);
const banner = canvas.getByRole("status");
await expect(banner).toHaveTextContent(message);
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
).not.toBeInTheDocument();
@@ -337,8 +333,7 @@ export const UsageDiagnosticsOnlyHeading: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const banner = canvas.getByRole("status");
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(canvas.getByText("License notices")).toBeInTheDocument();
await expect(
canvas.queryByText("Your license limits have been exceeded"),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index bd278dc591c..453b925fe6d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -151,9 +151,6 @@ export const LicenseBannerView: React.FC = ({
return (
From 40917af059a0b0a126161ea579aedd5b0611c4ca Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:42:51 +0000
Subject: [PATCH 102/116] chore: remove and simplify explanatory comments
---
codersdk/licenses.go | 23 ++------
.../coderd/coderdenttest/coderdenttest.go | 4 --
enterprise/coderd/license/license.go | 57 +++++++------------
enterprise/coderd/license/license_test.go | 6 +-
site/src/api/typesGenerated.ts | 15 -----
.../dashboard/LicenseBanner/LicenseBanner.tsx | 8 +--
.../LicenseBannerView.stories.tsx | 4 +-
.../LicenseBanner/LicenseBannerView.tsx | 5 +-
8 files changed, 33 insertions(+), 89 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 7aecbaa3089..6f277f17510 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -12,23 +12,12 @@ import (
)
const (
- LicenseExpiryClaim = "license_expires"
- LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
- LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
- LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
- LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- // managed agent usage query fails while computing entitlements; the
- // cause is logged server-side. It travels in the entitlements Errors
- // channel so the alertable coderd_license_errors gauge counts
- // measurement failures, but the dashboard recognizes the exact text and
- // renders it as a muted diagnostic rather than a license error.
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
- // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- // license carries unusable Coder Agent runtime hour claims (see
- // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- // the license and the dropped claims. The dashboard renders the exact
- // text as a muted diagnostic without a sales link.
+ LicenseExpiryClaim = "license_expires"
+ LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
+ LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
+ LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
+ LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 8f5f1e4a708..1d2678fd787 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,10 +237,6 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
-// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
-// or hardLimit omits that claim, which a license is allowed to do; a non-nil
-// value is emitted verbatim, so tests can express explicit zero or negative
-// claims.
func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
if softLimit != nil {
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8911f695d3d..66b55f1feea 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -130,8 +130,6 @@ func Entitlements(
}
type FeatureArguments struct {
- // Logger receives the causes behind operator-facing diagnostics whose
- // published message is a stable text. The zero value discards them.
Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
@@ -587,8 +585,6 @@ func LicensesEntitlements(
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
}
if len(ignoredClaims) > 0 {
- // The published warning is a stable text, so the details a
- // support case needs go to the log.
featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
slog.F("license_id", license.UUID),
slog.F("ignored_claims", ignoredClaims),
@@ -721,9 +717,8 @@ func LicensesEntitlements(
}
if ok {
agentLimit.Actual = &managedAgentCount
- // Written back directly: the feature contest is already
- // settled, so AddFeature's Compare must not get a chance to
- // drop the write.
+ // Write directly rather than via AddFeature so its Compare
+ // cannot drop the update.
entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
@@ -863,12 +858,10 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs one usage query over the feature's usage period and owns
-// the shared failure policy: a nil fn is a wiring bug and fails the whole
-// LicensesEntitlements call; a failure with a dead context fails the call
-// without logging; any other failure logs the cause and publishes the stable
-// unavailableText instead. It returns the measured value and true only on
-// success.
+// measureUsage runs fn over the feature's usage period. A nil fn or a
+// failure with a dead context fails the whole call; any other failure logs
+// the cause and publishes unavailableText instead. It returns the measured
+// value and true only on success.
func measureUsage(
ctx context.Context,
entitlements *codersdk.Entitlements,
@@ -884,12 +877,11 @@ func measureUsage(
value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
switch {
case err != nil && ctx.Err() != nil:
- // The computation's own context is dead, so abort the whole call
- // without logging. Do not classify by error shape instead: Postgres
- // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
- // as well as client cancels, and aborting on those would fail every
- // entitlements refresh on a deployment whose statement_timeout is
- // shorter than a usage query.
+ // Do not classify cancellation by error shape instead of ctx.Err():
+ // Postgres raises SQLSTATE 57014 (query_canceled) for
+ // statement_timeout kills as well as client cancels, and aborting on
+ // those would fail every entitlements refresh on a deployment whose
+ // statement_timeout is shorter than a usage query.
return 0, false, xerrors.Errorf("get %s: %w", what, err)
case err != nil:
logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
@@ -987,26 +979,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// allocation claim; per-claim validity rules live on the Claim* constants
// above.
//
-// Unusable claims are dropped, never license-invalidating: rejecting a
-// signed license over a cosmetic threshold claim would drop the deployment
-// to unlicensed. ignoredClaims names each dropped claim (including the
-// feature name itself minted as a claim, the most plausible issuer mistake)
-// so the caller can warn and log instead of letting an incorrectly issued
-// license look healthy.
+// Unusable claims are dropped rather than invalidating the license, since
+// rejecting a signed license over a cosmetic claim would drop the deployment
+// to unlicensed. Each dropped claim is returned in ignoredClaims so the
+// caller can warn and log instead of letting an incorrectly issued license
+// look healthy.
//
-// A zero allocation grants the feature disabled and drops both threshold
-// claims, but Actual is still measured and published. CODAGT-856 will make a
-// zero allocation force a concurrency-limited mode; that mode does not exist
-// yet.
-//
-// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
-// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
-// have nothing to threshold against, so they are dropped with the warning,
-// keeping an incorrectly issued license visible. Note that
-// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
-// issued-at and expiry tie a metered license outranks an unlimited one; ties
-// never happen for separately issued licenses, so this edge is documented
-// rather than special-cased.
+// A zero allocation grants the feature disabled, but Actual is still
+// measured and published. CODAGT-856 will make a zero allocation force a
+// concurrency-limited mode; that mode does not exist yet.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 0e393939c5d..ec8e06b2f8d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -986,8 +986,7 @@ func TestEntitlements(t *testing.T) {
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
- // The failure surfaces its stable text without the raw cause,
- // on the channel the codersdk constant docs prescribe.
+ // The failure surfaces its stable text without the raw cause.
require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
require.NotContains(t, entry, "kaboom")
@@ -1657,8 +1656,7 @@ func TestLicenseEntitlements(t *testing.T) {
},
},
{
- // A query failure is surfaced as a stable text in Errors (see
- // the codersdk constant docs for the channel choice) and
+ // A query failure is surfaced as a stable text in Errors and
// leaves Actual unset without aborting the rest of the
// entitlements.
Name: "ManagedAgentLimit/QueryError",
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 802565dbaf7..ca43735132e 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5758,13 +5758,6 @@ export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
// From codersdk/licenses.go
-/**
- * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- * license carries unusable Coder Agent runtime hour claims (see
- * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- * the license and the dropped claims. The dashboard renders the exact
- * text as a muted diagnostic without a sales link.
- */
export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
"A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
@@ -5776,14 +5769,6 @@ export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
// From codersdk/licenses.go
-/**
- * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- * managed agent usage query fails while computing entitlements; the
- * cause is logged server-side. It travels in the entitlements Errors
- * channel so the alertable coderd_license_errors gauge counts
- * measurement failures, but the dashboard recognizes the exact text and
- * renders it as a muted diagnostic rather than a license error.
- */
export const LicenseManagedAgentUsageUnavailableErrorText =
"Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 9ef4dda8bb1..74ce21b89fa 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -36,8 +36,7 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// Diagnostics about the license or the usage measurement rather than about
// usage itself. They render muted, without the exceedance heading or a sales
-// link. The "unavailable" pair arrives via entitlements.errors but must not
-// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
@@ -46,9 +45,8 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render in the muted variant: nothing is wrong
-// yet, so they must be visually distinct from warnings that demand action,
-// such as exceeding a license limit.
+// Advisories and diagnostics render muted to stay visually distinct from
+// warnings that demand action, such as exceeding a license limit.
const isMutedWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
isDiagnosticMessage(message);
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 4f2030a3080..7c2e67c9a71 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -294,9 +294,7 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
};
// Each diagnostic pins role=status (not alert) and a suppressed sales
-// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
-// mutedness is covered by the visual snapshot.
+// link. Background mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 453b925fe6d..2cef6164395 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,9 +70,8 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-// The muted variant only wins when every message is muted (see
-// getBannerVariant), which means advisories and diagnostics: nothing has
-// been exceeded, so the heading must not assert exceedance.
+// The muted "warning" variant means every message is an advisory or
+// diagnostic, so the heading must not assert exceedance.
const bannerTitle = (variant: LicenseBannerVariant): string => {
switch (variant) {
case "error":
From 18f5e7f2f6c1d844596106bb5875a0177c1011da Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 09:40:53 +0000
Subject: [PATCH 103/116] fix: make unlimited usage period features outrank
metered ones on ties
A nil Limit on a usage period feature means unlimited, so Feature.Compare now ranks it above any set limit when issued-at and end dates tie. Also types the LicenseBanner map callbacks as LicenseBannerMessage instead of using as const assertions.
---
codersdk/deployment.go | 12 +++-
codersdk/deployment_test.go | 25 ++++++++
enterprise/coderd/license/license_test.go | 60 +++++++++++++++++++
.../dashboard/LicenseBanner/LicenseBanner.tsx | 32 +++++-----
4 files changed, 111 insertions(+), 18 deletions(-)
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index bba55f81bec..8a60e11415f 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -425,7 +425,7 @@ type UsagePeriod struct {
// 2. The usage period has a greater end date (note: only certain features use usage periods)
// 3. Graceful & capable > Entitled & not capable (only if both have "Actual" values)
// 4. The entitlement is greater
-// 5. The limit is greater
+// 5. The limit is greater (except a nil limit on a usage period feature means unlimited, outranking any set limit)
// 6. Enabled is greater than disabled
// 7. The actual is greater
//
@@ -469,11 +469,19 @@ func (f Feature) Compare(b Feature) int {
return entitlementDifference
}
- // If the entitlement is the same, then we can compare the limits.
+ // If the entitlement is the same, then we can compare the limits. A nil
+ // limit on a usage period feature means unlimited, so it outranks any set
+ // limit; on other features a nil limit loses to a set one.
if f.Limit == nil && b.Limit != nil {
+ if bothHaveUsagePeriod {
+ return 1
+ }
return -1
}
if f.Limit != nil && b.Limit == nil {
+ if bothHaveUsagePeriod {
+ return -1
+ }
return 1
}
if f.Limit != nil && b.Limit != nil {
diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go
index de3395aac84..2abdff578e9 100644
--- a/codersdk/deployment_test.go
+++ b/codersdk/deployment_test.go
@@ -1225,6 +1225,31 @@ func TestFeatureComparison(t *testing.T) {
},
Expected: 1,
},
+ {
+ // A nil limit on a usage period feature means unlimited, so it
+ // outranks a set limit on an exact usage period tie.
+ Name: "UnlimitedUsagePeriodOutranksMeteredOnTie",
+ A: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ B: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ Limit: ptr.Ref(int64(100)),
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ Expected: 1,
+ },
}
for _, tc := range testCases {
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index ec8e06b2f8d..68bff19da49 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2616,6 +2616,66 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
}
})
+ // When an unlimited and a metered license are minted with identical
+ // issued-at and expiry claims, the unlimited grant must win the tie,
+ // regardless of load order.
+ t.Run("UnlimitedOutranksMeteredOnTie", func(t *testing.T) {
+ t.Parallel()
+
+ // JWT NumericDate claims have second granularity, so truncate to
+ // keep the round-tripped issued-at values identical.
+ iat := time.Now().Add(-time.Minute).Truncate(time.Second)
+ nbf := iat
+ exp := iat.Add(time.Hour).Truncate(time.Second)
+ unlimited := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+ metered := database.License{
+ ID: 2,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ }),
+ }
+
+ for _, order := range [][]database.License{
+ {unlimited, metered},
+ {metered, unlimited},
+ } {
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
+ require.NoError(t, err)
+
+ feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+ }
+ })
+
// A newer license without soft/hard limits must fully replace an older
// license that carried them; the limits must not merge across licenses.
t.Run("SoftHardRideAlongWithWinner", func(t *testing.T) {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 74ce21b89fa..2218aa5cf35 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -176,22 +176,22 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map((message) => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message)
- ? ("warning" as const)
- : ("error" as const),
- link: messageLink(message),
- })),
- ...normalizedWarnings.map((message) => ({
- message,
- variant: isMutedWarning(message)
- ? ("warning" as const)
- : ("warningProminent" as const),
- link: messageLink(message),
- })),
+ ...errors.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message) ? "warning" : "error",
+ link: messageLink(message),
+ }),
+ ),
+ ...normalizedWarnings.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ variant: isMutedWarning(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ }),
+ ),
];
if (messages.length === 0) {
From c1300ddeec9c630447c0403d1129691b1b1a0c9c Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:10:13 +0000
Subject: [PATCH 104/116] fix(enterprise/coderd/license): accept issuer-valid
zero soft limit for agent runtime hours
The license issuer validates the soft limit as 0 <= soft < allocation,
but this decoder dropped soft=0 as an unusable claim and warned. Accept
zero so every license the issuer mints decodes cleanly; omitting the
claim remains the way to express no soft limit.
---
enterprise/coderd/license/license.go | 4 ++--
enterprise/coderd/license/license_test.go | 13 ++++++-------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 66b55f1feea..f2a6924bf57 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -930,7 +930,7 @@ const (
// the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // becomes the feature's SoftLimit when 0 <= soft < allocation and is
// ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
// ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
@@ -1031,7 +1031,7 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
UsagePeriod: &usagePeriod,
}
if softOk {
- if soft > 0 && soft < allocation {
+ if soft >= 0 && soft < allocation {
feature.SoftLimit = &soft
} else {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 68bff19da49..66c46b3b691 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2857,20 +2857,19 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
},
},
{
- // A zero soft limit would warn at zero usage forever, so it is
- // dropped rather than rejecting the license. The canonical way
- // to express "no soft limit" is omitting the claim, so a
- // present-but-dropped zero still warns.
+ // A zero soft limit is valid (0 <= soft < allocation) and warns
+ // from the start of the usage period. Omitting the claim is the
+ // way to express "no soft limit".
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
expectFeature: &codersdk.Feature{
- Enabled: true,
- Limit: ptr.Ref[int64](100),
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](0),
},
- expectClaimsIgnored: true,
},
{
name: "NegativeSoft",
From 379db5cbb603f4aec61bc266230e0292d6b6038f Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Thu, 13 Aug 2026 12:20:55 +0000
Subject: [PATCH 105/116] chore: leave managed agent measurement untouched by
license diagnostics
Managed agents are deprecated and slated for removal, so the stable
measurement diagnostics no longer convert that path: the measureUsage
helper, the stable LicenseManagedAgentUsageUnavailableErrorText, the
AsUsagePublisher subject switch, and the direct Features map write are
dropped, restoring the original managed agent count handling. The
runtime hours claim tolerance, the -1 unlimited allocation, and the
muted diagnostics rendering (now seeded only with the claims-ignored
warning) are unchanged.
---
codersdk/licenses.go | 1 -
enterprise/coderd/license/license.go | 62 ++----
enterprise/coderd/license/license_test.go | 182 +-----------------
site/src/api/typesGenerated.ts | 4 -
.../dashboard/LicenseBanner/LicenseBanner.tsx | 2 -
.../LicenseBannerView.stories.tsx | 10 -
6 files changed, 26 insertions(+), 235 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 6f277f17510..414cfbcf04b 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,7 +17,6 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index f2a6924bf57..c4fe1ebcf4e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -115,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Reading usage events requires the usage publisher subject.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -709,17 +709,24 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
- featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
- "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
- if err != nil {
- return entitlements, err
+ var (
+ managedAgentCount int64
+ err = xerrors.New("dev error: managed agent count function is not set")
+ )
+ if featureArguments.ManagedAgentCountFn != nil {
+ managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
+ }
+ if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
+ // If the context is canceled, we want to bail the entire
+ // LicensesEntitlements call.
+ return entitlements, xerrors.Errorf("get managed agent count: %w", err)
}
- if ok {
+ if err != nil {
+ entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
+ // no return
+ } else {
agentLimit.Actual = &managedAgentCount
- // Write directly rather than via AddFeature so its Compare
- // cannot drop the update.
- entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
+ entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -858,39 +865,6 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs fn over the feature's usage period. A nil fn or a
-// failure with a dead context fails the whole call; any other failure logs
-// the cause and publishes unavailableText instead. It returns the measured
-// value and true only on success.
-func measureUsage(
- ctx context.Context,
- entitlements *codersdk.Entitlements,
- logger slog.Logger,
- fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
- usagePeriod codersdk.UsagePeriod,
- what string,
- unavailableText string,
-) (int64, bool, error) {
- if fn == nil {
- return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
- }
- value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
- switch {
- case err != nil && ctx.Err() != nil:
- // Do not classify cancellation by error shape instead of ctx.Err():
- // Postgres raises SQLSTATE 57014 (query_canceled) for
- // statement_timeout kills as well as client cancels, and aborting on
- // those would fail every entitlements refresh on a deployment whose
- // statement_timeout is shorter than a usage query.
- return 0, false, xerrors.Errorf("get %s: %w", what, err)
- case err != nil:
- logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
- entitlements.Errors = append(entitlements.Errors, unavailableText)
- return 0, false, nil
- }
- return value, true, nil
-}
-
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 66c46b3b691..24bd86b326c 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -15,11 +15,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
- "golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
- "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/database/dbtestutil"
@@ -37,42 +35,6 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
-// premiumRuntimeHoursFixture returns a mock store primed with a Premium
-// license carrying runtime hour claims (allocation 100, soft limit 80, hard
-// limit 120) plus the store expectations every entitlements refresh consumes
-// before usage is measured. Callers add expectations for the usage queries
-// under test.
-func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
- t.Helper()
-
- ctrl := gomock.NewController(t)
- mDB := dbmock.NewMockStore(ctrl)
-
- licenseOpts := (&coderdenttest.LicenseOptions{
- FeatureSet: codersdk.FeatureSetPremium,
- IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
- NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
- GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
- ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
- // The addon marks AI Bridge as explicitly entitled, suppressing
- // the unrelated "AI Governance add-on is required to use AI
- // Gateway" warning that Premium would otherwise produce.
- }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
-
- lic := database.License{
- ID: 1,
- JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
- Exp: licenseOpts.ExpiresAt,
- }
-
- mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
- mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
- mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
- mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
-
- return mDB, licenseOpts
-}
-
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -963,62 +925,6 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
- t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
- t.Parallel()
-
- // Drive the real Entitlements closure with a mock database so
- // measureUsage's failure path is exercised end to end: the cause
- // must land in the coderd log, which the stable payload text points
- // at, and must not land on the unauthenticated entitlements payload.
- mDB, _ := premiumRuntimeHoursFixture(t)
-
- mDB.EXPECT().
- GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
- Return(int64(0), xerrors.New("kaboom managed"))
-
- // The error-level logs are the behavior under test, so the default
- // failing test logger cannot be used.
- var logBuf bytes.Buffer
- logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
- AppendSinks(sloghuman.Sink(&logBuf))
-
- entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
- require.NoError(t, err)
- require.True(t, entitlements.HasLicense)
-
- // The failure surfaces its stable text without the raw cause.
- require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
- for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
- require.NotContains(t, entry, "kaboom")
- }
-
- logs := logBuf.String()
- require.Contains(t, logs, "get managed agent count for entitlements")
- require.Contains(t, logs, "kaboom managed")
- })
-
- t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
- t.Parallel()
-
- // A query failing while the refresh's own context is canceled,
- // e.g. during shutdown, aborts the whole entitlements refresh and
- // must not log a false query-failure alarm at error level.
- mDB, _ := premiumRuntimeHoursFixture(t)
-
- mDB.EXPECT().
- GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
- Return(int64(0), context.Canceled)
-
- var logBuf bytes.Buffer
- logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
-
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
- require.ErrorContains(t, err, "get managed agent count")
- require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
- })
-
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1398,12 +1304,6 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
- // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
- // injection below so the nil dev-error path can be exercised.
- KeepNilManagedAgentCountFn bool
- // CancelContext cancels the context passed to LicensesEntitlements
- // before the call, exercising the usage-measurement abort policy.
- CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1655,58 +1555,6 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
- {
- // A query failure is surfaced as a stable text in Errors and
- // leaves Actual unset without aborting the rest of the
- // entitlements.
- Name: "ManagedAgentLimit/QueryError",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- Arguments: license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, xerrors.New("kaboom")
- },
- },
- AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
- assertNoWarnings(t, entitlements)
- require.Len(t, entitlements.Errors, 1)
- assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
- // The raw error is logged rather than exposed on the
- // unauthenticated entitlements payload.
- assert.NotContains(t, entitlements.Errors[0], "kaboom")
- feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
- assert.Nil(t, feature.Actual)
- },
- },
- {
- // Forgetting to wire ManagedAgentCountFn is a dev error:
- // production always provides the closure, so it fails the whole
- // call loudly instead of degrading into an operator-facing
- // message.
- Name: "ManagedAgentLimit/NilFnDevError",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- KeepNilManagedAgentCountFn: true,
- ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
- },
- {
- // A failure while the computation's own context is canceled
- // aborts the whole call rather than degrading to an
- // entitlements error.
- Name: "ManagedAgentLimit/ContextCanceled",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- CancelContext: true,
- Arguments: license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, context.Canceled
- },
- },
- ExpectedErrorContains: "get managed agent count",
- },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1738,18 +1586,13 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
+ if tc.Arguments.ManagedAgentCountFn == nil {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
- ctx := context.Background()
- if tc.CancelContext {
- var cancel context.CancelFunc
- ctx, cancel = context.WithCancel(ctx)
- cancel()
- }
- entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1774,15 +1617,6 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
- // A Premium license grants a managed agent limit by default, and a nil
- // usage closure is a hard developer error, so these subtests wire a
- // zero-usage measurement closure.
- zeroUsageArgs := license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, nil
- },
- }
-
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1802,7 +1636,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1829,7 +1663,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1861,7 +1695,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1892,7 +1726,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1905,7 +1739,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index ca43735132e..177f4c28af8 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5768,10 +5768,6 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
-// From codersdk/licenses.go
-export const LicenseManagedAgentUsageUnavailableErrorText =
- "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
-
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 2218aa5cf35..a6c4cf072ce 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -4,7 +4,6 @@ import {
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -38,7 +37,6 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// usage itself. They render muted, without the exceedance heading or a sales
// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
];
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 7c2e67c9a71..c351a417655 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -5,7 +5,6 @@ import {
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -306,14 +305,6 @@ const playMutedDiagnostic =
).not.toBeInTheDocument();
};
-export const ManagedAgentUsageUnavailable: Story = {
- render: () =>
- renderLicenseBanner({
- errors: [LicenseManagedAgentUsageUnavailableErrorText],
- }),
- play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
-};
-
export const AgentRuntimeHoursClaimsIgnored: Story = {
render: () =>
renderLicenseBanner({
@@ -326,7 +317,6 @@ export const AgentRuntimeHoursClaimsIgnored: Story = {
export const UsageDiagnosticsOnlyHeading: Story = {
render: () =>
renderLicenseBanner({
- errors: [LicenseManagedAgentUsageUnavailableErrorText],
warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
}),
play: async ({ canvasElement }) => {
From a379adc773578f1189523a63615b6e79a7e37e46 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:52:20 +0000
Subject: [PATCH 106/116] fix(site/src/modules/dashboard/LicenseBanner): render
notices heading for a lone diagnostic
A single claims-ignored diagnostic took the single-message path and
rendered without any heading, contradicting the UsageDiagnosticsOnlyHeading
story, which only the pixel snapshot job exercised in CI.
Classify each entitlements message once in toBannerMessage and carry the
result as a structured kind on LicenseBannerMessage, so the view branches
on data instead of re-matching display text. The view now keeps the
"License notices" heading for a lone diagnostic while lone advisories,
warnings, and errors stay heading-less.
---
.../dashboard/LicenseBanner/LicenseBanner.tsx | 55 ++++++++++---------
.../LicenseBannerView.stories.tsx | 3 +
.../LicenseBanner/LicenseBannerView.tsx | 25 ++++++---
3 files changed, 49 insertions(+), 34 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index a6c4cf072ce..2c33e9fcbf4 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -43,11 +43,10 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render muted to stay visually distinct from
-// warnings that demand action, such as exceeding a license limit.
-const isMutedWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
- isDiagnosticMessage(message);
+// Advisories render muted to stay visually distinct from warnings that
+// demand action, such as exceeding a license limit.
+const isAdvisoryMessage = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -140,11 +139,6 @@ const messageLink = (message: string): LicenseBannerLink | undefined => {
showExternalIcon: false,
};
}
- // Diagnostics point the operator at the logs or support, so they do not
- // get a sales link.
- if (isDiagnosticMessage(message)) {
- return undefined;
- }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -152,6 +146,29 @@ const messageLink = (message: string): LicenseBannerLink | undefined => {
};
};
+// Classifies a raw entitlements message once and carries the result as
+// structured message data, so rendering branches on the message's kind and
+// variant fields rather than re-matching display text.
+const toBannerMessage = (
+ message: string,
+ channel: "errors" | "warnings",
+): LicenseBannerMessage => {
+ // Measurement diagnostics travel in the errors channel but are not
+ // license errors. They render muted and without a sales link: they point
+ // the operator at the logs, not at sales.
+ if (isDiagnosticMessage(message)) {
+ return { message, variant: "warning", kind: "diagnostic" };
+ }
+ if (channel === "errors") {
+ return { message, variant: "error", link: messageLink(message) };
+ }
+ return {
+ message,
+ variant: isAdvisoryMessage(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ };
+};
+
export const LicenseBanner: FC = () => {
const { entitlements } = useDashboard();
const { errors } = entitlements;
@@ -174,21 +191,9 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map(
- (message): LicenseBannerMessage => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message) ? "warning" : "error",
- link: messageLink(message),
- }),
- ),
- ...normalizedWarnings.map(
- (message): LicenseBannerMessage => ({
- message,
- variant: isMutedWarning(message) ? "warning" : "warningProminent",
- link: messageLink(message),
- }),
+ ...errors.map((message) => toBannerMessage(message, "errors")),
+ ...normalizedWarnings.map((message) =>
+ toBannerMessage(message, "warnings"),
),
];
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index c351a417655..94b04f29e5d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -260,6 +260,9 @@ export const AIGovernanceNearLimit: Story = {
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
+ // A lone advisory is muted but not a diagnostic, so it renders
+ // without the notices heading.
+ await expect(canvas.queryByText("License notices")).not.toBeInTheDocument();
},
};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 2cef6164395..8212fe70736 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -25,6 +25,10 @@ export interface LicenseBannerLink {
export interface LicenseBannerMessage {
message: string;
variant: LicenseBannerVariant;
+ // Diagnostics about the license or the usage measurement rather than
+ // about usage itself. They keep the "License notices" heading even when
+ // they are the only message, since the muted text needs that context.
+ kind?: "diagnostic";
link?: LicenseBannerLink;
}
@@ -146,6 +150,9 @@ export const LicenseBannerView: React.FC = ({
const bannerVariant = getBannerVariant(messages);
const visibleMessages = messages.slice(0, 2);
const hiddenMessages = messages.slice(2);
+ // A lone diagnostic keeps the heading: without it the muted banner is an
+ // unexplained sentence. Other single messages stay heading-less.
+ const showHeading = !isSingleMessage || messages[0].kind === "diagnostic";
return (
= ({
/>
+ {showHeading && (
+
+ {bannerTitle(bannerVariant)}
+
+ )}
{isSingleMessage ? (
) : (
- <>
-
- {bannerTitle(bannerVariant)}
-
-
- >
+
)}
From fce4f79c74fb401ed935b60918d2011533fadf88 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:55:40 +0000
Subject: [PATCH 107/116] chore(enterprise/coderd/license): document only
current zero-allocation behavior
Drop the speculative CODAGT-856 concurrency-mode sentence from the
decodeAgentRuntimeHours doc comment. The comment now states only the
implemented contract: a zero allocation grants the feature disabled while
Actual is still measured and published.
---
enterprise/coderd/license/license.go | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index c4fe1ebcf4e..366a853706e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -960,8 +960,7 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// look healthy.
//
// A zero allocation grants the feature disabled, but Actual is still
-// measured and published. CODAGT-856 will make a zero allocation force a
-// concurrency-limited mode; that mode does not exist yet.
+// measured and published.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
From 133838187740d7cf6d0a4f1c5c526a9b526fbd34 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Mon, 10 Aug 2026 10:58:22 +0000
Subject: [PATCH 108/116] feat: report stable license measurement diagnostics
and tolerate unusable runtime hours claims
Two coupled changes to the license/entitlements layer.
Measurement diagnostics: extract a measureUsage helper that owns the
usage-query failure policy. A query failure now publishes the stable
codersdk.LicenseManagedAgentUsageUnavailableErrorText on the
entitlements Errors channel (keeping the alertable coderd_license_errors
gauge counting) while the raw cause goes to the coderd log through the
new FeatureArguments.Logger. A nil usage closure becomes a hard
developer error, and a failure with a dead context still aborts the
whole refresh. The managed agent count closure switches from
AsSystemRestricted to the narrower AsUsagePublisher subject. The
dashboard recognizes diagnostic texts and renders them muted, without
the exceedance heading or a sales link.
Claim tolerance: unusable agent_runtime_hours_* claim combinations no
longer reject the whole license. decodeAgentRuntimeHours drops the
unusable claims, reports them in a stable
LicenseAgentRuntimeHoursClaimsIgnoredWarningText warning (deduplicated
across licenses), and logs the affected license and claims;
validateAgentRuntimeHours and its license-invalidating errors are
removed. Rejecting a signed license over a cosmetic threshold claim
would drop the deployment to unlicensed.
Part of the stack for coder/coder#27796.
---
codersdk/licenses.go | 13 +
enterprise/coderd/coderd_test.go | 6 +
.../coderd/coderdenttest/coderdenttest.go | 15 +
enterprise/coderd/license/license.go | 228 +++++----
enterprise/coderd/license/license_test.go | 476 +++++++++++++++---
enterprise/coderd/licenses_test.go | 35 +-
site/src/api/typesGenerated.ts | 23 +
.../dashboard/LicenseBanner/LicenseBanner.tsx | 54 +-
.../LicenseBannerView.stories.tsx | 113 ++++-
.../LicenseBanner/LicenseBannerView.tsx | 20 +-
10 files changed, 762 insertions(+), 221 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index f2ccf009842..7aecbaa3089 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,6 +17,19 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ // managed agent usage query fails while computing entitlements; the
+ // cause is logged server-side. It travels in the entitlements Errors
+ // channel so the alertable coderd_license_errors gauge counts
+ // measurement failures, but the dashboard recognizes the exact text and
+ // renders it as a muted diagnostic rather than a license error.
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
+ // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ // license carries unusable Coder Agent runtime hour claims (see
+ // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ // the license and the dropped claims. The dashboard renders the exact
+ // text as a muted diagnostic without a sales link.
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
type AddLicenseRequest struct {
diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go
index e023da7b1df..7be01405353 100644
--- a/enterprise/coderd/coderd_test.go
+++ b/enterprise/coderd/coderd_test.go
@@ -92,6 +92,12 @@ func TestEntitlements(t *testing.T) {
// Enable all features
features := make(license.Features)
for _, feature := range codersdk.FeatureNames {
+ if feature == codersdk.FeatureAgentRuntimeHours {
+ // The feature name is not a valid license claim; the
+ // feature is encoded as its allocation claim.
+ features[license.ClaimAgentRuntimeHoursAllocation] = 1
+ continue
+ }
features[feature] = 1
}
features[codersdk.FeatureUserLimit] = 100
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 1115ba12118..8f5f1e4a708 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,6 +237,21 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
+// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
+// or hardLimit omits that claim, which a license is allowed to do; a non-nil
+// value is emitted verbatim, so tests can express explicit zero or negative
+// claims.
+func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
+ opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
+ if softLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitSoft, *softLimit)
+ }
+ if hardLimit != nil {
+ opts.Feature(license.ClaimAgentRuntimeHoursLimitHard, *hardLimit)
+ }
+ return opts
+}
+
func (opts *LicenseOptions) Feature(name codersdk.FeatureName, value int64) *LicenseOptions {
if opts.Features == nil {
opts.Features = license.Features{}
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8cce657de65..e6f3598475e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -93,6 +93,7 @@ func Entitlements(
}
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
+ Logger: logger,
ActiveUserCount: activeUserCount,
ActiveAISeatCount: activeAISeatCount,
ReplicaCount: replicaCount,
@@ -114,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Reading usage events requires the usage publisher subject.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -129,6 +130,9 @@ func Entitlements(
}
type FeatureArguments struct {
+ // Logger receives the causes behind operator-facing diagnostics whose
+ // published message is a stable text. The zero value discards them.
+ Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
ReplicaCount int
@@ -508,11 +512,8 @@ func LicensesEntitlements(
continue
}
- // Agent runtime hours are encoded as up to three claims and are
- // decoded together after this loop, see
- // decodeAgentRuntimeHours. The feature name itself is never a
- // valid claim. The allocation must come from the dedicated claim
- // so it is validated against the soft and hard limits.
+ // Agent runtime hour claims are decoded together after this
+ // loop; see decodeAgentRuntimeHours.
if featureName == codersdk.FeatureAgentRuntimeHours ||
isAgentRuntimeHoursClaim(featureName) {
continue
@@ -577,14 +578,25 @@ func LicensesEntitlements(
}
}
- // The loop above skips Agent runtime hours because the
- // three claims that encode them decode into a single feature.
- if feature, ok := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
+ runtimeFeature, granted, ignoredClaims := decodeAgentRuntimeHours(claims.Features, entitlement, codersdk.UsagePeriod{
IssuedAt: claims.IssuedAt.Time,
Start: usagePeriodStart,
End: usagePeriodEnd,
- }); ok {
- entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, feature)
+ })
+ if granted {
+ entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
+ }
+ if len(ignoredClaims) > 0 {
+ // The published warning is a stable text, so the details a
+ // support case needs go to the log.
+ featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
+ slog.F("license_id", license.UUID),
+ slog.F("ignored_claims", ignoredClaims),
+ )
+ if !slices.Contains(entitlements.Warnings, codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText) {
+ entitlements.Warnings = append(entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ }
}
addonFeatures := make(map[codersdk.FeatureName]codersdk.Feature)
@@ -701,24 +713,18 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- var (
- managedAgentCount int64
- err = xerrors.New("dev error: managed agent count function is not set")
- )
- if featureArguments.ManagedAgentCountFn != nil {
- managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
- }
- if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
- // If the context is canceled, we want to bail the entire
- // LicensesEntitlements call.
- return entitlements, xerrors.Errorf("get managed agent count: %w", err)
- }
+ managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
+ featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
+ "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
if err != nil {
- entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
- // no return
- } else {
+ return entitlements, err
+ }
+ if ok {
agentLimit.Actual = &managedAgentCount
- entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
+ // Written back directly: the feature contest is already
+ // settled, so AddFeature's Compare must not get a chance to
+ // drop the write.
+ entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -857,6 +863,42 @@ func LicensesEntitlements(
return entitlements, nil
}
+// measureUsage runs one usage query over the feature's usage period and owns
+// the shared failure policy: a nil fn is a wiring bug and fails the whole
+// LicensesEntitlements call; a failure with a dead context fails the call
+// without logging; any other failure logs the cause and publishes the stable
+// unavailableText instead. It returns the measured value and true only on
+// success.
+func measureUsage(
+ ctx context.Context,
+ entitlements *codersdk.Entitlements,
+ logger slog.Logger,
+ fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
+ usagePeriod codersdk.UsagePeriod,
+ what string,
+ unavailableText string,
+) (int64, bool, error) {
+ if fn == nil {
+ return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
+ }
+ value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
+ switch {
+ case err != nil && ctx.Err() != nil:
+ // The computation's own context is dead, so abort the whole call
+ // without logging. Do not classify by error shape instead: Postgres
+ // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
+ // as well as client cancels, and aborting on those would fail every
+ // entitlements refresh on a deployment whose statement_timeout is
+ // shorter than a usage query.
+ return 0, false, xerrors.Errorf("get %s: %w", what, err)
+ case err != nil:
+ logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
+ entitlements.Errors = append(entitlements.Errors, unavailableText)
+ return 0, false, nil
+ }
+ return value, true, nil
+}
+
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
@@ -885,23 +927,22 @@ const (
VersionClaim = "version"
)
-// Agent runtime hour license claims. These are the canonical claim names
-// minted by github.com/coder/license. All three claims map to the single
-// codersdk.FeatureAgentRuntimeHours feature and are validated together when
-// the license is parsed, see validateClaims.
-//
-// The unit for all three claims is hours.
+// Agent runtime hour license claims, minted by github.com/coder/license.
+// All three are in hours and decode together into the single
+// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit.
+ // allocation for the license term. It becomes the feature's Limit. A
+ // negative allocation is ignored, in which case the license does not
+ // grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // must satisfy 0 <= soft < allocation, so it may only be set when the
- // allocation is greater than 0. It becomes the feature's SoftLimit.
+ // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
- // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It must be
- // absent or >= allocation, and may only be set when the allocation is
- // greater than 0. It becomes the feature's HardLimit.
+ // ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
+ // the feature's HardLimit when the allocation is greater than 0 and
+ // hard >= allocation, and is ignored otherwise.
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
@@ -917,19 +958,12 @@ var (
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
ErrMissingAccountType = xerrors.New("license must contain valid account type")
ErrMissingAccountID = xerrors.New("license must contain valid account ID")
-
- ErrMissingAgentRuntimeHoursAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but is missing the %s claim", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursAllocation = xerrors.Errorf("license has an invalid %s claim; it must not be negative", ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursSoftLimit = xerrors.Errorf("license has an invalid %s claim; it must be at least 0 and less than %s", ClaimAgentRuntimeHoursLimitSoft, ClaimAgentRuntimeHoursAllocation)
- ErrInvalidAgentRuntimeHoursHardLimit = xerrors.Errorf("license has an invalid %s claim; it must be greater than or equal to %s", ClaimAgentRuntimeHoursLimitHard, ClaimAgentRuntimeHoursAllocation)
- ErrAgentRuntimeHoursLimitsWithZeroAllocation = xerrors.Errorf("license has agent runtime hours soft or hard limit claims but the %s claim is 0", ClaimAgentRuntimeHoursAllocation)
)
type Features map[codersdk.FeatureName]int64
-// isAgentRuntimeHoursClaim reports whether the claim name is one of the three
-// claims that encode the codersdk.FeatureAgentRuntimeHours feature. These
-// claims are decoded together, see decodeAgentRuntimeHours.
+// isAgentRuntimeHoursClaim reports whether name is one of the three claims
+// decoded by decodeAgentRuntimeHours.
func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
switch name {
case ClaimAgentRuntimeHoursAllocation,
@@ -941,62 +975,65 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
}
}
-// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours feature
-// from the claims that encode it. It reports false when the license carries no
-// allocation claim, in which case the license does not grant the feature.
+// decodeAgentRuntimeHours builds the codersdk.FeatureAgentRuntimeHours
+// feature from its claims. granted is false when there is no usable
+// allocation claim; per-claim validity rules live on the Claim* constants
+// above.
+//
+// Unusable claims are dropped, never license-invalidating: rejecting a
+// signed license over a cosmetic threshold claim would drop the deployment
+// to unlicensed. ignoredClaims names each dropped claim (including the
+// feature name itself minted as a claim, the most plausible issuer mistake)
+// so the caller can warn and log instead of letting an incorrectly issued
+// license look healthy.
//
-// The claim combination is validated when the license is parsed, see
-// Features.validateAgentRuntimeHours. The allocation is never negative here
-// and the soft and hard limits are only present alongside a positive
-// allocation.
-func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (codersdk.Feature, bool) {
- allocation, ok := features[ClaimAgentRuntimeHoursAllocation]
- if !ok {
- return codersdk.Feature{}, false
+// A zero allocation grants the feature disabled and drops both threshold
+// claims, but Actual is still measured and published. CODAGT-856 will make a
+// zero allocation force a concurrency-limited mode; that mode does not exist
+// yet.
+func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
+ if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
+ ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
}
- feature := codersdk.Feature{
+ allocation, allocOk := features[ClaimAgentRuntimeHoursAllocation]
+ soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
+ hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+
+ if !allocOk || allocation < 0 {
+ if allocOk && allocation < 0 {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
+ }
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{}, false, ignoredClaims
+ }
+
+ feature = codersdk.Feature{
Enabled: allocation > 0,
Entitlement: entitlement,
Limit: &allocation,
UsagePeriod: &usagePeriod,
}
- if soft, ok := features[ClaimAgentRuntimeHoursLimitSoft]; ok {
- feature.SoftLimit = &soft
- }
- if hard, ok := features[ClaimAgentRuntimeHoursLimitHard]; ok {
- feature.HardLimit = &hard
- }
- return feature, true
-}
-
-// validateAgentRuntimeHours validates the relationship between the agent
-// runtime hour claims. Invalid combinations reject the entire license.
-func (f Features) validateAgentRuntimeHours() error {
- allocation, hasAllocation := f[ClaimAgentRuntimeHoursAllocation]
- soft, hasSoft := f[ClaimAgentRuntimeHoursLimitSoft]
- hard, hasHard := f[ClaimAgentRuntimeHoursLimitHard]
- if !hasAllocation {
- if hasSoft || hasHard {
- return ErrMissingAgentRuntimeHoursAllocation
+ if softOk {
+ if soft > 0 && soft < allocation {
+ feature.SoftLimit = &soft
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
}
- return nil
- }
- if allocation < 0 {
- return ErrInvalidAgentRuntimeHoursAllocation
- }
- // A zero allocation disables the feature.
- // A zero hard limit is not permitted.
- if allocation == 0 && (hasSoft || hasHard) {
- return ErrAgentRuntimeHoursLimitsWithZeroAllocation
}
- if hasSoft && (soft < 0 || soft >= allocation) {
- return ErrInvalidAgentRuntimeHoursSoftLimit
- }
- if hasHard && hard < allocation {
- return ErrInvalidAgentRuntimeHoursHardLimit
+ if hardOk {
+ if allocation > 0 && hard >= allocation {
+ feature.HardLimit = &hard
+ } else {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
}
- return nil
+ return feature, true, ignoredClaims
}
// Claims is the full set of claims in a license.
@@ -1089,9 +1126,6 @@ func validateClaims(tok *jwt.Token) (*Claims, error) {
if claims.AccountID == "" {
return nil, ErrMissingAccountID
}
- if err := claims.Features.validateAgentRuntimeHours(); err != nil {
- return nil, err
- }
return claims, nil
}
return nil, xerrors.New("unable to parse Claims")
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 6fe50902850..db68fb8b60a 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -1,9 +1,11 @@
package license_test
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
+ "maps"
"slices"
"testing"
"time"
@@ -13,12 +15,17 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
+ "golang.org/x/xerrors"
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "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/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -30,6 +37,42 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
+// premiumRuntimeHoursFixture returns a mock store primed with a Premium
+// license carrying runtime hour claims (allocation 100, soft limit 80, hard
+// limit 120) plus the store expectations every entitlements refresh consumes
+// before usage is measured. Callers add expectations for the usage queries
+// under test.
+func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
+ t.Helper()
+
+ ctrl := gomock.NewController(t)
+ mDB := dbmock.NewMockStore(ctrl)
+
+ licenseOpts := (&coderdenttest.LicenseOptions{
+ FeatureSet: codersdk.FeatureSetPremium,
+ IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
+ NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
+ GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
+ ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
+ // The addon marks AI Bridge as explicitly entitled, suppressing
+ // the unrelated "AI Governance add-on is required to use AI
+ // Gateway" warning that Premium would otherwise produce.
+ }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
+
+ lic := database.License{
+ ID: 1,
+ JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
+ Exp: licenseOpts.ExpiresAt,
+ }
+
+ mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
+ mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
+ mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
+ mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
+
+ return mDB, licenseOpts
+}
+
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -920,6 +963,63 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
+ t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
+ t.Parallel()
+
+ // Drive the real Entitlements closure with a mock database so
+ // measureUsage's failure path is exercised end to end: the cause
+ // must land in the coderd log, which the stable payload text points
+ // at, and must not land on the unauthenticated entitlements payload.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), xerrors.New("kaboom managed"))
+
+ // The error-level logs are the behavior under test, so the default
+ // failing test logger cannot be used.
+ var logBuf bytes.Buffer
+ logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
+ AppendSinks(sloghuman.Sink(&logBuf))
+
+ entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+
+ // The failure surfaces its stable text without the raw cause,
+ // on the channel the codersdk constant docs prescribe.
+ require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
+ for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
+ require.NotContains(t, entry, "kaboom")
+ }
+
+ logs := logBuf.String()
+ require.Contains(t, logs, "get managed agent count for entitlements")
+ require.Contains(t, logs, "kaboom managed")
+ })
+
+ t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
+ t.Parallel()
+
+ // A query failing while the refresh's own context is canceled,
+ // e.g. during shutdown, aborts the whole entitlements refresh and
+ // must not log a false query-failure alarm at error level.
+ mDB, _ := premiumRuntimeHoursFixture(t)
+
+ mDB.EXPECT().
+ GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
+ Return(int64(0), context.Canceled)
+
+ var logBuf bytes.Buffer
+ logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
+ require.ErrorContains(t, err, "get managed agent count")
+ require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
+ })
+
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1299,6 +1399,12 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
+ // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
+ // injection below so the nil dev-error path can be exercised.
+ KeepNilManagedAgentCountFn bool
+ // CancelContext cancels the context passed to LicensesEntitlements
+ // before the call, exercising the usage-measurement abort policy.
+ CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1550,6 +1656,59 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
+ {
+ // A query failure is surfaced as a stable text in Errors (see
+ // the codersdk constant docs for the channel choice) and
+ // leaves Actual unset without aborting the rest of the
+ // entitlements.
+ Name: "ManagedAgentLimit/QueryError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, xerrors.New("kaboom")
+ },
+ },
+ AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
+ assertNoWarnings(t, entitlements)
+ require.Len(t, entitlements.Errors, 1)
+ assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
+ // The raw error is logged rather than exposed on the
+ // unauthenticated entitlements payload.
+ assert.NotContains(t, entitlements.Errors[0], "kaboom")
+ feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
+ assert.Nil(t, feature.Actual)
+ },
+ },
+ {
+ // Forgetting to wire ManagedAgentCountFn is a dev error:
+ // production always provides the closure, so it fails the whole
+ // call loudly instead of degrading into an operator-facing
+ // message.
+ Name: "ManagedAgentLimit/NilFnDevError",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ KeepNilManagedAgentCountFn: true,
+ ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
+ },
+ {
+ // A failure while the computation's own context is canceled
+ // aborts the whole call rather than degrading to an
+ // entitlements error.
+ Name: "ManagedAgentLimit/ContextCanceled",
+ Licenses: []*coderdenttest.LicenseOptions{
+ enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
+ },
+ CancelContext: true,
+ Arguments: license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, context.Canceled
+ },
+ },
+ ExpectedErrorContains: "get managed agent count",
+ },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1581,13 +1740,18 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil {
+ if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
-
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+ ctx := context.Background()
+ if tc.CancelContext {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithCancel(ctx)
+ cancel()
+ }
+ entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1612,6 +1776,15 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
+ // A Premium license grants a managed agent limit by default, and a nil
+ // usage closure is a hard developer error, so these subtests wire a
+ // zero-usage measurement closure.
+ zeroUsageArgs := license.FeatureArguments{
+ ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
+ return 0, nil
+ },
+ }
+
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1631,7 +1804,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1658,7 +1831,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1690,7 +1863,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1721,7 +1894,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1734,7 +1907,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -2535,15 +2708,22 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
})
}
-// TestAgentRuntimeHoursClaimValidation ensures invalid combinations of the
-// agent runtime hour claims reject the entire license.
-func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
+// TestAgentRuntimeHoursClaimTolerance pins decodeAgentRuntimeHours's
+// tolerate-and-warn contract; see that function's doc for the rationale.
+func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
t.Parallel()
testCases := []struct {
- name string
- features license.Features
- expectedErr error
+ name string
+ features license.Features
+
+ // expectFeature is nil when the feature must be absent.
+ expectFeature *codersdk.Feature
+ // expectClaimsIgnored is true when at least one present claim is
+ // dropped, which must surface the claims-ignored warning: tolerating
+ // a claim and signaling nothing would make an incorrectly issued license
+ // undetectable from the deployment.
+ expectClaimsIgnored bool
}{
{
name: "AllClaims",
@@ -2552,109 +2732,172 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
license.ClaimAgentRuntimeHoursLimitSoft: 80,
license.ClaimAgentRuntimeHoursLimitHard: 120,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](80),
+ HardLimit: ptr.Ref[int64](120),
+ },
},
{
name: "AllocationOnly",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
},
{
+ // A zero soft limit would warn at zero usage forever, so it is
+ // dropped rather than rejecting the license. The canonical way
+ // to express "no soft limit" is omitting the claim, so a
+ // present-but-dropped zero still warns.
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "HardEqualsAllocation",
+ name: "NegativeSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocation",
+ // A soft limit at or above the allocation could never fire
+ // before the allocation warning supersedes it.
+ name: "SoftEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 100,
},
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithZeroHard",
+ name: "SoftAboveAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 0,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 150,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
{
- name: "ZeroAllocationWithPositiveHard",
+ name: "HardEqualsAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 100,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ HardLimit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
},
{
- name: "SoftWithoutAllocation",
+ name: "HardBelowAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitHard: 99,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardWithoutAllocation",
+ name: "ZeroAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursLimitHard: 120,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrMissingAgentRuntimeHoursAllocation,
},
{
- name: "NegativeAllocation",
+ // A zero allocation has no hour budget, so threshold claims
+ // alongside it are dropped, with the warning.
+ name: "ZeroAllocationWithLimits",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: 0,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 1000,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: false,
+ Limit: ptr.Ref[int64](0),
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "NegativeSoft",
+ name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -1,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftEqualsAllocation",
+ name: "SoftWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftAboveAllocation",
+ name: "HardWithoutAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursSoftLimit,
+ expectClaimsIgnored: true,
},
{
- name: "SoftWithZeroAllocation",
+ // The feature name itself is never a valid claim: the
+ // allocation must come from the dedicated claim. It is the
+ // shape every other metered feature uses, so a license minting
+ // it is the most plausible issuer mistake and must warn
+ // rather than being dropped silently.
+ name: "FeatureNameAsClaim",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 0,
- license.ClaimAgentRuntimeHoursLimitSoft: 0,
+ codersdk.FeatureAgentRuntimeHours: 100,
},
- expectedErr: license.ErrAgentRuntimeHoursLimitsWithZeroAllocation,
+ expectClaimsIgnored: true,
},
{
- name: "HardBelowAllocation",
+ // The feature name claim is dropped (with the warning) even
+ // when a usable allocation claim grants the feature.
+ name: "FeatureNameAlongsideAllocation",
features: license.Features{
+ codersdk.FeatureAgentRuntimeHours: 50,
license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitHard: 99,
},
- expectedErr: license.ErrInvalidAgentRuntimeHoursHardLimit,
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ },
+ expectClaimsIgnored: true,
},
}
@@ -2662,46 +2905,115 @@ func TestAgentRuntimeHoursClaimValidation(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
- jwt := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: tc.features,
- })
- _, err := license.ParseClaims(jwt, coderdenttest.Keys)
- if tc.expectedErr == nil {
- require.NoError(t, err)
+ features := license.Features{
+ codersdk.FeatureUserLimit: 100,
+ }
+ maps.Copy(features, tc.features)
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: features,
+ }),
+ }
+
+ var logBuf bytes.Buffer
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
+ )
+ require.NoError(t, err)
+
+ // The license as a whole survives: unrelated paid features are
+ // unaffected by an unusable runtime hour claim.
+ require.Empty(t, entitlements.Errors)
+ require.True(t, entitlements.HasLicense)
+ userLimit := entitlements.Features[codersdk.FeatureUserLimit]
+ require.NotNil(t, userLimit.Limit)
+ require.EqualValues(t, 100, *userLimit.Limit)
+
+ // Dropped claims are tolerated but never silent: the operator
+ // sees the stable warning, and the log names the license and
+ // the dropped claims for support.
+ if tc.expectClaimsIgnored {
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ logs := logBuf.String()
+ require.Contains(t, logs, "ignored unusable Coder Agent runtime hour claims in license")
+ require.Contains(t, logs, lic.UUID.String())
+ } else {
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ require.Empty(t, logBuf.String())
+ }
+
+ // Every known feature name has a default entry in the map, so
+ // "the license does not grant the feature" surfaces as the
+ // default: no limit, no usage period, not enabled.
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ if tc.expectFeature == nil {
+ require.Nil(t, feature.Limit, "feature must not be granted")
+ require.Nil(t, feature.UsagePeriod, "feature must not be granted")
+ require.False(t, feature.Enabled)
return
}
- require.ErrorIs(t, err, tc.expectedErr)
+ require.NotNil(t, feature.UsagePeriod, "feature must be granted")
+ require.Equal(t, tc.expectFeature.Enabled, feature.Enabled)
+ require.Equal(t, tc.expectFeature.Limit, feature.Limit)
+ require.Equal(t, tc.expectFeature.SoftLimit, feature.SoftLimit)
+ require.Equal(t, tc.expectFeature.HardLimit, feature.HardLimit)
})
}
- // An invalid license already stored in the database is rejected entirely
- // and produces an entitlements error.
- t.Run("EntitlementsError", func(t *testing.T) {
+ t.Run("WarningDeduplicatedAcrossLicenses", func(t *testing.T) {
t.Parallel()
- lic := database.License{
- ID: 1,
- UploadedAt: time.Now(),
- Exp: time.Now().Add(time.Hour),
- UUID: uuid.New(),
- JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 150,
- },
- }),
+ // Two licenses with unusable claims must publish the stable warning
+ // once, or the banner would stack identical texts, while the log
+ // names each affected license so the operator can tell which ones
+ // need re-issuing.
+ newLicense := func(id int32) database.License {
+ return database.License{
+ ID: id,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
+ // A threshold without an allocation is unusable.
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ }),
+ }
}
+ licenses := []database.License{newLicense(1), newLicense(2)}
+ var logBuf bytes.Buffer
entitlements, err := license.LicensesEntitlements(
- context.Background(), time.Now(), []database.License{lic},
- map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ context.Background(), time.Now(), licenses,
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{
+ Logger: slog.Make(sloghuman.Sink(&logBuf)),
+ },
)
require.NoError(t, err)
- require.Len(t, entitlements.Errors, 1)
- require.Contains(t, entitlements.Errors[0], fmt.Sprintf("Invalid license (%s) parsing claims", lic.UUID))
- require.False(t, entitlements.HasLicense)
- feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
- require.Equal(t, codersdk.EntitlementNotEntitled, feature.Entitlement)
+
+ warningCount := 0
+ for _, warning := range entitlements.Warnings {
+ if warning == codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText {
+ warningCount++
+ }
+ }
+ require.Equal(t, 1, warningCount, "the claims-ignored warning must appear exactly once")
+
+ logs := logBuf.String()
+ for _, lic := range licenses {
+ require.Contains(t, logs, lic.UUID.String())
+ }
})
}
diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go
index c88f975f2c0..811929f0939 100644
--- a/enterprise/coderd/licenses_test.go
+++ b/enterprise/coderd/licenses_test.go
@@ -12,6 +12,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database/dbtime"
+ "github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
@@ -105,35 +106,41 @@ func TestPostLicense(t *testing.T) {
require.Contains(t, errResp.Message, "Invalid license")
})
- t.Run("InvalidAgentRuntimeClaims", func(t *testing.T) {
+ t.Run("UnusableAgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- // A soft limit claim without an allocation claim rejects the whole
- // license.
+ // A soft limit claim without an allocation claim is unusable, but it
+ // never rejects the whole license: the license stays valid, the
+ // runtime hours feature is simply not granted, and the dropped claim
+ // is surfaced as a warning. See decodeAgentRuntimeHours.
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
+ codersdk.FeatureUserLimit: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 80,
},
})
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
License: lic,
})
- errResp := &codersdk.Error{}
- require.ErrorAs(t, err, &errResp)
- require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
- require.Contains(t, errResp.Message, "Invalid license")
+ require.NoError(t, err)
+ // The claims round-trip through GET /api/v2/entitlements.
+ //nolint:gocritic // This test asserts license state, not authz behavior.
+ entitlements, err := client.Entitlements(context.Background())
+ require.NoError(t, err)
+ require.True(t, entitlements.HasLicense)
+ require.Empty(t, entitlements.Errors)
+ require.Contains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.UsagePeriod)
})
t.Run("AgentRuntimeClaims", func(t *testing.T) {
t.Parallel()
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
- coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
- Features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: 100,
- license.ClaimAgentRuntimeHoursLimitSoft: 80,
- license.ClaimAgentRuntimeHoursLimitHard: 120,
- },
- })
+ coderdenttest.AddLicense(t, client,
+ *(&coderdenttest.LicenseOptions{}).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120)))
// The claims round-trip through GET /api/v2/entitlements.
//nolint:gocritic // This test asserts license state, not authz behavior.
entitlements, err := client.Entitlements(context.Background())
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index cfc4fc94650..802565dbaf7 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5757,6 +5757,17 @@ export const LicenseAIGovernance90PercentWarningText =
export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
+// From codersdk/licenses.go
+/**
+ * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
+ * license carries unusable Coder Agent runtime hour claims (see
+ * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
+ * the license and the dropped claims. The dashboard renders the exact
+ * text as a muted diagnostic without a sales link.
+ */
+export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
+ "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
+
// From codersdk/licenses.go
export const LicenseExpiryClaim = "license_expires";
@@ -5764,6 +5775,18 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
+// From codersdk/licenses.go
+/**
+ * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
+ * managed agent usage query fails while computing entitlements; the
+ * cause is logged server-side. It travels in the entitlements Errors
+ * channel so the alertable coderd_license_errors gauge counts
+ * measurement failures, but the dashboard recognizes the exact text and
+ * renders it as a muted diagnostic rather than a license error.
+ */
+export const LicenseManagedAgentUsageUnavailableErrorText =
+ "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
+
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index ba5f739d93d..9ef4dda8bb1 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -1,8 +1,10 @@
import type { FC } from "react";
import {
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -24,8 +26,32 @@ const isAIGovernanceWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
message.startsWith(aiGovernanceOverLimitWarningPrefix);
-const isAIGovernanceNearLimitWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix);
+// Substitutes the given values into the template's %d placeholders in order.
+// No other fmt verb, width, or flag is implemented.
+const formatLicenseMessage = (template: string, ...values: number[]): string =>
+ values.reduce(
+ (message, value) => message.replace("%d", `${value}`),
+ template,
+ );
+
+// Diagnostics about the license or the usage measurement rather than about
+// usage itself. They render muted, without the exceedance heading or a sales
+// link. The "unavailable" pair arrives via entitlements.errors but must not
+// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+const diagnosticMessages: readonly string[] = [
+ LicenseManagedAgentUsageUnavailableErrorText,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
+];
+
+const isDiagnosticMessage = (message: string): boolean =>
+ diagnosticMessages.includes(message);
+
+// Advisories and diagnostics render in the muted variant: nothing is wrong
+// yet, so they must be visually distinct from warnings that demand action,
+// such as exceeding a license limit.
+const isMutedWarning = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
+ isDiagnosticMessage(message);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -48,9 +74,12 @@ const aiGovernanceOverLimitMessage = (
}
const overLimitSeats = actual - limit;
- return LicenseAIGovernanceOverLimitWarningText.replace("%d", `${actual}`)
- .replace("%d", `${limit}`)
- .replace("%d", `${overLimitSeats}`);
+ return formatLicenseMessage(
+ LicenseAIGovernanceOverLimitWarningText,
+ actual,
+ limit,
+ overLimitSeats,
+ );
};
const aiGovernanceNearLimitMessage = (
@@ -99,7 +128,7 @@ const normalizeAIGovernanceWarning = (
);
};
-const messageLink = (message: string): LicenseBannerLink => {
+const messageLink = (message: string): LicenseBannerLink | undefined => {
if (message === LicenseManagedAgentLimitExceededWarningText) {
return {
href: docs("/ai-coder/ai-governance"),
@@ -115,6 +144,11 @@ const messageLink = (message: string): LicenseBannerLink => {
showExternalIcon: false,
};
}
+ // Diagnostics point the operator at the logs or support, so they do not
+ // get a sales link.
+ if (isDiagnosticMessage(message)) {
+ return undefined;
+ }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -146,12 +180,16 @@ export const LicenseBanner: FC = () => {
const messages: LicenseBannerMessage[] = [
...errors.map((message) => ({
message,
- variant: "error" as const,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message)
+ ? ("warning" as const)
+ : ("error" as const),
link: messageLink(message),
})),
...normalizedWarnings.map((message) => ({
message,
- variant: isAIGovernanceNearLimitWarning(message)
+ variant: isMutedWarning(message)
? ("warning" as const)
: ("warningProminent" as const),
link: messageLink(message),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 582493a4eff..6c3dd4e662b 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import {
+ type Entitlements,
+ LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
+ LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -180,30 +183,24 @@ export const ManagedAgentLimitExceededWithOtherWarnings: Story = {
},
};
-const renderLicenseBannerWithAIGovernance = ({
- actual,
- entitlement = "entitled",
- limit,
+const renderLicenseBanner = ({
+ errors = [],
warnings = [],
+ features = {},
}: {
- actual: number;
- entitlement?: "entitled" | "grace_period" | "not_entitled";
- limit?: number;
+ errors?: string[];
warnings?: string[];
+ features?: Partial;
}) => {
const mockDashboardValue: DashboardValue = {
entitlements: {
...MockEntitlements,
has_license: true,
+ errors,
warnings,
features: {
...MockEntitlements.features,
- ai_governance_user_limit: {
- enabled: true,
- entitlement,
- actual,
- ...(limit !== undefined ? { limit } : {}),
- },
+ ...features,
},
},
experiments: MockExperiments,
@@ -215,12 +212,39 @@ const renderLicenseBannerWithAIGovernance = ({
};
return (
-
+
-
+
);
};
+const renderLicenseBannerWithAIGovernance = ({
+ actual,
+ entitlement = "entitled",
+ limit,
+ warnings = [],
+}: {
+ actual: number;
+ entitlement?: "entitled" | "grace_period" | "not_entitled";
+ limit?: number;
+ warnings?: string[];
+}) =>
+ renderLicenseBanner({
+ warnings,
+ features: {
+ ai_governance_user_limit: {
+ enabled: true,
+ entitlement,
+ actual,
+ ...(limit !== undefined ? { limit } : {}),
+ },
+ },
+ });
+
+// Without the data-variant assertions, every story would keep passing with
+// the muted/prominent classifier disabled.
+const mutedVariant = "warning";
+
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -230,9 +254,13 @@ export const AIGovernanceNearLimit: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await expect(canvas.getByRole("status")).toHaveTextContent(
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
+ // Pins the AI Governance near-limit branch of isMutedWarning,
+ // independently of the runtime soft-limit branch below.
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -267,3 +295,56 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
);
},
};
+
+// Each entry of the frontend's diagnosticMessages set is pinned on both
+// properties the set drives: the muted variant and the suppressed sales
+// link. The "unavailable" message arrives on the errors channel; see the
+// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+const playMutedDiagnostic =
+ (message: string): Story["play"] =>
+ async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveTextContent(message);
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(
+ canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
+ ).not.toBeInTheDocument();
+ };
+
+export const ManagedAgentUsageUnavailable: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ }),
+ play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
+};
+
+export const AgentRuntimeHoursClaimsIgnored: Story = {
+ render: () =>
+ renderLicenseBanner({
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText),
+};
+
+// An all-diagnostic banner must not claim license limits were exceeded.
+export const UsageDiagnosticsOnlyHeading: Story = {
+ render: () =>
+ renderLicenseBanner({
+ errors: [LicenseManagedAgentUsageUnavailableErrorText],
+ warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
+ }),
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const banner = canvas.getByRole("status");
+ await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByText("License notices")).toBeInTheDocument();
+ await expect(
+ canvas.queryByText("Your license limits have been exceeded"),
+ ).not.toBeInTheDocument();
+ await expect(
+ canvas.queryByText("License errors require attention"),
+ ).not.toBeInTheDocument();
+ },
+};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 80674fcbb7e..bd278dc591c 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,10 +70,19 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-const bannerTitle = (variant: LicenseBannerVariant): string =>
- variant === "error"
- ? "License errors require attention"
- : "Your license limits have been exceeded";
+// The muted variant only wins when every message is muted (see
+// getBannerVariant), which means advisories and diagnostics: nothing has
+// been exceeded, so the heading must not assert exceedance.
+const bannerTitle = (variant: LicenseBannerVariant): string => {
+ switch (variant) {
+ case "error":
+ return "License errors require attention";
+ case "warningProminent":
+ return "Your license limits have been exceeded";
+ case "warning":
+ return "License notices";
+ }
+};
const bannerRole = (variant: LicenseBannerVariant): "alert" | "status" =>
variant === "error" ? "alert" : "status";
@@ -142,6 +151,9 @@ export const LicenseBannerView: React.FC = ({
return (
From dff7735a241986ac31f6699a9359501bdec14efa Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 05:47:20 +0000
Subject: [PATCH 109/116] feat(enterprise/coderd/license): decode -1 runtime
hours allocation as unlimited
An agent_runtime_hours_allocation claim of exactly -1 now grants the
agent_runtime_hours feature enabled with a nil Limit, which the
entitlements API serves as an omitted "limit" field, the shape the UI
already renders as Unlimited. Threshold claims alongside it are dropped
with the existing claims-ignored warning, and any other negative
allocation remains unusable.
---
enterprise/coderd/license/license.go | 36 ++++++++-
enterprise/coderd/license/license_test.go | 90 ++++++++++++++++++++++-
2 files changed, 122 insertions(+), 4 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index e6f3598475e..8911f695d3d 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -932,9 +932,10 @@ const (
// codersdk.FeatureAgentRuntimeHours feature; see decodeAgentRuntimeHours.
const (
// ClaimAgentRuntimeHoursAllocation is the purchased runtime-hour
- // allocation for the license term. It becomes the feature's Limit. A
- // negative allocation is ignored, in which case the license does not
- // grant the feature.
+ // allocation for the license term. It becomes the feature's Limit.
+ // AgentRuntimeHoursUnlimitedAllocation (-1) is reserved to mean
+ // unlimited; any other negative allocation is ignored, in which case
+ // the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
// becomes the feature's SoftLimit when 0 < soft < allocation and is
@@ -946,6 +947,12 @@ const (
ClaimAgentRuntimeHoursLimitHard = "agent_runtime_hours_limit_hard"
)
+// AgentRuntimeHoursUnlimitedAllocation is the reserved
+// ClaimAgentRuntimeHoursAllocation value meaning the license grants
+// unlimited runtime hours. It decodes to an enabled feature with a nil
+// Limit. Mirrored in github.com/coder/license.
+const AgentRuntimeHoursUnlimitedAllocation int64 = -1
+
var (
ValidMethods = []string{"EdDSA"}
@@ -991,6 +998,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// claims, but Actual is still measured and published. CODAGT-856 will make a
// zero allocation force a concurrency-limited mode; that mode does not exist
// yet.
+//
+// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
+// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
+// have nothing to threshold against, so they are dropped with the warning,
+// keeping an incorrectly issued license visible. Note that
+// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
+// issued-at and expiry tie a metered license outranks an unlimited one; ties
+// never happen for separately issued licenses, so this edge is documented
+// rather than special-cased.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
@@ -1000,6 +1016,20 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
soft, softOk := features[ClaimAgentRuntimeHoursLimitSoft]
hard, hardOk := features[ClaimAgentRuntimeHoursLimitHard]
+ if allocOk && allocation == AgentRuntimeHoursUnlimitedAllocation {
+ if softOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
+ }
+ if hardOk {
+ ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitHard)
+ }
+ return codersdk.Feature{
+ Enabled: true,
+ Entitlement: entitlement,
+ UsagePeriod: &usagePeriod,
+ }, true, ignoredClaims
+ }
+
if !allocOk || allocation < 0 {
if allocOk && allocation < 0 {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursAllocation)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index db68fb8b60a..0e393939c5d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2501,6 +2501,55 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
require.NotNil(t, feature.UsagePeriod)
})
+ // An unlimited (-1) allocation grants the feature enabled with no Limit,
+ // which the API serves as an omitted "limit" field, the shape the UI
+ // already renders as "Unlimited".
+ t.Run("UnlimitedAllocation", func(t *testing.T) {
+ t.Parallel()
+
+ lic := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: time.Now().Add(time.Hour),
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+
+ entitlements, err := license.LicensesEntitlements(
+ context.Background(), time.Now(), []database.License{lic},
+ map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{},
+ )
+ require.NoError(t, err)
+ require.Empty(t, entitlements.Errors)
+ require.NotContains(t, entitlements.Warnings,
+ codersdk.LicenseAgentRuntimeHoursClaimsIgnoredWarningText)
+
+ feature := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+
+ // The entitlements JSON served by GET /api/v2/entitlements omits
+ // "limit" entirely for the unlimited feature.
+ data, err := json.Marshal(entitlements)
+ require.NoError(t, err)
+ var raw struct {
+ Features map[codersdk.FeatureName]map[string]any `json:"features"`
+ }
+ require.NoError(t, json.Unmarshal(data, &raw))
+ rawFeature := raw.Features[codersdk.FeatureAgentRuntimeHours]
+ require.Equal(t, true, rawFeature["enabled"])
+ require.NotContains(t, rawFeature, "limit")
+ require.Contains(t, rawFeature, "usage_period")
+ })
+
// The license with the newest issued-at claim wins, even if another
// license was loaded first or has a larger allocation. The soft and hard
// limits come from the winning license.
@@ -2853,9 +2902,48 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
expectClaimsIgnored: true,
},
{
+ // An unlimited allocation grants the feature with no Limit and
+ // no warning: -1 is the canonical unlimited encoding, not an
+ // issuance mistake.
+ name: "UnlimitedAllocation",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ },
+ {
+ // Threshold claims alongside an unlimited allocation have
+ // nothing to threshold against; the grant survives but the
+ // issuance mistake must stay visible via the warning.
+ name: "UnlimitedWithSoft",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ name: "UnlimitedWithHard",
+ features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ expectFeature: &codersdk.Feature{
+ Enabled: true,
+ },
+ expectClaimsIgnored: true,
+ },
+ {
+ // Only exactly -1 is the unlimited sentinel; any other negative
+ // allocation stays unusable.
name: "NegativeAllocation",
features: license.Features{
- license.ClaimAgentRuntimeHoursAllocation: -1,
+ license.ClaimAgentRuntimeHoursAllocation: -2,
},
expectClaimsIgnored: true,
},
From 40266579876e319e20d3f1652e936f93a804d236 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:07:05 +0000
Subject: [PATCH 110/116] chore: remove data-variant
---
.../LicenseBannerView.stories.tsx | 21 +++++++------------
.../LicenseBanner/LicenseBannerView.tsx | 3 ---
2 files changed, 8 insertions(+), 16 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 6c3dd4e662b..4f2030a3080 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -59,6 +59,10 @@ export const TwoWarnings: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
+ await expect(
+ canvas.getByText("Your license limits have been exceeded"),
+ ).toBeInTheDocument();
await expect(
canvas.queryByRole("button", { name: "Show more" }),
).not.toBeInTheDocument();
@@ -241,10 +245,6 @@ const renderLicenseBannerWithAIGovernance = ({
},
});
-// Without the data-variant assertions, every story would keep passing with
-// the muted/prominent classifier disabled.
-const mutedVariant = "warning";
-
export const AIGovernanceNearLimit: Story = {
render: () =>
renderLicenseBannerWithAIGovernance({
@@ -258,9 +258,6 @@ export const AIGovernanceNearLimit: Story = {
await expect(banner).toHaveTextContent(
"You have used 95% of your AI Governance add-on seats.",
);
- // Pins the AI Governance near-limit branch of isMutedWarning,
- // independently of the runtime soft-limit branch below.
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
@@ -296,17 +293,16 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
},
};
-// Each entry of the frontend's diagnosticMessages set is pinned on both
-// properties the set drives: the muted variant and the suppressed sales
+// Each diagnostic pins role=status (not alert) and a suppressed sales
// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why.
+// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
+// mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
const canvas = within(canvasElement);
const banner = canvas.getByRole("status");
await expect(banner).toHaveTextContent(message);
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
await expect(
canvas.queryByRole("link", { name: /Contact sales@coder\.com/i }),
).not.toBeInTheDocument();
@@ -337,8 +333,7 @@ export const UsageDiagnosticsOnlyHeading: Story = {
}),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- const banner = canvas.getByRole("status");
- await expect(banner).toHaveAttribute("data-variant", mutedVariant);
+ await expect(canvas.getByRole("status")).toBeInTheDocument();
await expect(canvas.getByText("License notices")).toBeInTheDocument();
await expect(
canvas.queryByText("Your license limits have been exceeded"),
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index bd278dc591c..453b925fe6d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -151,9 +151,6 @@ export const LicenseBannerView: React.FC = ({
return (
From 04caac958345ce9bf586cec244d98fe66842da05 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 06:42:51 +0000
Subject: [PATCH 111/116] chore: remove and simplify explanatory comments
---
codersdk/licenses.go | 23 ++------
.../coderd/coderdenttest/coderdenttest.go | 4 --
enterprise/coderd/license/license.go | 57 +++++++------------
enterprise/coderd/license/license_test.go | 6 +-
site/src/api/typesGenerated.ts | 15 -----
.../dashboard/LicenseBanner/LicenseBanner.tsx | 8 +--
.../LicenseBannerView.stories.tsx | 4 +-
.../LicenseBanner/LicenseBannerView.tsx | 5 +-
8 files changed, 33 insertions(+), 89 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 7aecbaa3089..6f277f17510 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -12,23 +12,12 @@ import (
)
const (
- LicenseExpiryClaim = "license_expires"
- LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
- LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
- LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
- LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- // LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- // managed agent usage query fails while computing entitlements; the
- // cause is logged server-side. It travels in the entitlements Errors
- // channel so the alertable coderd_license_errors gauge counts
- // measurement failures, but the dashboard recognizes the exact text and
- // renders it as a muted diagnostic rather than a license error.
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
- // LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- // license carries unusable Coder Agent runtime hour claims (see
- // decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- // the license and the dropped claims. The dashboard renders the exact
- // text as a muted diagnostic without a sales link.
+ LicenseExpiryClaim = "license_expires"
+ LicenseTelemetryRequiredErrorText = "License requires telemetry but telemetry is disabled"
+ LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
+ LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
+ LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
+ LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go
index 8f5f1e4a708..1d2678fd787 100644
--- a/enterprise/coderd/coderdenttest/coderdenttest.go
+++ b/enterprise/coderd/coderdenttest/coderdenttest.go
@@ -237,10 +237,6 @@ func (opts *LicenseOptions) ManagedAgentLimit(limit int64) *LicenseOptions {
return opts.Feature(codersdk.FeatureManagedAgentLimit, limit)
}
-// AgentRuntimeHours sets the Coder Agent runtime hour claims. A nil softLimit
-// or hardLimit omits that claim, which a license is allowed to do; a non-nil
-// value is emitted verbatim, so tests can express explicit zero or negative
-// claims.
func (opts *LicenseOptions) AgentRuntimeHours(allocation int64, softLimit, hardLimit *int64) *LicenseOptions {
opts.Feature(license.ClaimAgentRuntimeHoursAllocation, allocation)
if softLimit != nil {
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 8911f695d3d..66b55f1feea 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -130,8 +130,6 @@ func Entitlements(
}
type FeatureArguments struct {
- // Logger receives the causes behind operator-facing diagnostics whose
- // published message is a stable text. The zero value discards them.
Logger slog.Logger
ActiveUserCount int64
ActiveAISeatCount int64
@@ -587,8 +585,6 @@ func LicensesEntitlements(
entitlements.AddFeature(codersdk.FeatureAgentRuntimeHours, runtimeFeature)
}
if len(ignoredClaims) > 0 {
- // The published warning is a stable text, so the details a
- // support case needs go to the log.
featureArguments.Logger.Warn(ctx, "ignored unusable Coder Agent runtime hour claims in license",
slog.F("license_id", license.UUID),
slog.F("ignored_claims", ignoredClaims),
@@ -721,9 +717,8 @@ func LicensesEntitlements(
}
if ok {
agentLimit.Actual = &managedAgentCount
- // Written back directly: the feature contest is already
- // settled, so AddFeature's Compare must not get a chance to
- // drop the write.
+ // Write directly rather than via AddFeature so its Compare
+ // cannot drop the update.
entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
// Only issue warnings if the feature is enabled.
@@ -863,12 +858,10 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs one usage query over the feature's usage period and owns
-// the shared failure policy: a nil fn is a wiring bug and fails the whole
-// LicensesEntitlements call; a failure with a dead context fails the call
-// without logging; any other failure logs the cause and publishes the stable
-// unavailableText instead. It returns the measured value and true only on
-// success.
+// measureUsage runs fn over the feature's usage period. A nil fn or a
+// failure with a dead context fails the whole call; any other failure logs
+// the cause and publishes unavailableText instead. It returns the measured
+// value and true only on success.
func measureUsage(
ctx context.Context,
entitlements *codersdk.Entitlements,
@@ -884,12 +877,11 @@ func measureUsage(
value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
switch {
case err != nil && ctx.Err() != nil:
- // The computation's own context is dead, so abort the whole call
- // without logging. Do not classify by error shape instead: Postgres
- // raises SQLSTATE 57014 (query_canceled) for statement_timeout kills
- // as well as client cancels, and aborting on those would fail every
- // entitlements refresh on a deployment whose statement_timeout is
- // shorter than a usage query.
+ // Do not classify cancellation by error shape instead of ctx.Err():
+ // Postgres raises SQLSTATE 57014 (query_canceled) for
+ // statement_timeout kills as well as client cancels, and aborting on
+ // those would fail every entitlements refresh on a deployment whose
+ // statement_timeout is shorter than a usage query.
return 0, false, xerrors.Errorf("get %s: %w", what, err)
case err != nil:
logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
@@ -987,26 +979,15 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// allocation claim; per-claim validity rules live on the Claim* constants
// above.
//
-// Unusable claims are dropped, never license-invalidating: rejecting a
-// signed license over a cosmetic threshold claim would drop the deployment
-// to unlicensed. ignoredClaims names each dropped claim (including the
-// feature name itself minted as a claim, the most plausible issuer mistake)
-// so the caller can warn and log instead of letting an incorrectly issued
-// license look healthy.
+// Unusable claims are dropped rather than invalidating the license, since
+// rejecting a signed license over a cosmetic claim would drop the deployment
+// to unlicensed. Each dropped claim is returned in ignoredClaims so the
+// caller can warn and log instead of letting an incorrectly issued license
+// look healthy.
//
-// A zero allocation grants the feature disabled and drops both threshold
-// claims, but Actual is still measured and published. CODAGT-856 will make a
-// zero allocation force a concurrency-limited mode; that mode does not exist
-// yet.
-//
-// An AgentRuntimeHoursUnlimitedAllocation (-1) allocation grants the feature
-// enabled with a nil Limit, meaning unlimited. Threshold claims alongside it
-// have nothing to threshold against, so they are dropped with the warning,
-// keeping an incorrectly issued license visible. Note that
-// codersdk.Feature.Compare ranks a nil Limit below a set one, so on an exact
-// issued-at and expiry tie a metered license outranks an unlimited one; ties
-// never happen for separately issued licenses, so this edge is documented
-// rather than special-cased.
+// A zero allocation grants the feature disabled, but Actual is still
+// measured and published. CODAGT-856 will make a zero allocation force a
+// concurrency-limited mode; that mode does not exist yet.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 0e393939c5d..ec8e06b2f8d 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -986,8 +986,7 @@ func TestEntitlements(t *testing.T) {
require.NoError(t, err)
require.True(t, entitlements.HasLicense)
- // The failure surfaces its stable text without the raw cause,
- // on the channel the codersdk constant docs prescribe.
+ // The failure surfaces its stable text without the raw cause.
require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
require.NotContains(t, entry, "kaboom")
@@ -1657,8 +1656,7 @@ func TestLicenseEntitlements(t *testing.T) {
},
},
{
- // A query failure is surfaced as a stable text in Errors (see
- // the codersdk constant docs for the channel choice) and
+ // A query failure is surfaced as a stable text in Errors and
// leaves Actual unset without aborting the rest of the
// entitlements.
Name: "ManagedAgentLimit/QueryError",
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index 802565dbaf7..ca43735132e 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5758,13 +5758,6 @@ export const LicenseAIGovernanceOverLimitWarningText =
"Your organization is using %d of %d AI Governance add-on seats (%d over the limit).";
// From codersdk/licenses.go
-/**
- * LicenseAgentRuntimeHoursClaimsIgnoredWarningText is emitted when a
- * license carries unusable Coder Agent runtime hour claims (see
- * decodeAgentRuntimeHours in enterprise/coderd/license); the logs name
- * the license and the dropped claims. The dashboard renders the exact
- * text as a muted diagnostic without a sales link.
- */
export const LicenseAgentRuntimeHoursClaimsIgnoredWarningText =
"A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued.";
@@ -5776,14 +5769,6 @@ export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
// From codersdk/licenses.go
-/**
- * LicenseManagedAgentUsageUnavailableErrorText is emitted when the
- * managed agent usage query fails while computing entitlements; the
- * cause is logged server-side. It travels in the entitlements Errors
- * channel so the alertable coderd_license_errors gauge counts
- * measurement failures, but the dashboard recognizes the exact text and
- * renders it as a muted diagnostic rather than a license error.
- */
export const LicenseManagedAgentUsageUnavailableErrorText =
"Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 9ef4dda8bb1..74ce21b89fa 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -36,8 +36,7 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// Diagnostics about the license or the usage measurement rather than about
// usage itself. They render muted, without the exceedance heading or a sales
-// link. The "unavailable" pair arrives via entitlements.errors but must not
-// render as license errors; see LicenseManagedAgentUsageUnavailableErrorText.
+// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
@@ -46,9 +45,8 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render in the muted variant: nothing is wrong
-// yet, so they must be visually distinct from warnings that demand action,
-// such as exceeding a license limit.
+// Advisories and diagnostics render muted to stay visually distinct from
+// warnings that demand action, such as exceeding a license limit.
const isMutedWarning = (message: string): boolean =>
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
isDiagnosticMessage(message);
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 4f2030a3080..7c2e67c9a71 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -294,9 +294,7 @@ export const AIGovernanceOverLimitGracePeriod: Story = {
};
// Each diagnostic pins role=status (not alert) and a suppressed sales
-// link. The "unavailable" message arrives on the errors channel; see the
-// LicenseManagedAgentUsageUnavailableErrorText doc for why. Background
-// mutedness is covered by the visual snapshot.
+// link. Background mutedness is covered by the visual snapshot.
const playMutedDiagnostic =
(message: string): Story["play"] =>
async ({ canvasElement }) => {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 453b925fe6d..2cef6164395 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -70,9 +70,8 @@ const getBannerVariant = (
return hasProminentWarning ? "warningProminent" : "warning";
};
-// The muted variant only wins when every message is muted (see
-// getBannerVariant), which means advisories and diagnostics: nothing has
-// been exceeded, so the heading must not assert exceedance.
+// The muted "warning" variant means every message is an advisory or
+// diagnostic, so the heading must not assert exceedance.
const bannerTitle = (variant: LicenseBannerVariant): string => {
switch (variant) {
case "error":
From ce5b547ea09e96537f0c5f2f4d837831ec875473 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Tue, 11 Aug 2026 09:40:53 +0000
Subject: [PATCH 112/116] fix: make unlimited usage period features outrank
metered ones on ties
A nil Limit on a usage period feature means unlimited, so Feature.Compare now ranks it above any set limit when issued-at and end dates tie. Also types the LicenseBanner map callbacks as LicenseBannerMessage instead of using as const assertions.
---
codersdk/deployment.go | 12 +++-
codersdk/deployment_test.go | 25 ++++++++
enterprise/coderd/license/license_test.go | 60 +++++++++++++++++++
.../dashboard/LicenseBanner/LicenseBanner.tsx | 32 +++++-----
4 files changed, 111 insertions(+), 18 deletions(-)
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index bba55f81bec..8a60e11415f 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -425,7 +425,7 @@ type UsagePeriod struct {
// 2. The usage period has a greater end date (note: only certain features use usage periods)
// 3. Graceful & capable > Entitled & not capable (only if both have "Actual" values)
// 4. The entitlement is greater
-// 5. The limit is greater
+// 5. The limit is greater (except a nil limit on a usage period feature means unlimited, outranking any set limit)
// 6. Enabled is greater than disabled
// 7. The actual is greater
//
@@ -469,11 +469,19 @@ func (f Feature) Compare(b Feature) int {
return entitlementDifference
}
- // If the entitlement is the same, then we can compare the limits.
+ // If the entitlement is the same, then we can compare the limits. A nil
+ // limit on a usage period feature means unlimited, so it outranks any set
+ // limit; on other features a nil limit loses to a set one.
if f.Limit == nil && b.Limit != nil {
+ if bothHaveUsagePeriod {
+ return 1
+ }
return -1
}
if f.Limit != nil && b.Limit == nil {
+ if bothHaveUsagePeriod {
+ return -1
+ }
return 1
}
if f.Limit != nil && b.Limit != nil {
diff --git a/codersdk/deployment_test.go b/codersdk/deployment_test.go
index de3395aac84..2abdff578e9 100644
--- a/codersdk/deployment_test.go
+++ b/codersdk/deployment_test.go
@@ -1225,6 +1225,31 @@ func TestFeatureComparison(t *testing.T) {
},
Expected: 1,
},
+ {
+ // A nil limit on a usage period feature means unlimited, so it
+ // outranks a set limit on an exact usage period tie.
+ Name: "UnlimitedUsagePeriodOutranksMeteredOnTie",
+ A: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ B: codersdk.Feature{
+ Entitlement: codersdk.EntitlementEntitled,
+ Enabled: true,
+ Limit: ptr.Ref(int64(100)),
+ UsagePeriod: &codersdk.UsagePeriod{
+ IssuedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
+ End: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC),
+ },
+ },
+ Expected: 1,
+ },
}
for _, tc := range testCases {
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index ec8e06b2f8d..68bff19da49 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2616,6 +2616,66 @@ func TestAgentRuntimeHoursLicenses(t *testing.T) {
}
})
+ // When an unlimited and a metered license are minted with identical
+ // issued-at and expiry claims, the unlimited grant must win the tie,
+ // regardless of load order.
+ t.Run("UnlimitedOutranksMeteredOnTie", func(t *testing.T) {
+ t.Parallel()
+
+ // JWT NumericDate claims have second granularity, so truncate to
+ // keep the round-tripped issued-at values identical.
+ iat := time.Now().Add(-time.Minute).Truncate(time.Second)
+ nbf := iat
+ exp := iat.Add(time.Hour).Truncate(time.Second)
+ unlimited := database.License{
+ ID: 1,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: license.AgentRuntimeHoursUnlimitedAllocation,
+ },
+ }),
+ }
+ metered := database.License{
+ ID: 2,
+ UploadedAt: time.Now(),
+ Exp: exp,
+ UUID: uuid.New(),
+ JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
+ IssuedAt: iat,
+ NotBefore: nbf,
+ ExpiresAt: exp,
+ Features: license.Features{
+ license.ClaimAgentRuntimeHoursAllocation: 100,
+ license.ClaimAgentRuntimeHoursLimitSoft: 80,
+ license.ClaimAgentRuntimeHoursLimitHard: 120,
+ },
+ }),
+ }
+
+ for _, order := range [][]database.License{
+ {unlimited, metered},
+ {metered, unlimited},
+ } {
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), order, map[codersdk.FeatureName]bool{}, coderdenttest.Keys, license.FeatureArguments{})
+ require.NoError(t, err)
+
+ feature, ok := entitlements.Features[codersdk.FeatureAgentRuntimeHours]
+ require.True(t, ok, "feature %s not found", codersdk.FeatureAgentRuntimeHours)
+ require.Equal(t, codersdk.EntitlementEntitled, feature.Entitlement)
+ require.True(t, feature.Enabled)
+ require.Nil(t, feature.Limit)
+ require.Nil(t, feature.SoftLimit)
+ require.Nil(t, feature.HardLimit)
+ require.NotNil(t, feature.UsagePeriod)
+ }
+ })
+
// A newer license without soft/hard limits must fully replace an older
// license that carried them; the limits must not merge across licenses.
t.Run("SoftHardRideAlongWithWinner", func(t *testing.T) {
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 74ce21b89fa..2218aa5cf35 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -176,22 +176,22 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map((message) => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message)
- ? ("warning" as const)
- : ("error" as const),
- link: messageLink(message),
- })),
- ...normalizedWarnings.map((message) => ({
- message,
- variant: isMutedWarning(message)
- ? ("warning" as const)
- : ("warningProminent" as const),
- link: messageLink(message),
- })),
+ ...errors.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ // Measurement diagnostics travel in the errors channel but are
+ // not license errors; see diagnosticMessages.
+ variant: isDiagnosticMessage(message) ? "warning" : "error",
+ link: messageLink(message),
+ }),
+ ),
+ ...normalizedWarnings.map(
+ (message): LicenseBannerMessage => ({
+ message,
+ variant: isMutedWarning(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ }),
+ ),
];
if (messages.length === 0) {
From 5a29e12e224ca7ce3e24c088b6d7ef37cf5b61ac Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Wed, 12 Aug 2026 13:10:13 +0000
Subject: [PATCH 113/116] fix(enterprise/coderd/license): accept issuer-valid
zero soft limit for agent runtime hours
The license issuer validates the soft limit as 0 <= soft < allocation,
but this decoder dropped soft=0 as an unusable claim and warned. Accept
zero so every license the issuer mints decodes cleanly; omitting the
claim remains the way to express no soft limit.
---
enterprise/coderd/license/license.go | 4 ++--
enterprise/coderd/license/license_test.go | 13 ++++++-------
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index 66b55f1feea..f2a6924bf57 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -930,7 +930,7 @@ const (
// the license does not grant the feature.
ClaimAgentRuntimeHoursAllocation = "agent_runtime_hours_allocation"
// ClaimAgentRuntimeHoursLimitSoft is the advisory warning threshold. It
- // becomes the feature's SoftLimit when 0 < soft < allocation and is
+ // becomes the feature's SoftLimit when 0 <= soft < allocation and is
// ignored otherwise.
ClaimAgentRuntimeHoursLimitSoft = "agent_runtime_hours_limit_soft"
// ClaimAgentRuntimeHoursLimitHard is the enforcement ceiling. It becomes
@@ -1031,7 +1031,7 @@ func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement
UsagePeriod: &usagePeriod,
}
if softOk {
- if soft > 0 && soft < allocation {
+ if soft >= 0 && soft < allocation {
feature.SoftLimit = &soft
} else {
ignoredClaims = append(ignoredClaims, ClaimAgentRuntimeHoursLimitSoft)
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 68bff19da49..66c46b3b691 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -2857,20 +2857,19 @@ func TestAgentRuntimeHoursClaimTolerance(t *testing.T) {
},
},
{
- // A zero soft limit would warn at zero usage forever, so it is
- // dropped rather than rejecting the license. The canonical way
- // to express "no soft limit" is omitting the claim, so a
- // present-but-dropped zero still warns.
+ // A zero soft limit is valid (0 <= soft < allocation) and warns
+ // from the start of the usage period. Omitting the claim is the
+ // way to express "no soft limit".
name: "ZeroSoft",
features: license.Features{
license.ClaimAgentRuntimeHoursAllocation: 100,
license.ClaimAgentRuntimeHoursLimitSoft: 0,
},
expectFeature: &codersdk.Feature{
- Enabled: true,
- Limit: ptr.Ref[int64](100),
+ Enabled: true,
+ Limit: ptr.Ref[int64](100),
+ SoftLimit: ptr.Ref[int64](0),
},
- expectClaimsIgnored: true,
},
{
name: "NegativeSoft",
From 128d73dd535387b029758a0b63a6052dc350b834 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Thu, 13 Aug 2026 12:20:55 +0000
Subject: [PATCH 114/116] chore: leave managed agent measurement untouched by
license diagnostics
Managed agents are deprecated and slated for removal, so the stable
measurement diagnostics no longer convert that path: the measureUsage
helper, the stable LicenseManagedAgentUsageUnavailableErrorText, the
AsUsagePublisher subject switch, and the direct Features map write are
dropped, restoring the original managed agent count handling. The
runtime hours claim tolerance, the -1 unlimited allocation, and the
muted diagnostics rendering (now seeded only with the claims-ignored
warning) are unchanged.
---
codersdk/licenses.go | 1 -
enterprise/coderd/license/license.go | 62 ++----
enterprise/coderd/license/license_test.go | 182 +-----------------
site/src/api/typesGenerated.ts | 4 -
.../dashboard/LicenseBanner/LicenseBanner.tsx | 2 -
.../LicenseBannerView.stories.tsx | 10 -
6 files changed, 26 insertions(+), 235 deletions(-)
diff --git a/codersdk/licenses.go b/codersdk/licenses.go
index 6f277f17510..414cfbcf04b 100644
--- a/codersdk/licenses.go
+++ b/codersdk/licenses.go
@@ -17,7 +17,6 @@ const (
LicenseManagedAgentLimitExceededWarningText = "You have built more workspaces with managed agents than your license allows."
LicenseAIGovernance90PercentWarningText = "You have used %d%% of your AI Governance add-on seats."
LicenseAIGovernanceOverLimitWarningText = "Your organization is using %d of %d AI Governance add-on seats (%d over the limit)."
- LicenseManagedAgentUsageUnavailableErrorText = "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details."
LicenseAgentRuntimeHoursClaimsIgnoredWarningText = "A license contains unusable Coder Agent runtime hour claims, which were ignored. The rest of that license is unaffected. Check the coderd logs for the affected license and claims, and contact support to have the license re-issued."
)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index f2a6924bf57..c4fe1ebcf4e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -115,8 +115,8 @@ func Entitlements(
// licenses (e.g. higher hard limit) to account for additional
// usage.
//
- // nolint:gocritic // Reading usage events requires the usage publisher subject.
- return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsUsagePublisher(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
+ // nolint:gocritic // Requires permission to read all workspaces to read managed agent count.
+ return db.GetTotalUsageDCManagedAgentsV1(dbauthz.AsSystemRestricted(ctx), database.GetTotalUsageDCManagedAgentsV1Params{
StartDate: startTime,
EndDate: endTime,
})
@@ -709,17 +709,24 @@ func LicensesEntitlements(
if entitlements.HasLicense && agentLimit.UsagePeriod != nil {
// Calculate the amount of agents between the usage period start and
// end.
- managedAgentCount, ok, err := measureUsage(ctx, &entitlements,
- featureArguments.Logger, featureArguments.ManagedAgentCountFn, *agentLimit.UsagePeriod,
- "managed agent count", codersdk.LicenseManagedAgentUsageUnavailableErrorText)
- if err != nil {
- return entitlements, err
+ var (
+ managedAgentCount int64
+ err = xerrors.New("dev error: managed agent count function is not set")
+ )
+ if featureArguments.ManagedAgentCountFn != nil {
+ managedAgentCount, err = featureArguments.ManagedAgentCountFn(ctx, agentLimit.UsagePeriod.Start, agentLimit.UsagePeriod.End)
+ }
+ if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
+ // If the context is canceled, we want to bail the entire
+ // LicensesEntitlements call.
+ return entitlements, xerrors.Errorf("get managed agent count: %w", err)
}
- if ok {
+ if err != nil {
+ entitlements.Errors = append(entitlements.Errors, fmt.Sprintf("Error getting managed agent count: %s", err.Error()))
+ // no return
+ } else {
agentLimit.Actual = &managedAgentCount
- // Write directly rather than via AddFeature so its Compare
- // cannot drop the update.
- entitlements.Features[codersdk.FeatureManagedAgentLimit] = agentLimit
+ entitlements.AddFeature(codersdk.FeatureManagedAgentLimit, agentLimit)
// Only issue warnings if the feature is enabled.
if agentLimit.Enabled && agentLimit.Limit != nil && managedAgentCount >= *agentLimit.Limit {
@@ -858,39 +865,6 @@ func LicensesEntitlements(
return entitlements, nil
}
-// measureUsage runs fn over the feature's usage period. A nil fn or a
-// failure with a dead context fails the whole call; any other failure logs
-// the cause and publishes unavailableText instead. It returns the measured
-// value and true only on success.
-func measureUsage(
- ctx context.Context,
- entitlements *codersdk.Entitlements,
- logger slog.Logger,
- fn func(ctx context.Context, from time.Time, to time.Time) (int64, error),
- usagePeriod codersdk.UsagePeriod,
- what string,
- unavailableText string,
-) (int64, bool, error) {
- if fn == nil {
- return 0, false, xerrors.Errorf("developer error: no closure provided to measure %s usage", what)
- }
- value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
- switch {
- case err != nil && ctx.Err() != nil:
- // Do not classify cancellation by error shape instead of ctx.Err():
- // Postgres raises SQLSTATE 57014 (query_canceled) for
- // statement_timeout kills as well as client cancels, and aborting on
- // those would fail every entitlements refresh on a deployment whose
- // statement_timeout is shorter than a usage query.
- return 0, false, xerrors.Errorf("get %s: %w", what, err)
- case err != nil:
- logger.Error(ctx, fmt.Sprintf("get %s for entitlements", what), slog.Error(err))
- entitlements.Errors = append(entitlements.Errors, unavailableText)
- return 0, false, nil
- }
- return value, true, nil
-}
-
func appendAIGovernanceSeatLimitWarning(warnings []string, actual int64, limit int64) []string {
if limit <= 0 {
return warnings
diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go
index 66c46b3b691..24bd86b326c 100644
--- a/enterprise/coderd/license/license_test.go
+++ b/enterprise/coderd/license/license_test.go
@@ -15,11 +15,9 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
- "golang.org/x/xerrors"
"cdr.dev/slog/v3"
"cdr.dev/slog/v3/sloggers/sloghuman"
- "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/database/dbtestutil"
@@ -37,42 +35,6 @@ import (
// licensing experiment, so it is never asked to authorize anything.
var testAuthorizer = rbac.NewCachingAuthorizer(prometheus.NewRegistry())
-// premiumRuntimeHoursFixture returns a mock store primed with a Premium
-// license carrying runtime hour claims (allocation 100, soft limit 80, hard
-// limit 120) plus the store expectations every entitlements refresh consumes
-// before usage is measured. Callers add expectations for the usage queries
-// under test.
-func premiumRuntimeHoursFixture(t *testing.T) (*dbmock.MockStore, *coderdenttest.LicenseOptions) {
- t.Helper()
-
- ctrl := gomock.NewController(t)
- mDB := dbmock.NewMockStore(ctrl)
-
- licenseOpts := (&coderdenttest.LicenseOptions{
- FeatureSet: codersdk.FeatureSetPremium,
- IssuedAt: dbtime.Now().Add(-2 * time.Hour).Truncate(time.Second),
- NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
- GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second), // 60 days to remove warning
- ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second), // 90 days to remove warning
- // The addon marks AI Bridge as explicitly entitled, suppressing
- // the unrelated "AI Governance add-on is required to use AI
- // Gateway" warning that Premium would otherwise produce.
- }).UserLimit(100).AIGovernanceAddon(100).AgentRuntimeHours(100, ptr.Ref[int64](80), ptr.Ref[int64](120))
-
- lic := database.License{
- ID: 1,
- JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
- Exp: licenseOpts.ExpiresAt,
- }
-
- mDB.EXPECT().GetUnexpiredLicenses(gomock.Any()).Return([]database.License{lic}, nil)
- mDB.EXPECT().GetActiveUserCount(gomock.Any(), false).Return(int64(1), nil)
- mDB.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(0), nil)
- mDB.EXPECT().GetTemplatesWithFilter(gomock.Any(), gomock.Any()).Return([]database.Template{}, nil)
-
- return mDB, licenseOpts
-}
-
func TestEntitlements(t *testing.T) {
t.Parallel()
all := make(map[codersdk.FeatureName]bool)
@@ -963,62 +925,6 @@ func TestEntitlements(t *testing.T) {
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
})
- t.Run("UsageQueryErrorsAreLoggedAndStable", func(t *testing.T) {
- t.Parallel()
-
- // Drive the real Entitlements closure with a mock database so
- // measureUsage's failure path is exercised end to end: the cause
- // must land in the coderd log, which the stable payload text points
- // at, and must not land on the unauthenticated entitlements payload.
- mDB, _ := premiumRuntimeHoursFixture(t)
-
- mDB.EXPECT().
- GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
- Return(int64(0), xerrors.New("kaboom managed"))
-
- // The error-level logs are the behavior under test, so the default
- // failing test logger cannot be used.
- var logBuf bytes.Buffer
- logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).
- AppendSinks(sloghuman.Sink(&logBuf))
-
- entitlements, err := license.Entitlements(context.Background(), logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
- require.NoError(t, err)
- require.True(t, entitlements.HasLicense)
-
- // The failure surfaces its stable text without the raw cause.
- require.Contains(t, entitlements.Errors, codersdk.LicenseManagedAgentUsageUnavailableErrorText)
- for _, entry := range append(entitlements.Errors, entitlements.Warnings...) {
- require.NotContains(t, entry, "kaboom")
- }
-
- logs := logBuf.String()
- require.Contains(t, logs, "get managed agent count for entitlements")
- require.Contains(t, logs, "kaboom managed")
- })
-
- t.Run("UsageQueryCancelDoesNotLogError", func(t *testing.T) {
- t.Parallel()
-
- // A query failing while the refresh's own context is canceled,
- // e.g. during shutdown, aborts the whole entitlements refresh and
- // must not log a false query-failure alarm at error level.
- mDB, _ := premiumRuntimeHoursFixture(t)
-
- mDB.EXPECT().
- GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
- Return(int64(0), context.Canceled)
-
- var logBuf bytes.Buffer
- logger := testutil.Logger(t).AppendSinks(sloghuman.Sink(&logBuf))
-
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- _, err := license.Entitlements(ctx, logger, mDB, 1, 0, coderdenttest.Keys, all, testAuthorizer, nil)
- require.ErrorContains(t, err, "get managed agent count")
- require.NotContains(t, logBuf.String(), "get managed agent count for entitlements")
- })
-
t.Run("AIGovernanceSeatWarnings", func(t *testing.T) {
t.Parallel()
@@ -1398,12 +1304,6 @@ func TestLicenseEntitlements(t *testing.T) {
Licenses []*coderdenttest.LicenseOptions
Enablements map[codersdk.FeatureName]bool
Arguments license.FeatureArguments
- // KeepNilManagedAgentCountFn skips the default ManagedAgentCountFn
- // injection below so the nil dev-error path can be exercised.
- KeepNilManagedAgentCountFn bool
- // CancelContext cancels the context passed to LicensesEntitlements
- // before the call, exercising the usage-measurement abort policy.
- CancelContext bool
ExpectedErrorContains string
AssertEntitlements func(t *testing.T, entitlements codersdk.Entitlements)
@@ -1655,58 +1555,6 @@ func TestLicenseEntitlements(t *testing.T) {
assert.Equal(t, int64(150), *feature.Actual)
},
},
- {
- // A query failure is surfaced as a stable text in Errors and
- // leaves Actual unset without aborting the rest of the
- // entitlements.
- Name: "ManagedAgentLimit/QueryError",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- Arguments: license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, xerrors.New("kaboom")
- },
- },
- AssertEntitlements: func(t *testing.T, entitlements codersdk.Entitlements) {
- assertNoWarnings(t, entitlements)
- require.Len(t, entitlements.Errors, 1)
- assert.Equal(t, codersdk.LicenseManagedAgentUsageUnavailableErrorText, entitlements.Errors[0])
- // The raw error is logged rather than exposed on the
- // unauthenticated entitlements payload.
- assert.NotContains(t, entitlements.Errors[0], "kaboom")
- feature := entitlements.Features[codersdk.FeatureManagedAgentLimit]
- assert.Nil(t, feature.Actual)
- },
- },
- {
- // Forgetting to wire ManagedAgentCountFn is a dev error:
- // production always provides the closure, so it fails the whole
- // call loudly instead of degrading into an operator-facing
- // message.
- Name: "ManagedAgentLimit/NilFnDevError",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- KeepNilManagedAgentCountFn: true,
- ExpectedErrorContains: "developer error: no closure provided to measure managed agent count usage",
- },
- {
- // A failure while the computation's own context is canceled
- // aborts the whole call rather than degrading to an
- // entitlements error.
- Name: "ManagedAgentLimit/ContextCanceled",
- Licenses: []*coderdenttest.LicenseOptions{
- enterpriseLicense().UserLimit(100).ManagedAgentLimit(100),
- },
- CancelContext: true,
- Arguments: license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, context.Canceled
- },
- },
- ExpectedErrorContains: "get managed agent count",
- },
{
Name: "ExternalTemplate",
Licenses: []*coderdenttest.LicenseOptions{
@@ -1738,18 +1586,13 @@ func TestLicenseEntitlements(t *testing.T) {
}
// Default to 0 managed agent count.
- if tc.Arguments.ManagedAgentCountFn == nil && !tc.KeepNilManagedAgentCountFn {
+ if tc.Arguments.ManagedAgentCountFn == nil {
tc.Arguments.ManagedAgentCountFn = func(ctx context.Context, from time.Time, to time.Time) (int64, error) {
return 0, nil
}
}
- ctx := context.Background()
- if tc.CancelContext {
- var cancel context.CancelFunc
- ctx, cancel = context.WithCancel(ctx)
- cancel()
- }
- entitlements, err := license.LicensesEntitlements(ctx, time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
+
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, tc.Enablements, coderdenttest.Keys, tc.Arguments)
if tc.ExpectedErrorContains != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.ExpectedErrorContains)
@@ -1774,15 +1617,6 @@ func TestAIBridgeSoftWarning(t *testing.T) {
aiBridgeWarningMessage := "The AI Governance add-on is required to use AI Gateway. Please reach out to your account team or sales@coder.com to learn more."
- // A Premium license grants a managed agent limit by default, and a nil
- // usage closure is a hard developer error, so these subtests wire a
- // zero-usage measurement closure.
- zeroUsageArgs := license.FeatureArguments{
- ManagedAgentCountFn: func(_ context.Context, _, _ time.Time) (int64, error) {
- return 0, nil
- },
- }
-
t.Run("NoAddon_AIBridgeOff", func(t *testing.T) {
t.Parallel()
// License without addon and AI Bridge disabled should NOT show warning.
@@ -1802,7 +1636,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1829,7 +1663,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1861,7 +1695,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeDisabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1892,7 +1726,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
},
}
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), generatedLicenses, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
@@ -1905,7 +1739,7 @@ func TestAIBridgeSoftWarning(t *testing.T) {
t.Parallel()
// No license with AI Bridge enabled should NOT show the soft warning
// (it will show the generic "not entitled" warning instead).
- entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, zeroUsageArgs)
+ entitlements, err := license.LicensesEntitlements(context.Background(), time.Now(), []database.License{}, aiBridgeEnabledEnablements, coderdenttest.Keys, license.FeatureArguments{})
require.NoError(t, err)
aiBridgeFeature := entitlements.Features[codersdk.FeatureAIBridge]
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index ca43735132e..177f4c28af8 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -5768,10 +5768,6 @@ export const LicenseExpiryClaim = "license_expires";
export const LicenseManagedAgentLimitExceededWarningText =
"You have built more workspaces with managed agents than your license allows.";
-// From codersdk/licenses.go
-export const LicenseManagedAgentUsageUnavailableErrorText =
- "Unable to determine managed agent usage. The reported count is unavailable until the next successful refresh; workspaces are unaffected. Check the coderd logs for details.";
-
// From codersdk/licenses.go
export const LicenseTelemetryRequiredErrorText =
"License requires telemetry but telemetry is disabled";
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index 2218aa5cf35..a6c4cf072ce 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -4,7 +4,6 @@ import {
LicenseAIGovernance90PercentWarningText,
LicenseAIGovernanceOverLimitWarningText,
LicenseManagedAgentLimitExceededWarningText,
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import { useDashboard } from "#/modules/dashboard/useDashboard";
@@ -38,7 +37,6 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
// usage itself. They render muted, without the exceedance heading or a sales
// link, even when they arrive via entitlements.errors.
const diagnosticMessages: readonly string[] = [
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
];
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index 7c2e67c9a71..c351a417655 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -5,7 +5,6 @@ import {
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
LicenseAIGovernance90PercentWarningText,
LicenseManagedAgentLimitExceededWarningText,
- LicenseManagedAgentUsageUnavailableErrorText,
LicenseTelemetryRequiredErrorText,
} from "#/api/typesGenerated";
import {
@@ -306,14 +305,6 @@ const playMutedDiagnostic =
).not.toBeInTheDocument();
};
-export const ManagedAgentUsageUnavailable: Story = {
- render: () =>
- renderLicenseBanner({
- errors: [LicenseManagedAgentUsageUnavailableErrorText],
- }),
- play: playMutedDiagnostic(LicenseManagedAgentUsageUnavailableErrorText),
-};
-
export const AgentRuntimeHoursClaimsIgnored: Story = {
render: () =>
renderLicenseBanner({
@@ -326,7 +317,6 @@ export const AgentRuntimeHoursClaimsIgnored: Story = {
export const UsageDiagnosticsOnlyHeading: Story = {
render: () =>
renderLicenseBanner({
- errors: [LicenseManagedAgentUsageUnavailableErrorText],
warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
}),
play: async ({ canvasElement }) => {
From 21575f16e26af18b16de664ccebd90d80ba21403 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:52:20 +0000
Subject: [PATCH 115/116] fix(site/src/modules/dashboard/LicenseBanner): render
notices heading for a lone diagnostic
A single claims-ignored diagnostic took the single-message path and
rendered without any heading, contradicting the UsageDiagnosticsOnlyHeading
story, which only the pixel snapshot job exercised in CI.
Classify each entitlements message once in toBannerMessage and carry the
result as a structured kind on LicenseBannerMessage, so the view branches
on data instead of re-matching display text. The view now keeps the
"License notices" heading for a lone diagnostic while lone advisories,
warnings, and errors stay heading-less.
---
.../dashboard/LicenseBanner/LicenseBanner.tsx | 55 ++++++++++---------
.../LicenseBannerView.stories.tsx | 3 +
.../LicenseBanner/LicenseBannerView.tsx | 25 ++++++---
3 files changed, 49 insertions(+), 34 deletions(-)
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
index a6c4cf072ce..2c33e9fcbf4 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBanner.tsx
@@ -43,11 +43,10 @@ const diagnosticMessages: readonly string[] = [
const isDiagnosticMessage = (message: string): boolean =>
diagnosticMessages.includes(message);
-// Advisories and diagnostics render muted to stay visually distinct from
-// warnings that demand action, such as exceeding a license limit.
-const isMutedWarning = (message: string): boolean =>
- message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
- isDiagnosticMessage(message);
+// Advisories render muted to stay visually distinct from warnings that
+// demand action, such as exceeding a license limit.
+const isAdvisoryMessage = (message: string): boolean =>
+ message.startsWith(aiGovernanceNearLimitWarningPrefix);
const aiGovernanceOverLimitMessage = (
feature: ReturnType<
@@ -140,11 +139,6 @@ const messageLink = (message: string): LicenseBannerLink | undefined => {
showExternalIcon: false,
};
}
- // Diagnostics point the operator at the logs or support, so they do not
- // get a sales link.
- if (isDiagnosticMessage(message)) {
- return undefined;
- }
return {
href: "mailto:sales@coder.com",
label: "Contact sales@coder.com.",
@@ -152,6 +146,29 @@ const messageLink = (message: string): LicenseBannerLink | undefined => {
};
};
+// Classifies a raw entitlements message once and carries the result as
+// structured message data, so rendering branches on the message's kind and
+// variant fields rather than re-matching display text.
+const toBannerMessage = (
+ message: string,
+ channel: "errors" | "warnings",
+): LicenseBannerMessage => {
+ // Measurement diagnostics travel in the errors channel but are not
+ // license errors. They render muted and without a sales link: they point
+ // the operator at the logs, not at sales.
+ if (isDiagnosticMessage(message)) {
+ return { message, variant: "warning", kind: "diagnostic" };
+ }
+ if (channel === "errors") {
+ return { message, variant: "error", link: messageLink(message) };
+ }
+ return {
+ message,
+ variant: isAdvisoryMessage(message) ? "warning" : "warningProminent",
+ link: messageLink(message),
+ };
+};
+
export const LicenseBanner: FC = () => {
const { entitlements } = useDashboard();
const { errors } = entitlements;
@@ -174,21 +191,9 @@ export const LicenseBanner: FC = () => {
);
const messages: LicenseBannerMessage[] = [
- ...errors.map(
- (message): LicenseBannerMessage => ({
- message,
- // Measurement diagnostics travel in the errors channel but are
- // not license errors; see diagnosticMessages.
- variant: isDiagnosticMessage(message) ? "warning" : "error",
- link: messageLink(message),
- }),
- ),
- ...normalizedWarnings.map(
- (message): LicenseBannerMessage => ({
- message,
- variant: isMutedWarning(message) ? "warning" : "warningProminent",
- link: messageLink(message),
- }),
+ ...errors.map((message) => toBannerMessage(message, "errors")),
+ ...normalizedWarnings.map((message) =>
+ toBannerMessage(message, "warnings"),
),
];
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
index c351a417655..94b04f29e5d 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.stories.tsx
@@ -260,6 +260,9 @@ export const AIGovernanceNearLimit: Story = {
await expect(
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
).toHaveAttribute("href", "mailto:sales@coder.com");
+ // A lone advisory is muted but not a diagnostic, so it renders
+ // without the notices heading.
+ await expect(canvas.queryByText("License notices")).not.toBeInTheDocument();
},
};
diff --git a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
index 2cef6164395..8212fe70736 100644
--- a/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
+++ b/site/src/modules/dashboard/LicenseBanner/LicenseBannerView.tsx
@@ -25,6 +25,10 @@ export interface LicenseBannerLink {
export interface LicenseBannerMessage {
message: string;
variant: LicenseBannerVariant;
+ // Diagnostics about the license or the usage measurement rather than
+ // about usage itself. They keep the "License notices" heading even when
+ // they are the only message, since the muted text needs that context.
+ kind?: "diagnostic";
link?: LicenseBannerLink;
}
@@ -146,6 +150,9 @@ export const LicenseBannerView: React.FC = ({
const bannerVariant = getBannerVariant(messages);
const visibleMessages = messages.slice(0, 2);
const hiddenMessages = messages.slice(2);
+ // A lone diagnostic keeps the heading: without it the muted banner is an
+ // unexplained sentence. Other single messages stay heading-less.
+ const showHeading = !isSingleMessage || messages[0].kind === "diagnostic";
return (
= ({
/>
+ {showHeading && (
+
+ {bannerTitle(bannerVariant)}
+
+ )}
{isSingleMessage ? (
) : (
- <>
-
- {bannerTitle(bannerVariant)}
-
-
- >
+
)}
From 845b6de82ff707b599ab18951408a92b2659e896 Mon Sep 17 00:00:00 2001
From: Jaayden Halko
Date: Fri, 14 Aug 2026 04:55:40 +0000
Subject: [PATCH 116/116] chore(enterprise/coderd/license): document only
current zero-allocation behavior
Drop the speculative CODAGT-856 concurrency-mode sentence from the
decodeAgentRuntimeHours doc comment. The comment now states only the
implemented contract: a zero allocation grants the feature disabled while
Actual is still measured and published.
---
enterprise/coderd/license/license.go | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go
index c4fe1ebcf4e..366a853706e 100644
--- a/enterprise/coderd/license/license.go
+++ b/enterprise/coderd/license/license.go
@@ -960,8 +960,7 @@ func isAgentRuntimeHoursClaim(name codersdk.FeatureName) bool {
// look healthy.
//
// A zero allocation grants the feature disabled, but Actual is still
-// measured and published. CODAGT-856 will make a zero allocation force a
-// concurrency-limited mode; that mode does not exist yet.
+// measured and published.
func decodeAgentRuntimeHours(features Features, entitlement codersdk.Entitlement, usagePeriod codersdk.UsagePeriod) (feature codersdk.Feature, granted bool, ignoredClaims []string) {
if _, ok := features[codersdk.FeatureAgentRuntimeHours]; ok {
ignoredClaims = append(ignoredClaims, string(codersdk.FeatureAgentRuntimeHours))