mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
feat: report agent runtime hours usage in entitlements (#27985)
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 with `created_at` at the bucket start, enforced by the unique partial index introduced in #27983. The measurement reuses the shared `measureUsage` policy from #27984 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 per refresh: 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. **Precise usage.** `Feature.ActualMs` (JSON `actual_ms`), set only for `agent_runtime_hours`, carries the exact stored milliseconds backing the floored `Actual` so clients can render fractional hours (e.g. `10.3`). It has the same freshness as `Actual`; the whole-hour warning thresholds are unchanged. **Unlimited licenses.** A license minted with the unlimited (`-1`) allocation decodes to an enabled feature with a nil `Limit` (#27984), 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, while `Actual` is still measured and published. `Feature.Compare` is unchanged; for usage-period features the issued-at/end dates decide first, so a metered feature outranks an unlimited one only on an exact timestamp tie, an edge pinned by a `TestFeatureComparison` case and documented on `decodeAgentRuntimeHours`. **Grandfathered premium licenses.** Premium licenses without `agent_runtime_hours_*` claims are now granted the feature disabled with a zero limit over the license term, identical to an explicit `allocation: 0`: usage is measured and published for every Premium deployment, and chatd's pooled admission (#27902) caps concurrent agentic chats until a license with a positive allocation is added. The default carries a fixed early `UsagePeriod.IssuedAt` (2026-08-01, the same mechanism as the managed-agents default) so any license actually carrying the claims outranks it in the `AddFeature` merge regardless of the licenses' relative issue dates; the constant must stay earlier than the earliest legitimately issued claim-bearing license. Zero allocations (explicit or grandfathered) emit no deployment-wide warning banner: those deployments are steered by the in-page upgrade CTA and the concurrency cap. Enterprise licenses are unchanged. Part 3 of a 3-PR stack splitting up #27796 (see there for review history). Stack: #27983 → #27984 → this PR. Closes CODAGT-852.
This commit is contained in:
Generated
+8
-2
@@ -20841,6 +20841,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, 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": {
|
||||
@@ -20854,14 +20859,15 @@ 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.\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; an unlimited allocation has no thresholds,\nso SoftLimit is omitted alongside the omitted Limit. Only\nFeatureAgentRuntimeHours sets this field.",
|
||||
"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"
|
||||
|
||||
Generated
+8
-2
@@ -18963,6 +18963,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, 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": {
|
||||
@@ -18976,14 +18981,15 @@
|
||||
"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.\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; an unlimited allocation has no thresholds,\nso SoftLimit is omitted alongside the omitted Limit. Only\nFeatureAgentRuntimeHours sets this field.",
|
||||
"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"
|
||||
|
||||
@@ -4949,6 +4949,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
|
||||
|
||||
@@ -6532,6 +6532,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",
|
||||
|
||||
+8
@@ -3129,6 +3129,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)
|
||||
|
||||
Generated
+15
@@ -5865,6 +5865,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()
|
||||
|
||||
Generated
+14
@@ -870,6 +870,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)
|
||||
|
||||
@@ -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"
|
||||
@@ -10924,8 +10925,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{
|
||||
@@ -10989,6 +10990,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()
|
||||
|
||||
|
||||
Generated
+38
@@ -28685,6 +28685,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 (
|
||||
|
||||
@@ -117,3 +117,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;
|
||||
|
||||
@@ -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+"'")
|
||||
}
|
||||
}
|
||||
+24
-14
@@ -380,32 +380,42 @@ 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 certain features set this field:
|
||||
// - FeatureAgentRuntimeHours
|
||||
// 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
|
||||
// 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, 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.
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
|
||||
+13
-6
@@ -12,12 +12,19 @@ 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)."
|
||||
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."
|
||||
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)."
|
||||
// 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 = "Your deployment has used %d of the %d Coder Agent runtime hours included in the current license term."
|
||||
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 = "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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Generated
+2
@@ -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,
|
||||
|
||||
Generated
+13
-12
@@ -7541,6 +7541,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,
|
||||
@@ -7554,6 +7555,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,
|
||||
@@ -7841,6 +7843,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,
|
||||
@@ -7856,18 +7859,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 | | |
|
||||
| `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, 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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -140,6 +149,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
|
||||
@@ -173,6 +185,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,
|
||||
@@ -465,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 measureAgentRuntimeMs call below)
|
||||
// and caps concurrent agentic chats the same as an explicit
|
||||
// zero allocation.
|
||||
var (
|
||||
// A fixed issue time that predates any license issued with
|
||||
// agent_runtime_hours_* claims, so a license that actually
|
||||
// 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.
|
||||
@@ -736,6 +783,44 @@ func LicensesEntitlements(
|
||||
}
|
||||
}
|
||||
|
||||
// Usage is measured even for a zero allocation, which reports the
|
||||
// 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 := measureAgentRuntimeMs(ctx, &entitlements,
|
||||
featureArguments.Logger, featureArguments.AgentRuntimeMsFn, *runtimeHours.UsagePeriod)
|
||||
if err != nil {
|
||||
return entitlements, err
|
||||
}
|
||||
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:
|
||||
// AddFeature only replaces the existing entry when the new one
|
||||
// strictly outranks it, so setting Actual on an otherwise
|
||||
// identical feature would be dropped as a tie.
|
||||
entitlements.Features[codersdk.FeatureAgentRuntimeHours] = runtimeHours
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entitlements.HasLicense {
|
||||
userLimit := entitlements.Features[codersdk.FeatureUserLimit]
|
||||
// The enforced count and its meaning come from the selected
|
||||
@@ -865,6 +950,63 @@ func LicensesEntitlements(
|
||||
return entitlements, nil
|
||||
}
|
||||
|
||||
// measureAgentRuntimeMs runs fn over the feature's usage period. A nil fn
|
||||
// or a failure with a dead context fails the whole call; any other failure
|
||||
// logs the cause and publishes the stable unavailable text instead. It
|
||||
// returns the measured milliseconds and true only on success.
|
||||
func measureAgentRuntimeMs(
|
||||
ctx context.Context,
|
||||
entitlements *codersdk.Entitlements,
|
||||
logger slog.Logger,
|
||||
fn AgentRuntimeMsFn,
|
||||
usagePeriod codersdk.UsagePeriod,
|
||||
) (int64, bool, error) {
|
||||
if fn == nil {
|
||||
return 0, false, xerrors.New("developer error: no closure provided to measure agent runtime usage")
|
||||
}
|
||||
value, err := fn(ctx, usagePeriod.Start, usagePeriod.End)
|
||||
switch {
|
||||
case err != nil && ctx.Err() != nil:
|
||||
// Do not classify cancellation by error shape instead of ctx.Err():
|
||||
// Postgres raises SQLSTATE 57014 (query_canceled) for
|
||||
// statement_timeout kills as well as client cancels, and aborting on
|
||||
// those would fail every entitlements refresh on a deployment whose
|
||||
// statement_timeout is shorter than a usage query.
|
||||
return 0, false, xerrors.Errorf("get agent runtime: %w", err)
|
||||
case err != nil:
|
||||
logger.Error(ctx, "get agent runtime for entitlements", slog.Error(err))
|
||||
entitlements.Errors = append(entitlements.Errors, codersdk.LicenseAgentRuntimeUsageUnavailableErrorText)
|
||||
return 0, false, nil
|
||||
}
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
@@ -948,6 +1090,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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ package coderd_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -155,6 +156,22 @@ 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, 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.
|
||||
// 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) {
|
||||
|
||||
Generated
+46
-12
@@ -5187,14 +5187,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 certain features set this field:
|
||||
* - FeatureAgentRuntimeHours
|
||||
* 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;
|
||||
/**
|
||||
@@ -5203,18 +5207,30 @@ 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, 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
|
||||
* 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;
|
||||
}
|
||||
@@ -5763,10 +5779,28 @@ 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
|
||||
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
|
||||
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
|
||||
/**
|
||||
* 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
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
|
||||
LicenseAgentRuntimeHoursSoftLimitWarningText,
|
||||
LicenseAgentRuntimeUsageUnavailableErrorText,
|
||||
LicenseAIGovernance90PercentWarningText,
|
||||
LicenseAIGovernanceOverLimitWarningText,
|
||||
LicenseManagedAgentLimitExceededWarningText,
|
||||
@@ -18,6 +20,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.";
|
||||
|
||||
@@ -26,8 +30,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,
|
||||
@@ -37,6 +45,7 @@ const formatLicenseMessage = (template: string, ...values: number[]): string =>
|
||||
// usage itself. They render muted, without the exceedance heading or a sales
|
||||
// link, even when they arrive via entitlements.errors.
|
||||
const diagnosticMessages: readonly string[] = [
|
||||
LicenseAgentRuntimeUsageUnavailableErrorText,
|
||||
LicenseAgentRuntimeHoursClaimsIgnoredWarningText,
|
||||
];
|
||||
|
||||
@@ -44,9 +53,10 @@ const isDiagnosticMessage = (message: string): boolean =>
|
||||
diagnosticMessages.includes(message);
|
||||
|
||||
// Advisories render muted to stay visually distinct from warnings that
|
||||
// demand action, such as exceeding a license limit.
|
||||
// demand action, such as reaching the runtime hours allocation.
|
||||
const isAdvisoryMessage = (message: string): boolean =>
|
||||
message.startsWith(aiGovernanceNearLimitWarningPrefix);
|
||||
message.startsWith(aiGovernanceNearLimitWarningPrefix) ||
|
||||
message.startsWith(agentRuntimeSoftLimitWarningPrefix);
|
||||
|
||||
const aiGovernanceOverLimitMessage = (
|
||||
feature: ReturnType<
|
||||
@@ -139,6 +149,11 @@ const messageLink = (message: string): LicenseBannerLink | undefined => {
|
||||
showExternalIcon: false,
|
||||
};
|
||||
}
|
||||
// The soft-limit advisory fires inside the purchased allocation, so it
|
||||
// does not get a sales link.
|
||||
if (message.startsWith(agentRuntimeSoftLimitWarningPrefix)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
href: "mailto:sales@coder.com",
|
||||
label: "Contact sales@coder.com.",
|
||||
|
||||
@@ -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,
|
||||
LicenseTelemetryRequiredErrorText,
|
||||
@@ -16,7 +19,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<typeof LicenseBannerView> = {
|
||||
@@ -60,7 +63,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" }),
|
||||
@@ -295,6 +298,83 @@ 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.",
|
||||
);
|
||||
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(
|
||||
canvas.getByRole("link", { name: /Contact sales@coder\.com/i }),
|
||||
).toHaveAttribute("href", "mailto:sales@coder.com");
|
||||
},
|
||||
};
|
||||
|
||||
// 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: [LicenseAgentRuntimeUsageUnavailableErrorText],
|
||||
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(
|
||||
LicenseAgentRuntimeUsageUnavailableErrorText,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Each diagnostic pins role=status (not alert) and a suppressed sales
|
||||
// link. Background mutedness is covered by the visual snapshot.
|
||||
const playMutedDiagnostic =
|
||||
@@ -308,6 +388,14 @@ const playMutedDiagnostic =
|
||||
).not.toBeInTheDocument();
|
||||
};
|
||||
|
||||
export const AgentRuntimeUsageUnavailable: Story = {
|
||||
render: () =>
|
||||
renderLicenseBanner({
|
||||
errors: [LicenseAgentRuntimeUsageUnavailableErrorText],
|
||||
}),
|
||||
play: playMutedDiagnostic(LicenseAgentRuntimeUsageUnavailableErrorText),
|
||||
};
|
||||
|
||||
export const AgentRuntimeHoursClaimsIgnored: Story = {
|
||||
render: () =>
|
||||
renderLicenseBanner({
|
||||
@@ -316,10 +404,12 @@ export const AgentRuntimeHoursClaimsIgnored: Story = {
|
||||
play: playMutedDiagnostic(LicenseAgentRuntimeHoursClaimsIgnoredWarningText),
|
||||
};
|
||||
|
||||
// An all-diagnostic banner must not claim license limits were exceeded.
|
||||
// An all-diagnostic banner must not claim license limits were exceeded,
|
||||
// even when a diagnostic arrives via entitlements.errors.
|
||||
export const UsageDiagnosticsOnlyHeading: Story = {
|
||||
render: () =>
|
||||
renderLicenseBanner({
|
||||
errors: [LicenseAgentRuntimeUsageUnavailableErrorText],
|
||||
warnings: [LicenseAgentRuntimeHoursClaimsIgnoredWarningText],
|
||||
}),
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -327,7 +417,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"),
|
||||
|
||||
@@ -75,13 +75,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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user