From ed531d9d36561da4cbab19a78d3f6f7ceeaccac1 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 10 Aug 2026 10:37:13 +0000 Subject: [PATCH 01/56] 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 5aee2385c2958..83eb7e88b730d 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 550fd9b56fd03..f64981add0360 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 37db6f733cc5a..94d0068692306 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 3927ab28e31dd..4d5ffdf4744c3 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 4315def76659e..1523ba1228527 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 02/56] 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 dbf0debfcab50..e90397c9ec9c1 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 808111c37645f..b209504c0d8ef 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 0000000000000..ed8b4ae78b8f4 --- /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 0000000000000..33c0c28f1a7ab --- /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 882e4cb9d58a8..05147dad10942 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 e84b81b79ccab..abb2d0c39e1b4 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 e499234f558db..20b5f33bea53f 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 8ba706b0c8ae9..03f435c5af76d 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 4b1a4376f2db4..23256ed3b86fc 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 91d911b57a71f..a5b6788a214f4 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 0000000000000..fa6aeb6344055 --- /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 03/56] 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 24a8366b62b8b..9cb0dbc88b194 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 e023da7b1df28..7be0140535333 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 1115ba12118c7..8f5f1e4a708d6 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 8cce657de65a8..e6f3598475ead 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 6fe509028502a..db68fb8b60adc 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 c88f975f2c002..811929f09390a 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 ba98c91351f5d..3f7d38b761fec 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 ba5f739d93d2c..9ef4dda8bb10b 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 582493a4eff2a..6c3dd4e662b86 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 80674fcbb7e2f..bd278dc591c4b 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 04/56] 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 39e1a97f23ee7..769a17ff1ab9a 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 e70e01bad930e..738600449bcb9 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 58e8b989b259d..056c3354dc2b7 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 c0cd5e280b853..c553c6e1afe53 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 2cbc97102869f..a664865da2a19 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 38ae689b768e4..e7bb6ba9381d8 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 05147dad10942..5b01b474b1b9f 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 abb2d0c39e1b4..beacc1a0359c0 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 20b5f33bea53f..cb2f177d01ab0 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 03f435c5af76d..563f392f20fee 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 0000000000000..798a340533591 --- /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 14eca1624ec45..2edeb087a8e92 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 9cb0dbc88b194..444a68bd91cf0 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 0000000000000..2042cc50aaf2a --- /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 1dc9283ebea18..105cdd7d6c7fc 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 e6f3598475ead..b238e514d3621 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 616f0b5b989b9..161c980e436c8 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 db68fb8b60adc..0d9e38198dbb3 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 811929f09390a..d006a5b16d00e 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 3f7d38b761fec..f85d83a914331 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 9ef4dda8bb10b..0a37a582d3f42 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 6c3dd4e662b86..76443dc50cd1d 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 05/56] 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 83eb7e88b730d..5aee2385c2958 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 f64981add0360..550fd9b56fd03 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 94d0068692306..37db6f733cc5a 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 4d5ffdf4744c3..3927ab28e31dd 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 1523ba1228527..4315def76659e 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 06/56] 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 769a17ff1ab9a..781add1362b07 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 738600449bcb9..5a03174e986b3 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 2edeb087a8e92..95f2708b5aeac 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 105cdd7d6c7fc..f0541373c1fd5 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 b238e514d3621..cf2b19fa9f10d 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 0d9e38198dbb3..4f41a3bf00625 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 f85d83a914331..00a3bdc848b39 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 07/56] 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 ed8b4ae78b8f4..8a6a02409885e 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 08/56] 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 e6f3598475ead..8911f695d3df1 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 db68fb8b60adc..0e393939c5d45 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 09/56] 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 781add1362b07..140d2981b1683 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 5a03174e986b3..3304101a10b8b 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 95f2708b5aeac..5d3604af944f4 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 de3395aac84ab..eb0743804e546 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 f0541373c1fd5..4ad9305da59a6 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 445a84978bc09..ccf6d038f6c88 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 18bc98eb26ca4..fb2a26c5079e7 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 00a3bdc848b39..52e89873483c3 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 10/56] 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 6c3dd4e662b86..4f2030a30807e 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 bd278dc591c4b..453b925fe6dad 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 11/56] 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 05147dad10942..882e4cb9d58a8 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 20b5f33bea53f..e499234f558db 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 03f435c5af76d..8ba706b0c8ae9 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 12/56] 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 9cb0dbc88b194..ee891740e2984 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 8f5f1e4a708d6..1d2678fd787d1 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 8911f695d3df1..66b55f1feead4 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 0e393939c5d45..ec8e06b2f8de9 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 3f7d38b761fec..59e05c9351429 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 9ef4dda8bb10b..74ce21b89facf 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 4f2030a30807e..7c2e67c9a7172 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 453b925fe6dad..2cef6164395bd 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 13/56] 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 14/56] 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 a5b6788a214f4..af7fcd713eea8 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 15/56] 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 241945c7c1c71..5c7fd4d67d726 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 16/56] 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 14eca1624ec45..7f22eaf25122b 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 de3395aac84ab..2abdff578e910 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 ec8e06b2f8de9..68bff19da496c 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 74ce21b89facf..2218aa5cf35d3 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 f00afbb9a8ace9bee338156f15be809e08314eda Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 12 Aug 2026 07:18:39 +0000 Subject: [PATCH 17/56] feat(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): add per-license Products section with Coder Agents price gates Each license card now always expands to a Products section: a Coder Workspaces box with active seat usage, and (on Premium licenses) a Coder Agents box driven by the agent_runtime_hours_* license claims and merged agent_runtime_hours entitlement. The Coder Agents box renders five states: no allocation (dashed purple upgrade CTA), unlimited (-1 sentinel), normal usage, allocation exceeded (red border, red 'Agent hours exceeded' status, chats stay Unlimited), and hard limit exceeded (red 'Hard limit exceeded' status, chats capped at 5, mirroring the backend's maxConcurrentRootAgents). Usage and overage indicators follow the AI Governance winning-license pattern via a generalized isLicenseApplicableForFeatureUsage helper. The header gains a Type column (Trial/Standard) and the left label now shows the feature set only (Premium/Enterprise). --- .../AIGovernanceLicensing.ts | 30 +-- .../CoderAgentsProductCard.stories.tsx | 134 ++++++++++++ .../CoderAgentsProductCard.tsx | 177 ++++++++++++++++ .../CoderWorkspacesProductCard.stories.tsx | 63 ++++++ .../CoderWorkspacesProductCard.tsx | 55 +++++ .../LicenseCard.stories.tsx | 200 +++++++++++++++++- .../LicensesSettingsPage/LicenseCard.tsx | 173 ++++++++++----- .../LicensesSettingsPage.tsx | 3 + .../LicensesSettingsPageView.tsx | 3 + .../licenseApplicability.ts | 24 +++ 10 files changed, 780 insertions(+), 82 deletions(-) create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx create mode 100644 site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts index cbae3e582a5e2..bfe1eaa25301f 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceLicensing.ts @@ -1,6 +1,6 @@ -import dayjs from "dayjs"; import type { GetLicensesResponse } from "#/api/api"; import type { Feature } from "#/api/typesGenerated"; +import { isLicenseApplicableForFeatureUsage } from "./licenseApplicability"; function isPremiumLicense(license: GetLicensesResponse): boolean { return license.claims.feature_set?.toLowerCase() === "premium"; @@ -19,24 +19,6 @@ export function licenseShowsAiGovernanceAddOn( ); } -export function isLicenseApplicableForAiGovernanceOverage( - license: GetLicensesResponse, - aiGovernanceUserFeature: Feature | undefined, -): boolean { - const isExpired = dayjs - .unix(license.claims.license_expires) - .isBefore(dayjs()); - const isNotYetValid = - license.claims.nbf !== undefined && - dayjs.unix(license.claims.nbf).isAfter(dayjs()); - const isAiGovernanceEntitlementInGracePeriod = - aiGovernanceUserFeature?.entitlement === "grace_period"; - - return ( - !isNotYetValid && (!isExpired || isAiGovernanceEntitlementInGracePeriod) - ); -} - export function hasAiGovernanceAddOnLicense( licenses: GetLicensesResponse[] | undefined, aiGovernanceUserFeature: Feature | undefined, @@ -45,10 +27,7 @@ export function hasAiGovernanceAddOnLicense( licenses?.some( (license) => licenseShowsAiGovernanceAddOn(license) && - isLicenseApplicableForAiGovernanceOverage( - license, - aiGovernanceUserFeature, - ), + isLicenseApplicableForFeatureUsage(license, aiGovernanceUserFeature), ) ?? false ); } @@ -65,10 +44,7 @@ function aiGovernanceLimitFromLicenses( .filter( (license) => licenseShowsAiGovernanceAddOn(license) && - isLicenseApplicableForAiGovernanceOverage( - license, - aiGovernanceUserFeature, - ), + isLicenseApplicableForFeatureUsage(license, aiGovernanceUserFeature), ) .map((license) => license.claims.features?.ai_governance_user_limit) .filter((limit): limit is number => limit !== undefined); diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx new file mode 100644 index 0000000000000..0f51a52a54b72 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -0,0 +1,134 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { CoderAgentsProductCard } from "./CoderAgentsProductCard"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard", + component: CoderAgentsProductCard, + args: { + allocation: 20000, + actual: 16264, + isExceeded: false, + isHardLimitExceeded: false, + }, +}; + +export default meta; +type Story = StoryObj; + +const getMetricValue = (canvas: ReturnType, label: string) => + canvas.getByText(label).parentElement?.nextElementSibling; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "16,264 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + const manageUsage = canvas.getByRole("link", { name: "Manage usage" }); + await expect(manageUsage).toHaveAttribute("href", "/deployment/groups"); + const agentSettings = canvas.getByRole("link", { name: "Agent settings" }); + await expect(agentSettings).toHaveAttribute( + "href", + "/ai/settings/coder-agents", + ); + }, +}; + +export const UnlimitedAllocation: Story = { + args: { + allocation: -1, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "Unlimited", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +export const NotProvidingUsage: Story = { + args: { + actual: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "\u2014 / 20,000", + ); + }, +}; + +export const Exceeded: Story = { + args: { + actual: 21000, + isExceeded: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "21,000 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +export const HardLimitExceeded: Story = { + args: { + actual: 25000, + isHardLimitExceeded: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "25,000 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "5", + ); + }, +}; + +export const NoAllocation: Story = { + args: { + allocation: undefined, + actual: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + getMetricValue(canvas, "Max concurrent chats"), + ).toHaveTextContent("5"); + await expect( + canvas.queryByText(/Agent hours used/), + ).not.toBeInTheDocument(); + const upgrade = canvas.getByRole("link", { name: "Upgrade" }); + await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); + }, +}; + +export const NoAllocationWithUsage: Story = { + args: { + allocation: undefined, + actual: 1234, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( + "Agent hours used: 1,234", + ); + await expect( + canvas.getByRole("link", { name: "Upgrade" }), + ).toBeInTheDocument(); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx new file mode 100644 index 0000000000000..3154054b4f348 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -0,0 +1,177 @@ +import { InfoIcon } from "lucide-react"; +import type { FC, ReactNode } from "react"; +import { Link as RouterLink } from "react-router"; +import { Button } from "#/components/Button/Button"; +import { Link } from "#/components/Link/Link"; +import { Separator } from "#/components/Separator/Separator"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; +import { cn } from "#/utils/cn"; + +// Sentinel allocation claim value meaning the license grants unlimited +// agent runtime hours (AgentRuntimeHoursUnlimitedAllocation in +// enterprise/coderd/license). +const unlimitedAllocation = -1; + +// Mirrors the backend's maxConcurrentRootAgents constant, which caps +// concurrent chats once the hard limit is reached. It is not exposed via +// the API, so keep this value in sync with the backend. +const maxConcurrentChatsOverHardLimit = 5; + +type CoderAgentsProductCardProps = { + /** + * The license's agent_runtime_hours_allocation claim, in hours. + * Undefined or non-positive (other than the -1 unlimited sentinel) + * means the license does not include Coder Agents hours. + */ + allocation?: number; + /** + * Agent runtime hours used in the current usage period, from the + * merged entitlements. Undefined when usage does not apply to this + * license (another license provides the feature) or is unknown. + */ + actual?: number; + /** Usage is above this license's allocation. */ + isExceeded: boolean; + /** Usage is at or above this license's hard limit. */ + isHardLimitExceeded: boolean; +}; + +const MetricLabel: FC<{ label: string; tooltip: string }> = ({ + label, + tooltip, +}) => ( +
+ {label} + + + + + + {tooltip} + + +
+); + +const CardContainer: FC<{ className?: string; children: ReactNode }> = ({ + className, + children, +}) => ( +
+
Coder Agents
+ {children} +
+); + +// TODO: placeholder tooltip copy pending product review. +const totalAgentHoursTooltip = + "Total agent runtime hours used out of the hours included in this license."; +const concurrentChatsTooltip = + "Number of Coder Agents chats that can run at the same time."; + +export const CoderAgentsProductCard: FC = ({ + allocation, + actual, + isExceeded, + isHardLimitExceeded, +}) => { + const isUnlimited = allocation === unlimitedAllocation; + const grantsAgentHours = + allocation !== undefined && (allocation > 0 || isUnlimited); + + if (!grantsAgentHours) { + return ( + +
+ +
+ {maxConcurrentChatsOverHardLimit} +
+ {actual !== undefined && ( +
+ Agent hours used:{" "} + + {actual.toLocaleString("en-US")} + +
+ )} +
+ +
+ ); + } + + const isOverage = isExceeded || isHardLimitExceeded; + const actualLabel = + actual === undefined ? "\u2014" : actual.toLocaleString("en-US"); + + return ( + +
+
+ +
+ {isUnlimited ? ( + "Unlimited" + ) : ( + <> + + {actualLabel} + {" "} + / {allocation.toLocaleString("en-US")} + + )} +
+
+
+ +
+ {isHardLimitExceeded + ? maxConcurrentChatsOverHardLimit + : "Unlimited"} +
+
+
+
+ + Manage usage + + + + Agent settings + +
+
+ ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx new file mode 100644 index 0000000000000..805e4dde93276 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { CoderWorkspacesProductCard } from "./CoderWorkspacesProductCard"; + +const meta: Meta = { + title: + "pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard", + component: CoderWorkspacesProductCard, + args: { + userLimitActual: 4, + userLimitLimit: 10, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Coder Workspaces")).toBeInTheDocument(); + const usageLabel = canvas.getByText("Active seat usage"); + const usageValue = usageLabel.parentElement?.nextElementSibling; + await expect(usageValue).toHaveTextContent("4 / 10"); + }, +}; + +export const UnlimitedSeats: Story = { + args: { + userLimitLimit: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const usageLabel = canvas.getByText("Active seat usage"); + const usageValue = usageLabel.parentElement?.nextElementSibling; + await expect(usageValue).toHaveTextContent("4 / Unlimited"); + }, +}; + +export const NoUsageData: Story = { + args: { + userLimitActual: undefined, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const usageLabel = canvas.getByText("Active seat usage"); + const usageValue = usageLabel.parentElement?.nextElementSibling; + await expect(usageValue).toHaveTextContent("\u2014 / 10"); + }, +}; + +export const LargeCounts: Story = { + args: { + userLimitActual: 1923, + userLimitLimit: 2500, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const usageLabel = canvas.getByText("Active seat usage"); + const usageValue = usageLabel.parentElement?.nextElementSibling; + await expect(usageValue).toHaveTextContent("1,923 / 2,500"); + }, +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx new file mode 100644 index 0000000000000..d2428a70370c1 --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx @@ -0,0 +1,55 @@ +import { InfoIcon } from "lucide-react"; +import type { FC } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; + +type CoderWorkspacesProductCardProps = { + userLimitActual?: number; + userLimitLimit?: number; +}; + +export const CoderWorkspacesProductCard: FC< + CoderWorkspacesProductCardProps +> = ({ userLimitActual, userLimitLimit }) => { + const actualLabel = + userLimitActual === undefined + ? "\u2014" + : userLimitActual.toLocaleString("en-US"); + const limitLabel = userLimitLimit + ? userLimitLimit.toLocaleString("en-US") + : "Unlimited"; + + return ( +
+
+ Coder Workspaces +
+
+
+ Active seat usage + + + + + + Only Active user accounts consume license seats. Dormant and + suspended accounts don't count toward the total. + + +
+
+ {actualLabel} / {limitLabel} +
+
+
+ ); +}; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index 88cc7a320812d..11022111d8970 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import dayjs from "dayjs"; -import { expect, fn, within } from "storybook/test"; +import { expect, fn, waitFor, within } from "storybook/test"; import { MockLicenseResponse } from "#/testHelpers/entities"; import { LicenseCard } from "./LicenseCard"; @@ -23,12 +23,53 @@ const meta: Meta = { export default meta; type Story = StoryObj; +const getMetricValue = (canvas: ReturnType, label: string) => + canvas.getByText(label).parentElement?.nextElementSibling; + export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("#1")).toBeInTheDocument(); - await expect(canvas.getByText("4 / 10")).toBeInTheDocument(); + // The Users header field and the Coder Workspaces product card show + // the same seat usage. + await expect(canvas.getAllByText("4 / 10")).toHaveLength(2); await expect(canvas.getByText("Enterprise")).toBeInTheDocument(); + await expect(canvas.getByText("Standard")).toBeInTheDocument(); + await expect(canvas.getByText("Products")).toBeInTheDocument(); + await expect(canvas.getByText("Coder Workspaces")).toBeInTheDocument(); + // Enterprise licenses do not get the Coder Agents product. + await expect(canvas.queryByText("Coder Agents")).not.toBeInTheDocument(); + }, +}; + +export const CollapsesProducts: Story = { + play: async ({ canvasElement, userEvent }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Products")).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: /#1/ })); + await waitFor(() => + expect(canvas.queryByText("Products")).not.toBeInTheDocument(), + ); + await userEvent.click(canvas.getByRole("button", { name: /#1/ })); + await waitFor(() => expect(canvas.getByText("Products")).toBeVisible()); + }, +}; + +export const Trial: Story = { + args: { + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + trial: true, + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Premium")).toBeInTheDocument(); + const typeLabel = canvas.getByText("Type"); + await expect(typeLabel.nextElementSibling).toHaveTextContent("Trial"); }, }; @@ -38,7 +79,7 @@ export const UnlimitedUsers: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("4 / Unlimited")).toBeInTheDocument(); + await expect(canvas.getAllByText("4 / Unlimited")).toHaveLength(2); }, }; @@ -59,7 +100,7 @@ export const UsesLicenseUserLimit: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("1 / 3")).toBeInTheDocument(); + await expect(canvas.getAllByText("1 / 3")).toHaveLength(2); }, }; @@ -67,6 +108,157 @@ export const Premium: Story = { args: { license: MockLicenseResponse[1], }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // A Premium license with no agent hours allocation shows the Coder + // Agents upgrade card. + await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); + await expect( + getMetricValue(canvas, "Max concurrent chats"), + ).toHaveTextContent("5"); + const upgrade = canvas.getByRole("link", { name: "Upgrade" }); + await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); + }, +}; + +const premiumLicenseWithAgentHours = (allocation: number) => ({ + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + features: { + ...MockLicenseResponse[1].claims.features, + agent_runtime_hours_allocation: allocation, + ...(allocation > 0 + ? { + agent_runtime_hours_limit_soft: Math.floor(allocation * 0.8), + agent_runtime_hours_limit_hard: Math.floor(allocation * 1.25), + } + : {}), + }, + }, +}); + +export const PremiumWithAgentHours: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 16264, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "16,264 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + await expect( + canvas.getByRole("link", { name: "Manage usage" }), + ).toBeInTheDocument(); + await expect( + canvas.getByRole("link", { name: "Agent settings" }), + ).toBeInTheDocument(); + }, +}; + +export const PremiumWithAgentHoursExceeded: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 21000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Agent hours exceeded")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "21,000 / 20,000", + ); + // Concurrency is only capped once the hard limit is reached. + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +export const PremiumWithAgentHoursHardLimitExceeded: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 25000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Hard limit exceeded")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "25,000 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "5", + ); + }, +}; + +export const PremiumWithUnlimitedAgentHours: Story = { + args: { + license: premiumLicenseWithAgentHours(-1), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + actual: 16264, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "Unlimited", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +export const LowerAgentHoursCardUsesMergedEntitlement: Story = { + args: { + license: premiumLicenseWithAgentHours(10000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + actual: 16264, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Usage belongs to the winning 20,000-hour license, so this card + // shows no usage and no overage. + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "\u2014 / 10,000", + ); + await expect( + canvas.queryByText("Agent hours exceeded"), + ).not.toBeInTheDocument(); + }, }; export const PremiumWithAIGovernance: Story = { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index b4667e4c9a4d7..764717094c657 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -18,14 +18,15 @@ import { } from "#/components/DropdownMenu/DropdownMenu"; import { cn } from "#/utils/cn"; import { AIGovernanceAddOnCard } from "./AIGovernanceAddOnCard"; -import { - isLicenseApplicableForAiGovernanceOverage, - licenseShowsAiGovernanceAddOn, -} from "./AIGovernanceLicensing"; +import { licenseShowsAiGovernanceAddOn } from "./AIGovernanceLicensing"; +import { CoderAgentsProductCard } from "./CoderAgentsProductCard"; +import { CoderWorkspacesProductCard } from "./CoderWorkspacesProductCard"; +import { isLicenseApplicableForFeatureUsage } from "./licenseApplicability"; type LicenseCardProps = { license: GetLicensesResponse; aiGovernanceUserFeature?: Feature; + agentRuntimeHoursFeature?: Feature; userLimitActual?: number; userLimitLimit?: number; onRemove: (licenseId: number) => void; @@ -35,6 +36,7 @@ type LicenseCardProps = { export const LicenseCard: FC = ({ license, aiGovernanceUserFeature, + agentRuntimeHoursFeature, userLimitActual, userLimitLimit, onRemove, @@ -59,15 +61,11 @@ export const LicenseCard: FC = ({ const aiGovernanceLimit = license.claims.features?.ai_governance_user_limit ?? 0; - const licenseType = license.claims.trial - ? "Trial" - : isPremium - ? "Premium" - : "Enterprise"; + const licenseType = isPremium ? "Premium" : "Enterprise"; const hasExplicitAiGovernanceAddOn = licenseShowsAiGovernanceAddOn(license); // Overage/display checks only apply to licenses that are currently effective. - const isLicenseApplicable = isLicenseApplicableForAiGovernanceOverage( + const isLicenseApplicable = isLicenseApplicableForFeatureUsage( license, aiGovernanceUserFeature, ); @@ -89,26 +87,80 @@ export const LicenseCard: FC = ({ const aiGovernanceDisplayActual = canUseAiGovernanceUsageForThisLicense ? aiGovernanceActual : undefined; + + // Agent runtime hour claims, in hours. The -1 allocation is the + // unlimited sentinel; other non-positive allocations do not grant the + // feature. The hard limit only applies to positive allocations it is + // at or above, mirroring the backend's claim validation. + const agentHoursAllocation = + license.claims.features.agent_runtime_hours_allocation; + const agentHoursHardLimit = + license.claims.features.agent_runtime_hours_limit_hard; + const licenseGrantsAgentHours = + agentHoursAllocation !== undefined && + (agentHoursAllocation > 0 || agentHoursAllocation === -1); + const isAgentHoursLicenseApplicable = isLicenseApplicableForFeatureUsage( + license, + agentRuntimeHoursFeature, + ); + // A license "wins" when its allocation matches the merged entitlement: + // equal limits, or an unlimited allocation with the merged limit omitted. + const isWinningAgentHoursLicense = + agentHoursAllocation === -1 + ? agentRuntimeHoursFeature?.enabled === true && + agentRuntimeHoursFeature.limit === undefined + : agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursAllocation === agentRuntimeHoursFeature?.limit; + const canUseAgentHoursUsageForThisLicense = + isAgentHoursLicenseApplicable && isWinningAgentHoursLicense; + // Usage applies to the winning license's quota. Licenses without an + // allocation show deployment-wide usage in their upgrade card instead. + const agentHoursDisplayActual = + isAgentHoursLicenseApplicable && + (isWinningAgentHoursLicense || !licenseGrantsAgentHours) + ? agentRuntimeHoursFeature?.actual + : undefined; + const isAgentHoursHardLimitExceeded = + canUseAgentHoursUsageForThisLicense && + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursHardLimit !== undefined && + agentHoursHardLimit >= agentHoursAllocation && + agentHoursDisplayActual !== undefined && + agentHoursDisplayActual >= agentHoursHardLimit; + const isAgentHoursExceeded = + canUseAgentHoursUsageForThisLicense && + !isAgentHoursHardLimitExceeded && + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursDisplayActual !== undefined && + agentHoursDisplayActual > agentHoursAllocation; + const statusClassName = - isAiGovernanceAddOnExceeded || isExpired + isAgentHoursHardLimitExceeded || + isAgentHoursExceeded || + isAiGovernanceAddOnExceeded || + isExpired ? "text-content-destructive" : isNotYetValid ? "text-content-warning" : "text-content-success"; - const statusText = isAiGovernanceAddOnExceeded - ? "Add-on exceeded" - : isExpired - ? "Expired" - : isNotYetValid - ? "Not started" - : "Active"; - const hasCollapsibleContent = isPremium && hasExplicitAiGovernanceAddOn; + const statusText = isAgentHoursHardLimitExceeded + ? "Hard limit exceeded" + : isAgentHoursExceeded + ? "Agent hours exceeded" + : isAiGovernanceAddOnExceeded + ? "Add-on exceeded" + : isExpired + ? "Expired" + : isNotYetValid + ? "Not started" + : "Active"; const headerContent = ( <>
- {hasCollapsibleContent && ( - - )} + #{license.id} @@ -122,6 +174,12 @@ export const LicenseCard: FC = ({ Status {statusText}
+
+ Type + + {license.claims.trial ? "Trial" : "Standard"} + +
Users @@ -177,23 +235,17 @@ export const LicenseCard: FC = ({ />
- {hasCollapsibleContent ? ( - + - - ) : ( -
{headerContent} -
- )} + + @@ -220,22 +272,41 @@ export const LicenseCard: FC = ({
- {hasCollapsibleContent && ( -
-
- Add-ons -
-
- +
+ Products +
+
+ + {isPremium && ( + -
+ )}
- )} + {hasExplicitAiGovernanceAddOn && ( + <> +
+ Add-ons +
+
+ +
+ + )} +
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx index 9e0735314e57d..56f6aeec5981f 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPage.tsx @@ -97,6 +97,9 @@ const LicensesSettingsPage: FC = () => { aiGovernanceUserFeature={ entitlementsQuery.data?.features.ai_governance_user_limit } + agentRuntimeHoursFeature={ + entitlementsQuery.data?.features.agent_runtime_hours + } refreshEntitlements={async () => { try { await refreshEntitlementsMutation.mutateAsync(); diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index 60689401139a3..8030439411fd8 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -40,6 +40,7 @@ type Props = { activeUsers: UserStatusChangeCount[] | undefined; managedAgentFeature?: Feature; aiGovernanceUserFeature?: Feature; + agentRuntimeHoursFeature?: Feature; }; const LicensesSettingsPageView: FC = ({ @@ -56,6 +57,7 @@ const LicensesSettingsPageView: FC = ({ activeUsers, managedAgentFeature, aiGovernanceUserFeature, + agentRuntimeHoursFeature, }) => { const theme = useTheme(); const { width, height } = useWindowSize(); @@ -124,6 +126,7 @@ const LicensesSettingsPageView: FC = ({ userLimitActual={userLimitActual} userLimitLimit={userLimitLimit} aiGovernanceUserFeature={aiGovernanceUserFeature} + agentRuntimeHoursFeature={agentRuntimeHoursFeature} isRemoving={isRemovingLicense} onRemove={removeLicense} /> diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts new file mode 100644 index 0000000000000..52ea45d47e32c --- /dev/null +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts @@ -0,0 +1,24 @@ +import dayjs from "dayjs"; +import type { GetLicensesResponse } from "#/api/api"; +import type { Feature } from "#/api/typesGenerated"; + +/** + * Usage and overage indicators only apply to licenses that are currently + * effective: past their nbf and not expired, unless the merged entitlement + * for the feature is in its grace period (an expired license can still be + * the one granting the feature while the grace period lasts). + */ +export function isLicenseApplicableForFeatureUsage( + license: GetLicensesResponse, + feature: Feature | undefined, +): boolean { + const isExpired = dayjs + .unix(license.claims.license_expires) + .isBefore(dayjs()); + const isNotYetValid = + license.claims.nbf !== undefined && + dayjs.unix(license.claims.nbf).isAfter(dayjs()); + const isFeatureInGracePeriod = feature?.entitlement === "grace_period"; + + return !isNotYetValid && (!isExpired || isFeatureInGracePeriod); +} From 78e4477090c9d10791df7d03f7043a88fce30668 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Wed, 12 Aug 2026 13:10:13 +0000 Subject: [PATCH 18/56] 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 66b55f1feead4..f2a6924bf5736 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 68bff19da496c..66c46b3b69179 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 19/56] 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 db79f93530420..2abdff578e910 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 6535b392af78a..83d54ec9e530e 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 5c7fd4d67d726..cba412fb5fac6 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 2cef6164395bd..223dae024d638 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 20/56] 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 21/56] 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 2e7dff8cbecd6..3557a42a0e325 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 550fd9b56fd03..f64981add0360 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 37db6f733cc5a..94d0068692306 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 3927ab28e31dd..4d5ffdf4744c3 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 4315def76659e..1523ba1228527 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 22/56] 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 268009cd29b76..b07a16a517815 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 79e987cc084e9..f869088245f4a 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 0000000000000..ed8b4ae78b8f4 --- /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 0000000000000..33c0c28f1a7ab --- /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 34e74dedc4f15..cf563ec624c0e 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 d416a3f6880a9..a873b84990c77 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 5ada09d52b088..ebb6013162feb 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 8ba706b0c8ae9..03f435c5af76d 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 4b1a4376f2db4..23256ed3b86fc 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 91d911b57a71f..a5b6788a214f4 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 0000000000000..fa6aeb6344055 --- /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 23/56] 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 3557a42a0e325..03324f7857391 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 f64981add0360..550fd9b56fd03 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 94d0068692306..37db6f733cc5a 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 4d5ffdf4744c3..3927ab28e31dd 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 1523ba1228527..4315def76659e 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 24/56] 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 ed8b4ae78b8f4..8a6a02409885e 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 25/56] 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 cf563ec624c0e..34e74dedc4f15 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 ebb6013162feb..5ada09d52b088 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 03f435c5af76d..8ba706b0c8ae9 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 26/56] 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 27/56] 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 a5b6788a214f4..af7fcd713eea8 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 28/56] 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 29/56] 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 af7fcd713eea8..cec94a1a511e5 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 30/56] 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 f2ccf009842e1..7aecbaa3089aa 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 e023da7b1df28..7be0140535333 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 1115ba12118c7..8f5f1e4a708d6 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 8cce657de65a8..e6f3598475ead 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 6fe509028502a..db68fb8b60adc 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 c88f975f2c002..811929f09390a 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 0222095336d24..c00449c8b5163 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 ba5f739d93d2c..9ef4dda8bb10b 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 582493a4eff2a..6c3dd4e662b86 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 80674fcbb7e2f..bd278dc591c4b 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 31/56] 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 e6f3598475ead..8911f695d3df1 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 db68fb8b60adc..0e393939c5d45 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 32/56] 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 6c3dd4e662b86..4f2030a30807e 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 bd278dc591c4b..453b925fe6dad 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 33/56] 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 7aecbaa3089aa..6f277f17510da 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 8f5f1e4a708d6..1d2678fd787d1 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 8911f695d3df1..66b55f1feead4 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 0e393939c5d45..ec8e06b2f8de9 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 c00449c8b5163..677438bcfa47d 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 9ef4dda8bb10b..74ce21b89facf 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 4f2030a30807e..7c2e67c9a7172 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 453b925fe6dad..2cef6164395bd 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 34/56] 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 bba55f81beca2..8a60e11415f6a 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 de3395aac84ab..2abdff578e910 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 ec8e06b2f8de9..68bff19da496c 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 74ce21b89facf..2218aa5cf35d3 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 35/56] 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 66b55f1feead4..f2a6924bf5736 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 68bff19da496c..66c46b3b69179 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 36/56] 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 03324f7857391..2e7dff8cbecd6 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 37/56] 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 f2ccf009842e1..7aecbaa3089aa 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 e023da7b1df28..7be0140535333 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 1115ba12118c7..8f5f1e4a708d6 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 8cce657de65a8..e6f3598475ead 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 6fe509028502a..db68fb8b60adc 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 c88f975f2c002..811929f09390a 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 0222095336d24..c00449c8b5163 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 ba5f739d93d2c..9ef4dda8bb10b 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 582493a4eff2a..6c3dd4e662b86 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 80674fcbb7e2f..bd278dc591c4b 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 38/56] 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 e6f3598475ead..8911f695d3df1 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 db68fb8b60adc..0e393939c5d45 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 39/56] 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 6c3dd4e662b86..4f2030a30807e 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 bd278dc591c4b..453b925fe6dad 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 40/56] 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 7aecbaa3089aa..6f277f17510da 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 8f5f1e4a708d6..1d2678fd787d1 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 8911f695d3df1..66b55f1feead4 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 0e393939c5d45..ec8e06b2f8de9 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 c00449c8b5163..677438bcfa47d 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 9ef4dda8bb10b..74ce21b89facf 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 4f2030a30807e..7c2e67c9a7172 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 453b925fe6dad..2cef6164395bd 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 41/56] 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 bba55f81beca2..8a60e11415f6a 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 de3395aac84ab..2abdff578e910 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 ec8e06b2f8de9..68bff19da496c 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 74ce21b89facf..2218aa5cf35d3 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 42/56] 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 66b55f1feead4..f2a6924bf5736 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 68bff19da496c..66c46b3b69179 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 43/56] 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 edb1586a38713..29a120af0bfae 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 83d54ec9e530e..6e9dbf6c34fc6 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 e617d4e44cff6189c35eb7083898bfba782f3601 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 05:40:08 +0000 Subject: [PATCH 44/56] chore(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): pin grandfathered agent hours rendering in the Premium story The backend now grandfathers claim-less premium licenses into a zero-hour agent runtime allocation, so the merged entitlement (and its measured usage) is always present. The Premium upgrade-state story now passes that realistic merged shape and asserts the deployment-wide usage row. --- .../LicensesSettingsPage/LicenseCard.stories.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index 11022111d8970..855b7e7faa833 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -107,15 +107,27 @@ export const UsesLicenseUserLimit: Story = { export const Premium: Story = { args: { license: MockLicenseResponse[1], + // The backend grandfathers premium licenses without agent hour + // claims into a zero-hour allocation, so the merged entitlement is + // always present: disabled, zero limit, usage measured. + agentRuntimeHoursFeature: { + enabled: false, + entitlement: "entitled", + limit: 0, + actual: 137, + }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); // A Premium license with no agent hours allocation shows the Coder - // Agents upgrade card. + // Agents upgrade card, including deployment-wide usage. await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); await expect( getMetricValue(canvas, "Max concurrent chats"), ).toHaveTextContent("5"); + await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( + "Agent hours used: 137", + ); const upgrade = canvas.getByRole("link", { name: "Upgrade" }); await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); }, From 7e5b4b8d106485e1d531ad2dd4dd7425e25e7e9d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 06:59:19 +0000 Subject: [PATCH 45/56] 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 b9a3b82c74e71..349403203bcb7 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 974cdd50ede75..e6a505b491420 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 7006f4a2f0f9a..1261bf02b375d 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 a6c7114b16252..aada5a73777a8 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 83c08fa1df97e..fd9ae336be32e 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 29a120af0bfae..567b1503a23e0 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 6e9dbf6c34fc6..f61ea3ff9417f 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 d006a5b16d00e..953f9dd19351c 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 4eb6eb6f94182..098b19cb32858 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 9c8ac85d078f29f0e45c8070a055a789667d934d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 07:05:56 +0000 Subject: [PATCH 46/56] feat(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): show agent runtime hours with one decimal Derive the license page's agent hours value from the entitlement's new actual_ms, floored to tenths of an hour with integer math so the displayed number and the exceeded state flip at the same instant as the backend's whole-hour thresholds. Usage renders with exactly one decimal (e.g. 42.0, 10.3) on both the Total Agent hours metric and the upgrade card's usage line; the allocation denominator stays whole. --- .../CoderAgentsProductCard.stories.tsx | 14 ++++--- .../CoderAgentsProductCard.tsx | 18 +++++--- .../LicenseCard.stories.tsx | 41 +++++++++++++++++-- .../LicensesSettingsPage/LicenseCard.tsx | 10 ++++- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx index 0f51a52a54b72..76de6dd5dddb3 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -8,7 +8,9 @@ const meta: Meta = { component: CoderAgentsProductCard, args: { allocation: 20000, - actual: 16264, + // Fractional usage renders with one decimal; whole values render + // with a trailing .0 (see Exceeded). + actual: 16264.3, isExceeded: false, isHardLimitExceeded: false, }, @@ -25,7 +27,7 @@ export const Default: Story = { const canvas = within(canvasElement); await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "16,264 / 20,000", + "16,264.3 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", @@ -75,7 +77,7 @@ export const Exceeded: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "21,000 / 20,000", + "21,000.0 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", @@ -91,7 +93,7 @@ export const HardLimitExceeded: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "25,000 / 20,000", + "25,000.0 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "5", @@ -120,12 +122,12 @@ export const NoAllocation: Story = { export const NoAllocationWithUsage: Story = { args: { allocation: undefined, - actual: 1234, + actual: 1234.5, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( - "Agent hours used: 1,234", + "Agent hours used: 1,234.5", ); await expect( canvas.getByRole("link", { name: "Upgrade" }), diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx index 3154054b4f348..cc8c0e0d2322c 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -30,8 +30,9 @@ type CoderAgentsProductCardProps = { allocation?: number; /** * Agent runtime hours used in the current usage period, from the - * merged entitlements. Undefined when usage does not apply to this - * license (another license provides the feature) or is unknown. + * merged entitlements, floored to tenths of an hour. Undefined when + * usage does not apply to this license (another license provides the + * feature) or is unknown. */ actual?: number; /** Usage is above this license's allocation. */ @@ -84,6 +85,14 @@ const totalAgentHoursTooltip = const concurrentChatsTooltip = "Number of Coder Agents chats that can run at the same time."; +// Usage always renders with exactly one decimal (e.g. 42.0, 10.3). The +// value is already floored to tenths, so no rounding happens here. +const formatHoursUsed = (hours: number) => + hours.toLocaleString("en-US", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }); + export const CoderAgentsProductCard: FC = ({ allocation, actual, @@ -109,7 +118,7 @@ export const CoderAgentsProductCard: FC = ({
Agent hours used:{" "} - {actual.toLocaleString("en-US")} + {formatHoursUsed(actual)}
)} @@ -122,8 +131,7 @@ export const CoderAgentsProductCard: FC = ({ } const isOverage = isExceeded || isHardLimitExceeded; - const actualLabel = - actual === undefined ? "\u2014" : actual.toLocaleString("en-US"); + const actualLabel = actual === undefined ? "\u2014" : formatHoursUsed(actual); return ( { @@ -126,7 +128,7 @@ export const Premium: Story = { getMetricValue(canvas, "Max concurrent chats"), ).toHaveTextContent("5"); await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( - "Agent hours used: 137", + "Agent hours used: 137.3", ); const upgrade = canvas.getByRole("link", { name: "Upgrade" }); await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); @@ -160,13 +162,15 @@ export const PremiumWithAgentHours: Story = { soft_limit: 16000, hard_limit: 25000, actual: 16264, + // 16,264 hours and 18 minutes: renders as 16,264.3. + actual_ms: 16_264 * 3_600_000 + 18 * 60_000, }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Active")).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "16,264 / 20,000", + "16,264.3 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", @@ -190,13 +194,14 @@ export const PremiumWithAgentHoursExceeded: Story = { soft_limit: 16000, hard_limit: 25000, actual: 21000, + actual_ms: 21_000 * 3_600_000, }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Agent hours exceeded")).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "21,000 / 20,000", + "21,000.0 / 20,000", ); // Concurrency is only capped once the hard limit is reached. await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( @@ -215,13 +220,14 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = { soft_limit: 16000, hard_limit: 25000, actual: 25000, + actual_ms: 25_000 * 3_600_000, }, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Hard limit exceeded")).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "25,000 / 20,000", + "25,000.0 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "5", @@ -229,6 +235,31 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = { }, }; +export const PremiumWithAgentHoursExceededByFraction: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + // The whole-hour actual sits exactly at the allocation, but the + // extra 6 minutes push the tenths-precision value past it, so + // the fraction alone flips the exceeded state. + actual: 20000, + actual_ms: 20_000 * 3_600_000 + 6 * 60_000, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Agent hours exceeded")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "20,000.1 / 20,000", + ); + }, +}; + export const PremiumWithUnlimitedAgentHours: Story = { args: { license: premiumLicenseWithAgentHours(-1), @@ -236,6 +267,7 @@ export const PremiumWithUnlimitedAgentHours: Story = { enabled: true, entitlement: "entitled", actual: 16264, + actual_ms: 16_264 * 3_600_000 + 18 * 60_000, }, }, play: async ({ canvasElement }) => { @@ -258,6 +290,7 @@ export const LowerAgentHoursCardUsesMergedEntitlement: Story = { entitlement: "entitled", limit: 20000, actual: 16264, + actual_ms: 16_264 * 3_600_000 + 18 * 60_000, }, }, play: async ({ canvasElement }) => { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 764717094c657..1b9c2a0cfb1c5 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -114,12 +114,20 @@ export const LicenseCard: FC = ({ agentHoursAllocation === agentRuntimeHoursFeature?.limit; const canUseAgentHoursUsageForThisLicense = isAgentHoursLicenseApplicable && isWinningAgentHoursLicense; + // Precise usage in tenths of hours, floored via integer math so the + // displayed number and the exceeded state below flip at the same + // instant as the backend's whole-hour warning thresholds. + const agentHoursActualMs = agentRuntimeHoursFeature?.actual_ms; + const agentHoursActual = + agentHoursActualMs === undefined + ? undefined + : Math.floor(agentHoursActualMs / 360_000) / 10; // Usage applies to the winning license's quota. Licenses without an // allocation show deployment-wide usage in their upgrade card instead. const agentHoursDisplayActual = isAgentHoursLicenseApplicable && (isWinningAgentHoursLicense || !licenseGrantsAgentHours) - ? agentRuntimeHoursFeature?.actual + ? agentHoursActual : undefined; const isAgentHoursHardLimitExceeded = canUseAgentHoursUsageForThisLicense && From ea1ef4f51a047ff8e9cd31dc0fc36be21e2a65d4 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 10:04:22 +0000 Subject: [PATCH 47/56] fix(site/src): identify winning agent hours license by merged usage period The merged entitlement's usage period is stamped with the issued-at of the license Feature.Compare selected, so comparing allocation alone could mark an older duplicate (e.g. a replaced renewal) as the winner and report its overage state. Require the license iat to match usage_period.issued_at before treating a card as the winning license. Also exercise the product card tooltips (keyboard and hover) in Storybook, drop comments that narrated assertions, and name the exact backend constant the concurrency cap mirrors. --- site/src/api/api.ts | 4 ++ .../CoderAgentsProductCard.stories.tsx | 33 +++++++++- .../CoderAgentsProductCard.tsx | 2 +- .../CoderWorkspacesProductCard.stories.tsx | 17 ++++- .../LicenseCard.stories.tsx | 66 ++++++++++++++++--- .../LicensesSettingsPage/LicenseCard.tsx | 19 ++++-- 6 files changed, 126 insertions(+), 15 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7c12690a077c3..0fe8286ce6d73 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -365,6 +365,10 @@ type Claims = { license_expires: number; // nbf is a standard JWT claim for "not before" - the license valid from date nbf?: number; + // iat is a standard JWT claim for "issued at". Valid licenses always + // carry it; the merged entitlement's usage_period.issued_at is stamped + // from the winning license's iat. + iat?: number; account_type?: string; account_id?: string; trial: boolean; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx index 76de6dd5dddb3..d4ec4cc87772d 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, screen, waitFor, within } from "storybook/test"; import { CoderAgentsProductCard } from "./CoderAgentsProductCard"; const meta: Meta = { @@ -42,6 +42,37 @@ export const Default: Story = { }, }; +export const TooltipInteractions: Story = { + play: async ({ canvasElement, userEvent, step }) => { + const canvas = within(canvasElement); + await step("open the Total Agent hours tooltip from keyboard", async () => { + await userEvent.tab(); + await expect( + canvas.getByRole("button", { name: "Total Agent hours information" }), + ).toHaveFocus(); + await waitFor(async () => { + await expect(screen.getByRole("tooltip")).toHaveTextContent( + "Total agent runtime hours used out of the hours included in this license.", + ); + }); + await userEvent.keyboard("{Escape}"); + await waitFor(async () => { + await expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + await step("open the Concurrent chats tooltip on hover", async () => { + await userEvent.hover( + canvas.getByRole("button", { name: "Concurrent chats information" }), + ); + await waitFor(async () => { + await expect(screen.getByRole("tooltip")).toHaveTextContent( + "Number of Coder Agents chats that can run at the same time.", + ); + }); + }); + }, +}; + export const UnlimitedAllocation: Story = { args: { allocation: -1, diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx index cc8c0e0d2322c..08abb8639dd57 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -16,7 +16,7 @@ import { cn } from "#/utils/cn"; // enterprise/coderd/license). const unlimitedAllocation = -1; -// Mirrors the backend's maxConcurrentRootAgents constant, which caps +// Mirrors defaultMaxConcurrentRootAgents in coderd/x/chatd, which caps // concurrent chats once the hard limit is reached. It is not exposed via // the API, so keep this value in sync with the backend. const maxConcurrentChatsOverHardLimit = 5; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx index 805e4dde93276..0bea7524b56e0 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, screen, waitFor, within } from "storybook/test"; import { CoderWorkspacesProductCard } from "./CoderWorkspacesProductCard"; const meta: Meta = { @@ -25,6 +25,21 @@ export const Default: Story = { }, }; +export const TooltipInteraction: Story = { + play: async ({ canvasElement, userEvent }) => { + const canvas = within(canvasElement); + await userEvent.tab(); + await expect( + canvas.getByRole("button", { name: "Active seat usage information" }), + ).toHaveFocus(); + await waitFor(async () => { + await expect(screen.getByRole("tooltip")).toHaveTextContent( + "Only Active user accounts consume license seats.", + ); + }); + }, +}; + export const UnlimitedSeats: Story = { args: { userLimitLimit: undefined, diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index fe055c7559735..f751a3f5830aa 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -30,14 +30,11 @@ export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("#1")).toBeInTheDocument(); - // The Users header field and the Coder Workspaces product card show - // the same seat usage. await expect(canvas.getAllByText("4 / 10")).toHaveLength(2); await expect(canvas.getByText("Enterprise")).toBeInTheDocument(); await expect(canvas.getByText("Standard")).toBeInTheDocument(); await expect(canvas.getByText("Products")).toBeInTheDocument(); await expect(canvas.getByText("Coder Workspaces")).toBeInTheDocument(); - // Enterprise licenses do not get the Coder Agents product. await expect(canvas.queryByText("Coder Agents")).not.toBeInTheDocument(); }, }; @@ -121,8 +118,6 @@ export const Premium: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // A Premium license with no agent hours allocation shows the Coder - // Agents upgrade card, including deployment-wide usage. await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); await expect( getMetricValue(canvas, "Max concurrent chats"), @@ -135,10 +130,24 @@ export const Premium: Story = { }, }; -const premiumLicenseWithAgentHours = (allocation: number) => ({ +// Issued-at of the license that supplies the merged entitlement. The +// merged usage period is stamped with this timestamp, so only the license +// carrying the same iat claim shows usage and overage. +const WINNING_ISSUED_AT = dayjs("2026-01-01T12:00:00Z"); +const winningUsagePeriod = { + issued_at: WINNING_ISSUED_AT.toISOString(), + start: WINNING_ISSUED_AT.toISOString(), + end: WINNING_ISSUED_AT.add(1, "year").toISOString(), +}; + +const premiumLicenseWithAgentHours = ( + allocation: number, + issuedAt = WINNING_ISSUED_AT, +) => ({ ...MockLicenseResponse[1], claims: { ...MockLicenseResponse[1].claims, + iat: issuedAt.unix(), features: { ...MockLicenseResponse[1].claims.features, agent_runtime_hours_allocation: allocation, @@ -164,6 +173,7 @@ export const PremiumWithAgentHours: Story = { actual: 16264, // 16,264 hours and 18 minutes: renders as 16,264.3. actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -195,6 +205,7 @@ export const PremiumWithAgentHoursExceeded: Story = { hard_limit: 25000, actual: 21000, actual_ms: 21_000 * 3_600_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -203,7 +214,6 @@ export const PremiumWithAgentHoursExceeded: Story = { await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( "21,000.0 / 20,000", ); - // Concurrency is only capped once the hard limit is reached. await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", ); @@ -221,6 +231,7 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = { hard_limit: 25000, actual: 25000, actual_ms: 25_000 * 3_600_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -249,6 +260,7 @@ export const PremiumWithAgentHoursExceededByFraction: Story = { // the fraction alone flips the exceeded state. actual: 20000, actual_ms: 20_000 * 3_600_000 + 6 * 60_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -268,6 +280,7 @@ export const PremiumWithUnlimitedAgentHours: Story = { entitlement: "entitled", actual: 16264, actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -284,13 +297,17 @@ export const PremiumWithUnlimitedAgentHours: Story = { export const LowerAgentHoursCardUsesMergedEntitlement: Story = { args: { - license: premiumLicenseWithAgentHours(10000), + license: premiumLicenseWithAgentHours( + 10000, + WINNING_ISSUED_AT.subtract(1, "year"), + ), agentRuntimeHoursFeature: { enabled: true, entitlement: "entitled", limit: 20000, actual: 16264, actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + usage_period: winningUsagePeriod, }, }, play: async ({ canvasElement }) => { @@ -306,6 +323,39 @@ export const LowerAgentHoursCardUsesMergedEntitlement: Story = { }, }; +export const ReplacedDuplicateAllocationShowsNoUsage: Story = { + args: { + // An older license with the same allocation as the winning renewal. + // Only the license whose iat matches the merged usage period shows + // usage, so this card stays free of usage and overage even though + // its allocation equals the merged limit. + license: premiumLicenseWithAgentHours( + 20000, + WINNING_ISSUED_AT.subtract(1, "year"), + ), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 26000, + actual_ms: 26_000 * 3_600_000, + usage_period: winningUsagePeriod, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "\u2014 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + export const PremiumWithAIGovernance: Story = { args: { license: { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 1b9c2a0cfb1c5..73590ae3349f2 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -103,15 +103,26 @@ export const LicenseCard: FC = ({ license, agentRuntimeHoursFeature, ); - // A license "wins" when its allocation matches the merged entitlement: - // equal limits, or an unlimited allocation with the merged limit omitted. + // The merged entitlement's usage period is stamped with the issued-at + // of the license the backend selected, so a license only "wins" when + // its own iat matches. Its allocation must also match the merged + // entitlement: equal limits, or an unlimited allocation with the + // merged limit omitted. Allocation alone is not enough because a + // renewal can carry the same allocation as the license it replaces. + const mergedUsagePeriodIssuedAt = + agentRuntimeHoursFeature?.usage_period?.issued_at; + const matchesMergedUsagePeriod = + license.claims.iat !== undefined && + mergedUsagePeriodIssuedAt !== undefined && + dayjs.unix(license.claims.iat).isSame(mergedUsagePeriodIssuedAt); const isWinningAgentHoursLicense = - agentHoursAllocation === -1 + matchesMergedUsagePeriod && + (agentHoursAllocation === -1 ? agentRuntimeHoursFeature?.enabled === true && agentRuntimeHoursFeature.limit === undefined : agentHoursAllocation !== undefined && agentHoursAllocation > 0 && - agentHoursAllocation === agentRuntimeHoursFeature?.limit; + agentHoursAllocation === agentRuntimeHoursFeature?.limit); const canUseAgentHoursUsageForThisLicense = isAgentHoursLicenseApplicable && isWinningAgentHoursLicense; // Precise usage in tenths of hours, floored via integer math so the From 9a1e2c46eb52f4b5b12547302237c50db9887781 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 13 Aug 2026 10:41:14 +0000 Subject: [PATCH 48/56] fix(site/src): match full usage period and render agents card on runtime claims The backend decodes agent_runtime_hours_* claims for every license regardless of feature set, so an Enterprise license carrying an allocation now renders the Coder Agents product instead of being hidden by the Premium-only condition. Feature.Compare tie-breaks equal issued-at values on the usage period end, so the winning-license predicate now matches the complete merged usage period (iat/nbf/exp) plus the validated soft and hard thresholds, not issued-at alone. The hard-limit claim validation moved into the effective threshold computation shared by both checks. --- site/src/api/api.ts | 5 ++ .../LicenseCard.stories.tsx | 86 ++++++++++++++++++- .../LicensesSettingsPage/LicenseCard.tsx | 73 ++++++++++++---- 3 files changed, 143 insertions(+), 21 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0fe8286ce6d73..65d97cea0f451 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -369,6 +369,11 @@ type Claims = { // carry it; the merged entitlement's usage_period.issued_at is stamped // from the winning license's iat. iat?: number; + // exp is a standard JWT claim for "expires at": the end of the grace + // period (identical to license_expires when there is no grace period). + // The merged entitlement's usage_period.end is stamped from the + // winning license's exp, and usage_period.start from its nbf. + exp?: number; account_type?: string; account_id?: string; trial: boolean; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index f751a3f5830aa..d1fbf5a9697c3 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -131,8 +131,9 @@ export const Premium: Story = { }; // Issued-at of the license that supplies the merged entitlement. The -// merged usage period is stamped with this timestamp, so only the license -// carrying the same iat claim shows usage and overage. +// merged usage period is copied from that license's iat/nbf/exp claims, +// so only the license whose claims reproduce the whole period shows +// usage and overage. const WINNING_ISSUED_AT = dayjs("2026-01-01T12:00:00Z"); const winningUsagePeriod = { issued_at: WINNING_ISSUED_AT.toISOString(), @@ -148,6 +149,8 @@ const premiumLicenseWithAgentHours = ( claims: { ...MockLicenseResponse[1].claims, iat: issuedAt.unix(), + nbf: issuedAt.unix(), + exp: issuedAt.add(1, "year").unix(), features: { ...MockLicenseResponse[1].claims.features, agent_runtime_hours_allocation: allocation, @@ -356,6 +359,85 @@ export const ReplacedDuplicateAllocationShowsNoUsage: Story = { }, }; +const sameIssuedAtShorterTermLicense = (() => { + const license = premiumLicenseWithAgentHours(20000); + return { + ...license, + claims: { + ...license.claims, + exp: WINNING_ISSUED_AT.add(6, "month").unix(), + }, + }; +})(); + +export const SameIssuedAtDifferentTermEndShowsNoUsage: Story = { + args: { + // Same iat and allocation as the winning license, but a shorter + // term. Feature.Compare tie-breaks equal issued-at values on the + // period end, so this license loses and must not display the + // merged usage or overage. + license: sameIssuedAtShorterTermLicense, + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 26000, + actual_ms: 26_000 * 3_600_000, + usage_period: winningUsagePeriod, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "\u2014 / 20,000", + ); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + +const enterpriseLicenseWithAgentHours = (() => { + const license = premiumLicenseWithAgentHours(20000); + return { + ...license, + claims: { + ...license.claims, + feature_set: "enterprise", + }, + }; +})(); + +export const EnterpriseWithAgentHours: Story = { + args: { + // The backend accepts runtime hour claims on any feature set, so + // an Enterprise license carrying an allocation renders the Coder + // Agents product with its usage. + license: enterpriseLicenseWithAgentHours, + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 16264, + actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + usage_period: winningUsagePeriod, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Enterprise")).toBeInTheDocument(); + await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "16,264.3 / 20,000", + ); + }, +}; + export const PremiumWithAIGovernance: Story = { args: { license: { diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 73590ae3349f2..8396180c08bad 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -89,34 +89,72 @@ export const LicenseCard: FC = ({ : undefined; // Agent runtime hour claims, in hours. The -1 allocation is the - // unlimited sentinel; other non-positive allocations do not grant the - // feature. The hard limit only applies to positive allocations it is - // at or above, mirroring the backend's claim validation. + // unlimited sentinel; other negative allocations are ignored by the + // backend, and a zero allocation grants the feature disabled. const agentHoursAllocation = license.claims.features.agent_runtime_hours_allocation; - const agentHoursHardLimit = - license.claims.features.agent_runtime_hours_limit_hard; + // The backend decodes runtime hour claims for every license + // regardless of feature set, so an Enterprise license carrying a + // usable allocation claim also gets the Coder Agents product. + // Premium licenses without claims are grandfathered into the + // zero-hour (upgrade) display. + const hasAgentHoursClaim = + agentHoursAllocation !== undefined && + (agentHoursAllocation >= 0 || agentHoursAllocation === -1); const licenseGrantsAgentHours = agentHoursAllocation !== undefined && (agentHoursAllocation > 0 || agentHoursAllocation === -1); + // Thresholds after the backend's claim validation: soft must be + // non-negative and below a positive allocation, hard at or above it. + // Invalid threshold claims are ignored rather than disqualifying the + // license. + const agentHoursSoftLimitClaim = + license.claims.features.agent_runtime_hours_limit_soft; + const agentHoursHardLimitClaim = + license.claims.features.agent_runtime_hours_limit_hard; + const agentHoursSoftLimit = + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursSoftLimitClaim !== undefined && + agentHoursSoftLimitClaim >= 0 && + agentHoursSoftLimitClaim < agentHoursAllocation + ? agentHoursSoftLimitClaim + : undefined; + const agentHoursHardLimit = + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursHardLimitClaim !== undefined && + agentHoursHardLimitClaim >= agentHoursAllocation + ? agentHoursHardLimitClaim + : undefined; const isAgentHoursLicenseApplicable = isLicenseApplicableForFeatureUsage( license, agentRuntimeHoursFeature, ); - // The merged entitlement's usage period is stamped with the issued-at - // of the license the backend selected, so a license only "wins" when - // its own iat matches. Its allocation must also match the merged - // entitlement: equal limits, or an unlimited allocation with the - // merged limit omitted. Allocation alone is not enough because a - // renewal can carry the same allocation as the license it replaces. - const mergedUsagePeriodIssuedAt = - agentRuntimeHoursFeature?.usage_period?.issued_at; + // The merged entitlement's usage period is copied verbatim from the + // license the backend selected (issued_at from iat, start from nbf, + // end from exp), so a license only "wins" when all three match. + // Feature.Compare tie-breaks equal issued-at values on the period + // end, so matching issued-at alone could mark two licenses with the + // same second-granularity iat as the winner. + const mergedUsagePeriod = agentRuntimeHoursFeature?.usage_period; const matchesMergedUsagePeriod = license.claims.iat !== undefined && - mergedUsagePeriodIssuedAt !== undefined && - dayjs.unix(license.claims.iat).isSame(mergedUsagePeriodIssuedAt); + license.claims.nbf !== undefined && + license.claims.exp !== undefined && + mergedUsagePeriod !== undefined && + dayjs.unix(license.claims.iat).isSame(mergedUsagePeriod.issued_at) && + dayjs.unix(license.claims.nbf).isSame(mergedUsagePeriod.start) && + dayjs.unix(license.claims.exp).isSame(mergedUsagePeriod.end); + // Beyond the usage period, the license's allocation and validated + // thresholds must equal the merged entitlement's: equal limits (or an + // unlimited allocation with the merged limit omitted) and equal + // soft/hard thresholds, since the backend retains only the selected + // license's thresholds. const isWinningAgentHoursLicense = matchesMergedUsagePeriod && + agentHoursSoftLimit === agentRuntimeHoursFeature?.soft_limit && + agentHoursHardLimit === agentRuntimeHoursFeature?.hard_limit && (agentHoursAllocation === -1 ? agentRuntimeHoursFeature?.enabled === true && agentRuntimeHoursFeature.limit === undefined @@ -142,10 +180,7 @@ export const LicenseCard: FC = ({ : undefined; const isAgentHoursHardLimitExceeded = canUseAgentHoursUsageForThisLicense && - agentHoursAllocation !== undefined && - agentHoursAllocation > 0 && agentHoursHardLimit !== undefined && - agentHoursHardLimit >= agentHoursAllocation && agentHoursDisplayActual !== undefined && agentHoursDisplayActual >= agentHoursHardLimit; const isAgentHoursExceeded = @@ -300,7 +335,7 @@ export const LicenseCard: FC = ({ userLimitActual={userLimitActual} userLimitLimit={currentUserLimit} /> - {isPremium && ( + {(isPremium || hasAgentHoursClaim) && ( Date: Mon, 17 Aug 2026 07:32:46 +0000 Subject: [PATCH 49/56] fix: fix storybook tests --- .../LicensesSettingsPage/CoderAgentsProductCard.stories.tsx | 4 ++-- .../CoderWorkspacesProductCard.stories.tsx | 4 ++-- .../LicensesSettingsPage/LicenseCard.stories.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx index d4ec4cc87772d..69cf27c2d509c 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, screen, waitFor, within } from "storybook/test"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; import { CoderAgentsProductCard } from "./CoderAgentsProductCard"; const meta: Meta = { @@ -43,7 +43,7 @@ export const Default: Story = { }; export const TooltipInteractions: Story = { - play: async ({ canvasElement, userEvent, step }) => { + play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step("open the Total Agent hours tooltip from keyboard", async () => { await userEvent.tab(); diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx index 0bea7524b56e0..b2649633729e8 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, screen, waitFor, within } from "storybook/test"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; import { CoderWorkspacesProductCard } from "./CoderWorkspacesProductCard"; const meta: Meta = { @@ -26,7 +26,7 @@ export const Default: Story = { }; export const TooltipInteraction: Story = { - play: async ({ canvasElement, userEvent }) => { + play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.tab(); await expect( diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index d1fbf5a9697c3..427e898709861 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import dayjs from "dayjs"; -import { expect, fn, waitFor, within } from "storybook/test"; +import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { MockLicenseResponse } from "#/testHelpers/entities"; import { LicenseCard } from "./LicenseCard"; @@ -40,7 +40,7 @@ export const Default: Story = { }; export const CollapsesProducts: Story = { - play: async ({ canvasElement, userEvent }) => { + play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Products")).toBeVisible(); await userEvent.click(canvas.getByRole("button", { name: /#1/ })); From 4f0c6941c4b9bebebdaa8ad181101619b8ae8ad8 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 17 Aug 2026 14:38:07 +0000 Subject: [PATCH 50/56] chore: license page improvements --- .../CoderAgentsProductCard.stories.tsx | 4 +- .../CoderAgentsProductCard.tsx | 41 +++++---- .../CoderWorkspacesProductCard.tsx | 2 +- .../LicenseCard.stories.tsx | 87 ++++++++++++------- .../LicensesSettingsPage/LicenseCard.tsx | 38 ++++++-- 5 files changed, 117 insertions(+), 55 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx index 69cf27c2d509c..5aa7a35161c58 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -157,8 +157,8 @@ export const NoAllocationWithUsage: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( - "Agent hours used: 1,234.5", + await expect(getMetricValue(canvas, "Agent hours used")).toHaveTextContent( + "1,234.5", ); await expect( canvas.getByRole("link", { name: "Upgrade" }), diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx index 08abb8639dd57..aa8547e13bc6e 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -3,7 +3,6 @@ import type { FC, ReactNode } from "react"; import { Link as RouterLink } from "react-router"; import { Button } from "#/components/Button/Button"; import { Link } from "#/components/Link/Link"; -import { Separator } from "#/components/Separator/Separator"; import { Tooltip, TooltipContent, @@ -106,20 +105,24 @@ export const CoderAgentsProductCard: FC = ({ if (!grantsAgentHours) { return ( -
- -
- {maxConcurrentChatsOverHardLimit} +
+
+ +
+ {maxConcurrentChatsOverHardLimit} +
{actual !== undefined && ( -
- Agent hours used:{" "} - +
+
+ Agent hours used +
+
{formatHoursUsed(actual)} - +
)}
@@ -146,7 +149,7 @@ export const CoderAgentsProductCard: FC = ({ label="Total Agent hours" tooltip={totalAgentHoursTooltip} /> -
+
{isUnlimited ? ( "Unlimited" ) : ( @@ -164,19 +167,21 @@ export const CoderAgentsProductCard: FC = ({ label="Concurrent chats" tooltip={concurrentChatsTooltip} /> -
+
{isHardLimitExceeded ? maxConcurrentChatsOverHardLimit : "Unlimited"}
-
- +
+ Manage usage - - + + | + + Agent settings
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx index d2428a70370c1..d8341b183259e 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderWorkspacesProductCard.tsx @@ -46,7 +46,7 @@ export const CoderWorkspacesProductCard: FC<
-
+
{actualLabel} / {limitLabel}
diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index 427e898709861..a4dc6c138e51d 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -26,6 +26,14 @@ type Story = StoryObj; const getMetricValue = (canvas: ReturnType, label: string) => canvas.getByText(label).parentElement?.nextElementSibling; +const getIncludedProducts = ( + canvas: ReturnType, + label: string, +) => + canvas.queryByRole("group", { + name: (accessibleName: string) => accessibleName === label, + }); + export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -36,6 +44,12 @@ export const Default: Story = { await expect(canvas.getByText("Products")).toBeInTheDocument(); await expect(canvas.getByText("Coder Workspaces")).toBeInTheDocument(); await expect(canvas.queryByText("Coder Agents")).not.toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces"), + ).not.toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).not.toBeInTheDocument(); }, }; @@ -67,6 +81,9 @@ export const Trial: Story = { await expect(canvas.getByText("Premium")).toBeInTheDocument(); const typeLabel = canvas.getByText("Type"); await expect(typeLabel.nextElementSibling).toHaveTextContent("Trial"); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).toBeInTheDocument(); }, }; @@ -103,7 +120,24 @@ export const UsesLicenseUserLimit: Story = { export const Premium: Story = { args: { - license: MockLicenseResponse[1], + license: { + ...MockLicenseResponse[1], + claims: { + ...MockLicenseResponse[1].claims, + // A seat-limit claim without addons is not enough to show + // the AI Governance add-on; that requires addons: ["ai_governance"]. + features: { + ...MockLicenseResponse[1].claims.features, + ai_governance_user_limit: 1000, + }, + }, + }, + aiGovernanceUserFeature: { + enabled: true, + entitlement: "entitled", + actual: 100, + limit: 1000, + }, // The backend grandfathers premium licenses without agent hour // claims into a zero-hour allocation, so the merged entitlement is // always present: disabled, zero limit, usage measured. @@ -119,14 +153,20 @@ export const Premium: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); + await expect(getIncludedProducts(canvas, "Workspaces")).toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).not.toBeInTheDocument(); await expect( getMetricValue(canvas, "Max concurrent chats"), ).toHaveTextContent("5"); - await expect(canvas.getByText(/Agent hours used/)).toHaveTextContent( - "Agent hours used: 137.3", + await expect(getMetricValue(canvas, "Agent hours used")).toHaveTextContent( + "137.3", ); const upgrade = canvas.getByRole("link", { name: "Upgrade" }); await expect(upgrade).toHaveAttribute("href", "mailto:sales@coder.com"); + await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument(); + await expect(canvas.queryByText("AI Governance")).not.toBeInTheDocument(); }, }; @@ -182,6 +222,9 @@ export const PremiumWithAgentHours: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( "16,264.3 / 20,000", ); @@ -289,6 +332,9 @@ export const PremiumWithUnlimitedAgentHours: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Active")).toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( "Unlimited", ); @@ -432,6 +478,12 @@ export const EnterpriseWithAgentHours: Story = { const canvas = within(canvasElement); await expect(canvas.getByText("Enterprise")).toBeInTheDocument(); await expect(canvas.getByText("Coder Agents")).toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces"), + ).not.toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + Agents"), + ).not.toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( "16,264.3 / 20,000", ); @@ -462,38 +514,15 @@ export const PremiumWithAIGovernance: Story = { const canvas = within(canvasElement); await expect(canvas.getByText(/add-ons/i)).toBeInTheDocument(); await expect(canvas.getByText(/ai governance/i)).toBeInTheDocument(); + await expect( + getIncludedProducts(canvas, "Workspaces + AI Governance"), + ).toBeInTheDocument(); const seatsLabel = canvas.getByText("Seats"); const seatsValue = seatsLabel.nextElementSibling; await expect(seatsValue).toHaveTextContent("750 / 1,000"); }, }; -export const PremiumWithoutAIGovernanceAddOn: Story = { - args: { - license: { - ...MockLicenseResponse[1], - claims: { - ...MockLicenseResponse[1].claims, - features: { - ...MockLicenseResponse[1].claims.features, - ai_governance_user_limit: 1000, - }, - }, - }, - aiGovernanceUserFeature: { - enabled: true, - entitlement: "entitled", - actual: 100, - limit: 1000, - }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument(); - await expect(canvas.queryByText("AI Governance")).not.toBeInTheDocument(); - }, -}; - export const Expired: Story = { args: { license: MockLicenseResponse[3], diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 8396180c08bad..75424699dab3b 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -211,16 +211,44 @@ export const LicenseCard: FC = ({ : isNotYetValid ? "Not started" : "Active"; + const includesAgents = + Boolean(license.claims.trial) || licenseGrantsAgentHours; + const includedProducts = isPremium + ? [ + "Workspaces", + ...(hasExplicitAiGovernanceAddOn ? ["AI Governance"] : []), + ...(includesAgents ? ["Agents"] : []), + ] + : []; + const includedProductsLabel = includedProducts.join(" + "); const headerContent = ( <> -
- +
+ #{license.id} - - {licenseType} - +
+ + {licenseType} + + {includedProducts.length > 0 && ( +
+ {includedProducts.map((product, index) => ( + + {index > 0 && ( + + + )} + {product} + + ))} +
+ )} +
From d4d1cc4b74f65eb12d9ffeb9b89e8a63adf9fc4d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 18 Aug 2026 04:42:21 +0000 Subject: [PATCH 51/56] chore: design updates --- .../CoderAgentsProductCard.stories.tsx | 40 ++++++++++- .../CoderAgentsProductCard.tsx | 66 +++++++++++++++---- .../LicenseCard.stories.tsx | 59 +++++++++++++---- .../LicensesSettingsPage/LicenseCard.tsx | 16 ++++- 4 files changed, 154 insertions(+), 27 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx index 5aa7a35161c58..8547da840e1a7 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -11,6 +11,7 @@ const meta: Meta = { // Fractional usage renders with one decimal; whole values render // with a trailing .0 (see Exceeded). actual: 16264.3, + isSoftLimitReached: false, isExceeded: false, isHardLimitExceeded: false, }, @@ -100,6 +101,27 @@ export const NotProvidingUsage: Story = { }, }; +export const SoftLimitReached: Story = { + args: { + actual: 16264.3, + isSoftLimitReached: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const hoursValue = getMetricValue(canvas, "Total Agent hours"); + await expect(hoursValue).toHaveTextContent("16,264.3 / 20,000"); + await expect(hoursValue?.querySelector("span")).toHaveClass( + "text-border-warning", + ); + await expect( + canvas.getByText("Coder Agents").closest(".coder-agents-product-card"), + ).toHaveClass("border-border-warning"); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + export const Exceeded: Story = { args: { actual: 21000, @@ -113,6 +135,9 @@ export const Exceeded: Story = { await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", ); + await expect( + canvas.queryByRole("status", { name: "Limit reached" }), + ).not.toBeInTheDocument(); }, }; @@ -126,9 +151,20 @@ export const HardLimitExceeded: Story = { await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( "25,000.0 / 20,000", ); - await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( - "5", + const concurrentChats = getMetricValue(canvas, "Concurrent chats"); + await expect(concurrentChats).toHaveTextContent("5"); + await expect(concurrentChats).toHaveClass("text-content-destructive"); + await expect( + canvas.getByRole("status", { name: "Limit reached" }), + ).toBeInTheDocument(); + await userEvent.hover( + canvas.getByRole("button", { name: "Concurrent chats information" }), ); + await waitFor(async () => { + await expect(screen.getByRole("tooltip")).toHaveTextContent( + "Number of Coder Agents chats that can run at the same time. You've reached your limit: concurrent chats are now capped at 5 (down from unlimited).", + ); + }); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx index aa8547e13bc6e..aafcc77d9da75 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -1,6 +1,7 @@ -import { InfoIcon } from "lucide-react"; +import { InfoIcon, TriangleAlertIcon } from "lucide-react"; import type { FC, ReactNode } from "react"; import { Link as RouterLink } from "react-router"; +import { Badge } from "#/components/Badge/Badge"; import { Button } from "#/components/Button/Button"; import { Link } from "#/components/Link/Link"; import { @@ -34,6 +35,11 @@ type CoderAgentsProductCardProps = { * feature) or is unknown. */ actual?: number; + /** + * Usage is at or above this license's advisory soft limit, but still + * within the purchased allocation. + */ + isSoftLimitReached: boolean; /** Usage is above this license's allocation. */ isExceeded: boolean; /** Usage is at or above this license's hard limit. */ @@ -63,17 +69,23 @@ const MetricLabel: FC<{ label: string; tooltip: string }> = ({
); -const CardContainer: FC<{ className?: string; children: ReactNode }> = ({ - className, - children, -}) => ( +const CardContainer: FC<{ + className?: string; + headerEnd?: ReactNode; + children: ReactNode; +}> = ({ className, headerEnd, children }) => (
-
Coder Agents
+
+
+ Coder Agents +
+ {headerEnd} +
{children}
); @@ -83,6 +95,7 @@ const totalAgentHoursTooltip = "Total agent runtime hours used out of the hours included in this license."; const concurrentChatsTooltip = "Number of Coder Agents chats that can run at the same time."; +const concurrentChatsHardLimitTooltip = `${concurrentChatsTooltip} You've reached your limit: concurrent chats are now capped at ${maxConcurrentChatsOverHardLimit} (down from unlimited).`; // Usage always renders with exactly one decimal (e.g. 42.0, 10.3). The // value is already floored to tenths, so no rounding happens here. @@ -95,6 +108,7 @@ const formatHoursUsed = (hours: number) => export const CoderAgentsProductCard: FC = ({ allocation, actual, + isSoftLimitReached, isExceeded, isHardLimitExceeded, }) => { @@ -135,13 +149,30 @@ export const CoderAgentsProductCard: FC = ({ const isOverage = isExceeded || isHardLimitExceeded; const actualLabel = actual === undefined ? "\u2014" : formatHoursUsed(actual); + const hoursValueClassName = isOverage + ? "text-content-destructive" + : isSoftLimitReached + ? "text-border-warning" + : undefined; return ( + + Limit reached + + ) : undefined + } >
@@ -154,9 +185,7 @@ export const CoderAgentsProductCard: FC = ({ "Unlimited" ) : ( <> - - {actualLabel} - {" "} + {actualLabel}{" "} / {allocation.toLocaleString("en-US")} )} @@ -165,9 +194,20 @@ export const CoderAgentsProductCard: FC = ({
-
+
{isHardLimitExceeded ? maxConcurrentChatsOverHardLimit : "Unlimited"} diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index a4dc6c138e51d..6ad40264ec266 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -213,9 +213,10 @@ export const PremiumWithAgentHours: Story = { limit: 20000, soft_limit: 16000, hard_limit: 25000, - actual: 16264, - // 16,264 hours and 18 minutes: renders as 16,264.3. - actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + actual: 12264, + // 12,264 hours and 18 minutes: renders as 12,264.3, below + // the 16,000-hour advisory soft limit. + actual_ms: 12_264 * 3_600_000 + 18 * 60_000, usage_period: winningUsagePeriod, }, }, @@ -226,7 +227,7 @@ export const PremiumWithAgentHours: Story = { getIncludedProducts(canvas, "Workspaces + Agents"), ).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "16,264.3 / 20,000", + "12,264.3 / 20,000", ); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", @@ -240,6 +241,39 @@ export const PremiumWithAgentHours: Story = { }, }; +export const PremiumWithAgentHoursSoftLimitReached: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + actual: 16264, + // 16,264 hours and 18 minutes: renders as 16,264.3, at or + // above the 16,000-hour advisory soft limit. + actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + usage_period: winningUsagePeriod, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Active")).toBeInTheDocument(); + const hoursValue = getMetricValue(canvas, "Total Agent hours"); + await expect(hoursValue).toHaveTextContent("16,264.3 / 20,000"); + await expect(hoursValue?.querySelector("span")).toHaveClass( + "text-border-warning", + ); + await expect( + canvas.getByText("Coder Agents").closest(".coder-agents-product-card"), + ).toHaveClass("border-border-warning"); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "Unlimited", + ); + }, +}; + export const PremiumWithAgentHoursExceeded: Story = { args: { license: premiumLicenseWithAgentHours(20000), @@ -282,13 +316,16 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await expect(canvas.getByText("Hard limit exceeded")).toBeInTheDocument(); + await expect(canvas.getByText("Limit exceeded")).toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( "25,000.0 / 20,000", ); - await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( - "5", - ); + const concurrentChats = getMetricValue(canvas, "Concurrent chats"); + await expect(concurrentChats).toHaveTextContent("5"); + await expect(concurrentChats).toHaveClass("text-content-destructive"); + await expect( + canvas.getByRole("status", { name: "Limit reached" }), + ).toBeInTheDocument(); }, }; @@ -469,8 +506,8 @@ export const EnterpriseWithAgentHours: Story = { limit: 20000, soft_limit: 16000, hard_limit: 25000, - actual: 16264, - actual_ms: 16_264 * 3_600_000 + 18 * 60_000, + actual: 12264, + actual_ms: 12_264 * 3_600_000 + 18 * 60_000, usage_period: winningUsagePeriod, }, }, @@ -485,7 +522,7 @@ export const EnterpriseWithAgentHours: Story = { getIncludedProducts(canvas, "Workspaces + Agents"), ).not.toBeInTheDocument(); await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( - "16,264.3 / 20,000", + "12,264.3 / 20,000", ); }, }; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 75424699dab3b..6be6cbb8d5bf1 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -190,6 +190,19 @@ export const LicenseCard: FC = ({ agentHoursAllocation > 0 && agentHoursDisplayActual !== undefined && agentHoursDisplayActual > agentHoursAllocation; + // Advisory only: at or above the soft threshold, still inside the + // purchased allocation. Allocation and hard-limit overage supersede + // this so the product card never stacks warning on destructive. + const isAgentHoursSoftLimitReached = + canUseAgentHoursUsageForThisLicense && + !isAgentHoursHardLimitExceeded && + !isAgentHoursExceeded && + agentHoursAllocation !== undefined && + agentHoursAllocation > 0 && + agentHoursSoftLimit !== undefined && + agentHoursDisplayActual !== undefined && + agentHoursDisplayActual >= agentHoursSoftLimit && + agentHoursDisplayActual < agentHoursAllocation; const statusClassName = isAgentHoursHardLimitExceeded || @@ -201,7 +214,7 @@ export const LicenseCard: FC = ({ ? "text-content-warning" : "text-content-success"; const statusText = isAgentHoursHardLimitExceeded - ? "Hard limit exceeded" + ? "Limit exceeded" : isAgentHoursExceeded ? "Agent hours exceeded" : isAiGovernanceAddOnExceeded @@ -367,6 +380,7 @@ export const LicenseCard: FC = ({ From 5ad5e7d0b4ad1dbddf0a881d4601943380c86a6b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 18 Aug 2026 04:54:44 +0000 Subject: [PATCH 52/56] fix: format --- .../LicensesSettingsPage/CoderAgentsProductCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx index aafcc77d9da75..a926308777246 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -185,8 +185,8 @@ export const CoderAgentsProductCard: FC = ({ "Unlimited" ) : ( <> - {actualLabel}{" "} - / {allocation.toLocaleString("en-US")} + {actualLabel} /{" "} + {allocation.toLocaleString("en-US")} )}
From 3fda674b3e47cdc6daf8853fe4130a86efa36d7d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 18 Aug 2026 05:02:31 +0000 Subject: [PATCH 53/56] chore(site/src): tighten comments in license products section --- site/src/api/api.ts | 11 ++--- .../CoderAgentsProductCard.stories.tsx | 2 - .../CoderAgentsProductCard.tsx | 22 ++++------ .../LicenseCard.stories.tsx | 35 ++++++--------- .../LicensesSettingsPage/LicenseCard.tsx | 43 +++++++------------ .../licenseApplicability.ts | 6 +-- 6 files changed, 41 insertions(+), 78 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 65d97cea0f451..e870ba8d6164e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -365,14 +365,11 @@ type Claims = { license_expires: number; // nbf is a standard JWT claim for "not before" - the license valid from date nbf?: number; - // iat is a standard JWT claim for "issued at". Valid licenses always - // carry it; the merged entitlement's usage_period.issued_at is stamped - // from the winning license's iat. + // iat is a standard JWT claim for "issued at"; the merged + // usage_period.issued_at is stamped from the winning license's iat. iat?: number; - // exp is a standard JWT claim for "expires at": the end of the grace - // period (identical to license_expires when there is no grace period). - // The merged entitlement's usage_period.end is stamped from the - // winning license's exp, and usage_period.start from its nbf. + // exp is a standard JWT claim for "expires at" (end of grace period); + // it stamps usage_period.end, and nbf stamps usage_period.start. exp?: number; account_type?: string; account_id?: string; diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx index 8547da840e1a7..2d59db9ff0722 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -8,8 +8,6 @@ const meta: Meta = { component: CoderAgentsProductCard, args: { allocation: 20000, - // Fractional usage renders with one decimal; whole values render - // with a trailing .0 (see Exceeded). actual: 16264.3, isSoftLimitReached: false, isExceeded: false, diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx index a926308777246..3e1142983509a 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -11,28 +11,23 @@ import { } from "#/components/Tooltip/Tooltip"; import { cn } from "#/utils/cn"; -// Sentinel allocation claim value meaning the license grants unlimited -// agent runtime hours (AgentRuntimeHoursUnlimitedAllocation in -// enterprise/coderd/license). +// Allocation sentinel for unlimited agent runtime hours +// (AgentRuntimeHoursUnlimitedAllocation in enterprise/coderd/license). const unlimitedAllocation = -1; -// Mirrors defaultMaxConcurrentRootAgents in coderd/x/chatd, which caps -// concurrent chats once the hard limit is reached. It is not exposed via -// the API, so keep this value in sync with the backend. +// Concurrent chat cap once the hard limit is reached. Mirrors +// defaultMaxConcurrentRootAgents in coderd/x/chatd; keep in sync. const maxConcurrentChatsOverHardLimit = 5; type CoderAgentsProductCardProps = { /** * The license's agent_runtime_hours_allocation claim, in hours. - * Undefined or non-positive (other than the -1 unlimited sentinel) - * means the license does not include Coder Agents hours. + * Undefined or non-positive (except -1, unlimited) grants no hours. */ allocation?: number; /** - * Agent runtime hours used in the current usage period, from the - * merged entitlements, floored to tenths of an hour. Undefined when - * usage does not apply to this license (another license provides the - * feature) or is unknown. + * Hours used in the current usage period, floored to tenths. + * Undefined when usage does not apply to this license. */ actual?: number; /** @@ -97,8 +92,7 @@ const concurrentChatsTooltip = "Number of Coder Agents chats that can run at the same time."; const concurrentChatsHardLimitTooltip = `${concurrentChatsTooltip} You've reached your limit: concurrent chats are now capped at ${maxConcurrentChatsOverHardLimit} (down from unlimited).`; -// Usage always renders with exactly one decimal (e.g. 42.0, 10.3). The -// value is already floored to tenths, so no rounding happens here. +// The value is already floored to tenths, so no rounding happens here. const formatHoursUsed = (hours: number) => hours.toLocaleString("en-US", { minimumFractionDigits: 1, diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index 6ad40264ec266..a345519c4c6cc 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -138,9 +138,8 @@ export const Premium: Story = { actual: 100, limit: 1000, }, - // The backend grandfathers premium licenses without agent hour - // claims into a zero-hour allocation, so the merged entitlement is - // always present: disabled, zero limit, usage measured. + // Premium licenses without agent hour claims are grandfathered + // into a zero-hour allocation, so the merged entitlement exists. agentRuntimeHoursFeature: { enabled: false, entitlement: "entitled", @@ -170,10 +169,8 @@ export const Premium: Story = { }, }; -// Issued-at of the license that supplies the merged entitlement. The -// merged usage period is copied from that license's iat/nbf/exp claims, -// so only the license whose claims reproduce the whole period shows -// usage and overage. +// Issued-at of the license supplying the merged entitlement; only the +// license whose iat/nbf/exp reproduce the merged period shows usage. const WINNING_ISSUED_AT = dayjs("2026-01-01T12:00:00Z"); const winningUsagePeriod = { issued_at: WINNING_ISSUED_AT.toISOString(), @@ -338,9 +335,8 @@ export const PremiumWithAgentHoursExceededByFraction: Story = { limit: 20000, soft_limit: 16000, hard_limit: 25000, - // The whole-hour actual sits exactly at the allocation, but the - // extra 6 minutes push the tenths-precision value past it, so - // the fraction alone flips the exceeded state. + // The extra 6 minutes push the tenths-precision value past the + // allocation, so the fraction alone flips the exceeded state. actual: 20000, actual_ms: 20_000 * 3_600_000 + 6 * 60_000, usage_period: winningUsagePeriod, @@ -398,8 +394,6 @@ export const LowerAgentHoursCardUsesMergedEntitlement: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Usage belongs to the winning 20,000-hour license, so this card - // shows no usage and no overage. await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( "\u2014 / 10,000", ); @@ -411,10 +405,8 @@ export const LowerAgentHoursCardUsesMergedEntitlement: Story = { export const ReplacedDuplicateAllocationShowsNoUsage: Story = { args: { - // An older license with the same allocation as the winning renewal. - // Only the license whose iat matches the merged usage period shows - // usage, so this card stays free of usage and overage even though - // its allocation equals the merged limit. + // Same allocation as the winning renewal but an older usage + // period, so the merged usage does not belong to this license. license: premiumLicenseWithAgentHours( 20000, WINNING_ISSUED_AT.subtract(1, "year"), @@ -455,10 +447,8 @@ const sameIssuedAtShorterTermLicense = (() => { export const SameIssuedAtDifferentTermEndShowsNoUsage: Story = { args: { - // Same iat and allocation as the winning license, but a shorter - // term. Feature.Compare tie-breaks equal issued-at values on the - // period end, so this license loses and must not display the - // merged usage or overage. + // Same iat and allocation as the winning license but a shorter + // term; the backend tie-breaks equal issued-at on the period end. license: sameIssuedAtShorterTermLicense, agentRuntimeHoursFeature: { enabled: true, @@ -496,9 +486,8 @@ const enterpriseLicenseWithAgentHours = (() => { export const EnterpriseWithAgentHours: Story = { args: { - // The backend accepts runtime hour claims on any feature set, so - // an Enterprise license carrying an allocation renders the Coder - // Agents product with its usage. + // Runtime hour claims apply to any feature set, so an Enterprise + // license with an allocation renders the Coder Agents product. license: enterpriseLicenseWithAgentHours, agentRuntimeHoursFeature: { enabled: true, diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 6be6cbb8d5bf1..8758c8a354c8d 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -88,26 +88,20 @@ export const LicenseCard: FC = ({ ? aiGovernanceActual : undefined; - // Agent runtime hour claims, in hours. The -1 allocation is the - // unlimited sentinel; other negative allocations are ignored by the - // backend, and a zero allocation grants the feature disabled. + // Agent runtime hour claims, in hours. -1 means unlimited; other + // negatives are ignored and zero grants the feature disabled. const agentHoursAllocation = license.claims.features.agent_runtime_hours_allocation; - // The backend decodes runtime hour claims for every license - // regardless of feature set, so an Enterprise license carrying a - // usable allocation claim also gets the Coder Agents product. - // Premium licenses without claims are grandfathered into the - // zero-hour (upgrade) display. + // The backend decodes these claims for any feature set, so a + // non-Premium license with a usable claim also shows Coder Agents. const hasAgentHoursClaim = agentHoursAllocation !== undefined && (agentHoursAllocation >= 0 || agentHoursAllocation === -1); const licenseGrantsAgentHours = agentHoursAllocation !== undefined && (agentHoursAllocation > 0 || agentHoursAllocation === -1); - // Thresholds after the backend's claim validation: soft must be - // non-negative and below a positive allocation, hard at or above it. - // Invalid threshold claims are ignored rather than disqualifying the - // license. + // Mirror the backend's threshold validation; invalid claims are + // ignored rather than disqualifying the license. const agentHoursSoftLimitClaim = license.claims.features.agent_runtime_hours_limit_soft; const agentHoursHardLimitClaim = @@ -131,12 +125,9 @@ export const LicenseCard: FC = ({ license, agentRuntimeHoursFeature, ); - // The merged entitlement's usage period is copied verbatim from the - // license the backend selected (issued_at from iat, start from nbf, - // end from exp), so a license only "wins" when all three match. - // Feature.Compare tie-breaks equal issued-at values on the period - // end, so matching issued-at alone could mark two licenses with the - // same second-granularity iat as the winner. + // The merged usage period is copied from the winning license's + // iat/nbf/exp claims. All three must match: issued-at alone can + // collide across licenses. const mergedUsagePeriod = agentRuntimeHoursFeature?.usage_period; const matchesMergedUsagePeriod = license.claims.iat !== undefined && @@ -146,11 +137,8 @@ export const LicenseCard: FC = ({ dayjs.unix(license.claims.iat).isSame(mergedUsagePeriod.issued_at) && dayjs.unix(license.claims.nbf).isSame(mergedUsagePeriod.start) && dayjs.unix(license.claims.exp).isSame(mergedUsagePeriod.end); - // Beyond the usage period, the license's allocation and validated - // thresholds must equal the merged entitlement's: equal limits (or an - // unlimited allocation with the merged limit omitted) and equal - // soft/hard thresholds, since the backend retains only the selected - // license's thresholds. + // The winner's allocation and thresholds must also equal the merged + // entitlement's; an unlimited allocation reports no merged limit. const isWinningAgentHoursLicense = matchesMergedUsagePeriod && agentHoursSoftLimit === agentRuntimeHoursFeature?.soft_limit && @@ -163,16 +151,15 @@ export const LicenseCard: FC = ({ agentHoursAllocation === agentRuntimeHoursFeature?.limit); const canUseAgentHoursUsageForThisLicense = isAgentHoursLicenseApplicable && isWinningAgentHoursLicense; - // Precise usage in tenths of hours, floored via integer math so the - // displayed number and the exceeded state below flip at the same - // instant as the backend's whole-hour warning thresholds. + // Usage floored to tenths of an hour via integer math so the display + // and the exceeded states below flip at the same instant. const agentHoursActualMs = agentRuntimeHoursFeature?.actual_ms; const agentHoursActual = agentHoursActualMs === undefined ? undefined : Math.floor(agentHoursActualMs / 360_000) / 10; - // Usage applies to the winning license's quota. Licenses without an - // allocation show deployment-wide usage in their upgrade card instead. + // Licenses without an allocation show deployment-wide usage in their + // upgrade card. const agentHoursDisplayActual = isAgentHoursLicenseApplicable && (isWinningAgentHoursLicense || !licenseGrantsAgentHours) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts index 52ea45d47e32c..6a419cd7788f1 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/licenseApplicability.ts @@ -3,10 +3,8 @@ import type { GetLicensesResponse } from "#/api/api"; import type { Feature } from "#/api/typesGenerated"; /** - * Usage and overage indicators only apply to licenses that are currently - * effective: past their nbf and not expired, unless the merged entitlement - * for the feature is in its grace period (an expired license can still be - * the one granting the feature while the grace period lasts). + * A license is applicable when past its nbf and not expired, or when the + * feature is in its grace period (an expired license can still grant it). */ export function isLicenseApplicableForFeatureUsage( license: GetLicensesResponse, From d1257d1e0b19f42daab7576c10b4368038baa5db Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 18 Aug 2026 05:18:01 +0000 Subject: [PATCH 54/56] fix(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): repair story queries broken by design updates --- .../CoderAgentsProductCard.stories.tsx | 4 +--- .../LicensesSettingsPage/LicenseCard.stories.tsx | 7 +++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx index 2d59db9ff0722..a50dcd2887fad 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -152,9 +152,7 @@ export const HardLimitExceeded: Story = { const concurrentChats = getMetricValue(canvas, "Concurrent chats"); await expect(concurrentChats).toHaveTextContent("5"); await expect(concurrentChats).toHaveClass("text-content-destructive"); - await expect( - canvas.getByRole("status", { name: "Limit reached" }), - ).toBeInTheDocument(); + await expect(canvas.getByRole("status")).toHaveTextContent("Limit reached"); await userEvent.hover( canvas.getByRole("button", { name: "Concurrent chats information" }), ); diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index a345519c4c6cc..67771f5e9544a 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -320,9 +320,7 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = { const concurrentChats = getMetricValue(canvas, "Concurrent chats"); await expect(concurrentChats).toHaveTextContent("5"); await expect(concurrentChats).toHaveClass("text-content-destructive"); - await expect( - canvas.getByRole("status", { name: "Limit reached" }), - ).toBeInTheDocument(); + await expect(canvas.getByRole("status")).toHaveTextContent("Limit reached"); }, }; @@ -539,7 +537,8 @@ export const PremiumWithAIGovernance: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText(/add-ons/i)).toBeInTheDocument(); - await expect(canvas.getByText(/ai governance/i)).toBeInTheDocument(); + // Matches both the included-products line and the add-on card title. + await expect(canvas.getAllByText(/ai governance/i)).toHaveLength(2); await expect( getIncludedProducts(canvas, "Workspaces + AI Governance"), ).toBeInTheDocument(); From 1863dc2286b9f95805562d8f58c24e572172df86 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 18 Aug 2026 05:57:58 +0000 Subject: [PATCH 55/56] fix(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): assert product card states semantically in stories --- .../CoderAgentsProductCard.stories.tsx | 22 ++++++++----------- .../CoderAgentsProductCard.tsx | 8 ++++++- .../LicenseCard.stories.tsx | 18 +++++++-------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx index a50dcd2887fad..51c13d33b40aa 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.stories.tsx @@ -106,14 +106,12 @@ export const SoftLimitReached: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const hoursValue = getMetricValue(canvas, "Total Agent hours"); - await expect(hoursValue).toHaveTextContent("16,264.3 / 20,000"); - await expect(hoursValue?.querySelector("span")).toHaveClass( - "text-border-warning", + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "16,264.3 / 20,000", + ); + await expect(canvas.getByRole("status")).toHaveTextContent( + "Approaching hours limit", ); - await expect( - canvas.getByText("Coder Agents").closest(".coder-agents-product-card"), - ).toHaveClass("border-border-warning"); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", ); @@ -133,9 +131,7 @@ export const Exceeded: Story = { await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", ); - await expect( - canvas.queryByRole("status", { name: "Limit reached" }), - ).not.toBeInTheDocument(); + await expect(canvas.queryByRole("status")).not.toBeInTheDocument(); }, }; @@ -149,9 +145,9 @@ export const HardLimitExceeded: Story = { await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( "25,000.0 / 20,000", ); - const concurrentChats = getMetricValue(canvas, "Concurrent chats"); - await expect(concurrentChats).toHaveTextContent("5"); - await expect(concurrentChats).toHaveClass("text-content-destructive"); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "5", + ); await expect(canvas.getByRole("status")).toHaveTextContent("Limit reached"); await userEvent.hover( canvas.getByRole("button", { name: "Concurrent chats information" }), diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx index 3e1142983509a..f2db2d3c98af6 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/CoderAgentsProductCard.tsx @@ -71,7 +71,7 @@ const CardContainer: FC<{ }> = ({ className, headerEnd, children }) => (
@@ -165,6 +165,12 @@ export const CoderAgentsProductCard: FC = ({ Limit reached + ) : isSoftLimitReached && !isOverage ? ( + // The soft limit is otherwise only conveyed by the warning + // colors, so announce it for assistive technology too. + + Approaching hours limit + ) : undefined } > diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index 67771f5e9544a..6b81958871577 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -257,14 +257,12 @@ export const PremiumWithAgentHoursSoftLimitReached: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByText("Active")).toBeInTheDocument(); - const hoursValue = getMetricValue(canvas, "Total Agent hours"); - await expect(hoursValue).toHaveTextContent("16,264.3 / 20,000"); - await expect(hoursValue?.querySelector("span")).toHaveClass( - "text-border-warning", + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "16,264.3 / 20,000", + ); + await expect(canvas.getByRole("status")).toHaveTextContent( + "Approaching hours limit", ); - await expect( - canvas.getByText("Coder Agents").closest(".coder-agents-product-card"), - ).toHaveClass("border-border-warning"); await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( "Unlimited", ); @@ -317,9 +315,9 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = { await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( "25,000.0 / 20,000", ); - const concurrentChats = getMetricValue(canvas, "Concurrent chats"); - await expect(concurrentChats).toHaveTextContent("5"); - await expect(concurrentChats).toHaveClass("text-content-destructive"); + await expect(getMetricValue(canvas, "Concurrent chats")).toHaveTextContent( + "5", + ); await expect(canvas.getByRole("status")).toHaveTextContent("Limit reached"); }, }; From c26d4c0581b6ee85e428b78d845a459186547b78 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 18 Aug 2026 06:33:08 +0000 Subject: [PATCH 56/56] fix(site/src/pages/DeploymentSettingsPage/LicensesSettingsPage): treat usage at the allocation boundary as exceeded --- .../LicenseCard.stories.tsx | 29 +++++++++++++++++-- .../LicensesSettingsPage/LicenseCard.tsx | 4 ++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx index 6b81958871577..9b19c823e75be 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.stories.tsx @@ -322,6 +322,31 @@ export const PremiumWithAgentHoursHardLimitExceeded: Story = { }, }; +export const PremiumWithAgentHoursAtAllocation: Story = { + args: { + license: premiumLicenseWithAgentHours(20000), + agentRuntimeHoursFeature: { + enabled: true, + entitlement: "entitled", + limit: 20000, + soft_limit: 16000, + hard_limit: 25000, + // Usage equal to the allocation is already over: the backend + // reports the allocation as reached at this exact boundary. + actual: 20000, + actual_ms: 20_000 * 3_600_000, + usage_period: winningUsagePeriod, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Agent hours exceeded")).toBeInTheDocument(); + await expect(getMetricValue(canvas, "Total Agent hours")).toHaveTextContent( + "20,000.0 / 20,000", + ); + }, +}; + export const PremiumWithAgentHoursExceededByFraction: Story = { args: { license: premiumLicenseWithAgentHours(20000), @@ -331,8 +356,8 @@ export const PremiumWithAgentHoursExceededByFraction: Story = { limit: 20000, soft_limit: 16000, hard_limit: 25000, - // The extra 6 minutes push the tenths-precision value past the - // allocation, so the fraction alone flips the exceeded state. + // The extra 6 minutes render as a tenth past the allocation, + // so the display shows fractional overage. actual: 20000, actual_ms: 20_000 * 3_600_000 + 6 * 60_000, usage_period: winningUsagePeriod, diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 8758c8a354c8d..994f7ea4832f4 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -170,13 +170,15 @@ export const LicenseCard: FC = ({ agentHoursHardLimit !== undefined && agentHoursDisplayActual !== undefined && agentHoursDisplayActual >= agentHoursHardLimit; + // Inclusive: usage equal to the allocation is already over, matching + // the backend's "allocation reached" warning boundary. const isAgentHoursExceeded = canUseAgentHoursUsageForThisLicense && !isAgentHoursHardLimitExceeded && agentHoursAllocation !== undefined && agentHoursAllocation > 0 && agentHoursDisplayActual !== undefined && - agentHoursDisplayActual > agentHoursAllocation; + agentHoursDisplayActual >= agentHoursAllocation; // Advisory only: at or above the soft threshold, still inside the // purchased allocation. Allocation and hard-limit overage supersede // this so the product card never stacks warning on destructive.