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:
Jaayden Halko
2026-08-18 12:40:33 +07:00
committed by GitHub
parent 30dc7ebd71
commit fa8ffe4eda
24 changed files with 1532 additions and 77 deletions
+84 -2
View File
@@ -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()