chore: implement database crud for AI seat usage (#22681)

Creates a new table `ai_seat_state` to keep track of when users consume an ai_seat. Once a user consumes an AI seat, they will forever in this table (as it stands today).
This commit is contained in:
Steven Masley
2026-03-16 11:53:20 -05:00
committed by GitHub
parent b2d8b67ff7
commit cabb611fd9
14 changed files with 275 additions and 0 deletions
+14
View File
@@ -2327,6 +2327,13 @@ func (q *querier) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Tim
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetAPIKeysLastUsedAfter)(ctx, lastUsed)
}
func (q *querier) GetActiveAISeatCount(ctx context.Context) (int64, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceLicense); err != nil {
return 0, err
}
return q.db.GetActiveAISeatCount(ctx)
}
func (q *querier) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceTemplate.All()); err != nil {
return nil, err
@@ -6425,6 +6432,13 @@ func (q *querier) UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg datab
return q.db.UpdateWorkspacesTTLByTemplateID(ctx, arg)
}
func (q *querier) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) error {
if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil {
return err
}
return q.db.UpsertAISeatState(ctx, arg)
}
func (q *querier) UpsertAnnouncementBanners(ctx context.Context, value string) error {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil {
return err
+8
View File
@@ -1163,6 +1163,14 @@ func (s *MethodTestSuite) TestProvisionerJob() {
}
func (s *MethodTestSuite) TestLicense() {
s.Run("GetActiveAISeatCount", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().GetActiveAISeatCount(gomock.Any()).Return(int64(100), nil).AnyTimes()
check.Args().Asserts(rbac.ResourceLicense, policy.ActionRead).Returns(int64(100))
}))
s.Run("UpsertAISeatState", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
dbm.EXPECT().UpsertAISeatState(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
check.Args(database.UpsertAISeatStateParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate)
}))
s.Run("GetLicenses", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
a := database.License{ID: 1}
b := database.License{ID: 2}
+16
View File
@@ -871,6 +871,14 @@ func (m queryMetricsStore) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed
return r0, r1
}
func (m queryMetricsStore) GetActiveAISeatCount(ctx context.Context) (int64, error) {
start := time.Now()
r0, r1 := m.s.GetActiveAISeatCount(ctx)
m.queryLatencies.WithLabelValues("GetActiveAISeatCount").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetActiveAISeatCount").Inc()
return r0, r1
}
func (m queryMetricsStore) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
start := time.Now()
r0, r1 := m.s.GetActivePresetPrebuildSchedules(ctx)
@@ -4414,6 +4422,14 @@ func (m queryMetricsStore) UpdateWorkspacesTTLByTemplateID(ctx context.Context,
return r0
}
func (m queryMetricsStore) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) error {
start := time.Now()
r0 := m.s.UpsertAISeatState(ctx, arg)
m.queryLatencies.WithLabelValues("UpsertAISeatState").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpsertAISeatState").Inc()
return r0
}
func (m queryMetricsStore) UpsertAnnouncementBanners(ctx context.Context, value string) error {
start := time.Now()
r0 := m.s.UpsertAnnouncementBanners(ctx, value)
+29
View File
@@ -1478,6 +1478,21 @@ func (mr *MockStoreMockRecorder) GetAPIKeysLastUsedAfter(ctx, lastUsed any) *gom
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAPIKeysLastUsedAfter", reflect.TypeOf((*MockStore)(nil).GetAPIKeysLastUsedAfter), ctx, lastUsed)
}
// GetActiveAISeatCount mocks base method.
func (m *MockStore) GetActiveAISeatCount(ctx context.Context) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetActiveAISeatCount", ctx)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetActiveAISeatCount indicates an expected call of GetActiveAISeatCount.
func (mr *MockStoreMockRecorder) GetActiveAISeatCount(ctx any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveAISeatCount", reflect.TypeOf((*MockStore)(nil).GetActiveAISeatCount), ctx)
}
// GetActivePresetPrebuildSchedules mocks base method.
func (m *MockStore) GetActivePresetPrebuildSchedules(ctx context.Context) ([]database.TemplateVersionPresetPrebuildSchedule, error) {
m.ctrl.T.Helper()
@@ -8244,6 +8259,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspacesTTLByTemplateID(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesTTLByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesTTLByTemplateID), ctx, arg)
}
// UpsertAISeatState mocks base method.
func (m *MockStore) UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertAISeatState", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertAISeatState indicates an expected call of UpsertAISeatState.
func (mr *MockStoreMockRecorder) UpsertAISeatState(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertAISeatState", reflect.TypeOf((*MockStore)(nil).UpsertAISeatState), ctx, arg)
}
// UpsertAnnouncementBanners mocks base method.
func (m *MockStore) UpsertAnnouncementBanners(ctx context.Context, value string) error {
m.ctrl.T.Helper()
+20
View File
@@ -10,6 +10,11 @@ CREATE TYPE agent_key_scope_enum AS ENUM (
'no_user_data'
);
CREATE TYPE ai_seat_usage_reason AS ENUM (
'aibridge',
'task'
);
CREATE TYPE api_key_scope AS ENUM (
'coder:all',
'coder:application_connect',
@@ -1046,6 +1051,15 @@ BEGIN
END;
$$;
CREATE TABLE ai_seat_state (
user_id uuid NOT NULL,
first_used_at timestamp with time zone NOT NULL,
last_used_at timestamp with time zone NOT NULL,
last_event_type ai_seat_usage_reason NOT NULL,
last_event_description text NOT NULL,
updated_at timestamp with time zone NOT NULL
);
CREATE TABLE aibridge_interceptions (
id uuid NOT NULL,
initiator_id uuid NOT NULL,
@@ -3156,6 +3170,9 @@ ALTER TABLE ONLY workspace_resource_metadata ALTER COLUMN id SET DEFAULT nextval
ALTER TABLE ONLY workspace_agent_stats
ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id);
ALTER TABLE ONLY ai_seat_state
ADD CONSTRAINT ai_seat_state_pkey PRIMARY KEY (user_id);
ALTER TABLE ONLY aibridge_interceptions
ADD CONSTRAINT aibridge_interceptions_pkey PRIMARY KEY (id);
@@ -3816,6 +3833,9 @@ COMMENT ON TRIGGER workspace_agent_name_unique_trigger ON workspace_agents IS 'U
the uniqueness requirement. A trigger allows us to enforce uniqueness going
forward without requiring a migration to clean up historical data.';
ALTER TABLE ONLY ai_seat_state
ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY aibridge_interceptions
ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
@@ -6,6 +6,7 @@ type ForeignKeyConstraint string
// ForeignKeyConstraint enums.
const (
ForeignKeyAiSeatStateUserID ForeignKeyConstraint = "ai_seat_state_user_id_fkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyAibridgeInterceptionsInitiatorID ForeignKeyConstraint = "aibridge_interceptions_initiator_id_fkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_initiator_id_fkey FOREIGN KEY (initiator_id) REFERENCES users(id);
ForeignKeyAPIKeysUserIDUUID ForeignKeyConstraint = "api_keys_user_id_uuid_fkey" // ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_user_id_uuid_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyChatDiffStatusesChatID ForeignKeyConstraint = "chat_diff_statuses_chat_id_fkey" // ALTER TABLE ONLY chat_diff_statuses ADD CONSTRAINT chat_diff_statuses_chat_id_fkey FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
@@ -0,0 +1,3 @@
DROP TABLE ai_seat_state;
DROP TYPE ai_seat_usage_reason;
@@ -0,0 +1,13 @@
CREATE TYPE ai_seat_usage_reason AS ENUM (
'aibridge',
'task'
);
CREATE TABLE ai_seat_state (
user_id uuid NOT NULL PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
first_used_at timestamptz NOT NULL,
last_used_at timestamptz NOT NULL,
last_event_type ai_seat_usage_reason NOT NULL,
last_event_description text NOT NULL,
updated_at timestamptz NOT NULL
);
@@ -0,0 +1,11 @@
INSERT INTO
ai_seat_state (
user_id,
first_used_at,
last_used_at,
last_event_type,
last_event_description,
updated_at
)
VALUES
('30095c71-380b-457a-8995-97b8ee6e5307', NOW(), NOW(), 'task'::ai_seat_usage_reason, 'Used for AI task', NOW());
+67
View File
@@ -741,6 +741,64 @@ func AllAgentKeyScopeEnumValues() []AgentKeyScopeEnum {
}
}
type AiSeatUsageReason string
const (
AiSeatUsageReasonAibridge AiSeatUsageReason = "aibridge"
AiSeatUsageReasonTask AiSeatUsageReason = "task"
)
func (e *AiSeatUsageReason) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = AiSeatUsageReason(s)
case string:
*e = AiSeatUsageReason(s)
default:
return fmt.Errorf("unsupported scan type for AiSeatUsageReason: %T", src)
}
return nil
}
type NullAiSeatUsageReason struct {
AiSeatUsageReason AiSeatUsageReason `json:"ai_seat_usage_reason"`
Valid bool `json:"valid"` // Valid is true if AiSeatUsageReason is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullAiSeatUsageReason) Scan(value interface{}) error {
if value == nil {
ns.AiSeatUsageReason, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.AiSeatUsageReason.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullAiSeatUsageReason) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.AiSeatUsageReason), nil
}
func (e AiSeatUsageReason) Valid() bool {
switch e {
case AiSeatUsageReasonAibridge,
AiSeatUsageReasonTask:
return true
}
return false
}
func AllAiSeatUsageReasonValues() []AiSeatUsageReason {
return []AiSeatUsageReason{
AiSeatUsageReasonAibridge,
AiSeatUsageReasonTask,
}
}
type AppSharingLevel string
const (
@@ -3975,6 +4033,15 @@ type APIKey struct {
AllowList AllowList `db:"allow_list" json:"allow_list"`
}
type AiSeatState struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
FirstUsedAt time.Time `db:"first_used_at" json:"first_used_at"`
LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"`
LastEventType AiSeatUsageReason `db:"last_event_type" json:"last_event_type"`
LastEventDescription string `db:"last_event_description" json:"last_event_description"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type AuditLog struct {
ID uuid.UUID `db:"id" json:"id"`
Time time.Time `db:"time" json:"time"`
+2
View File
@@ -187,6 +187,7 @@ type sqlcQuerier interface {
GetAPIKeysByLoginType(ctx context.Context, arg GetAPIKeysByLoginTypeParams) ([]APIKey, error)
GetAPIKeysByUserID(ctx context.Context, arg GetAPIKeysByUserIDParams) ([]APIKey, error)
GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error)
GetActiveAISeatCount(ctx context.Context) (int64, error)
GetActivePresetPrebuildSchedules(ctx context.Context) ([]TemplateVersionPresetPrebuildSchedule, error)
GetActiveUserCount(ctx context.Context, includeSystem bool) (int64, error)
GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]WorkspaceBuild, error)
@@ -834,6 +835,7 @@ type sqlcQuerier interface {
UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error
UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]WorkspaceTable, error)
UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg UpdateWorkspacesTTLByTemplateIDParams) error
UpsertAISeatState(ctx context.Context, arg UpsertAISeatStateParams) error
UpsertAnnouncementBanners(ctx context.Context, value string) error
UpsertApplicationName(ctx context.Context, value string) error
// Upserts boundary usage statistics for a replica. On INSERT (new period), uses
+58
View File
@@ -1198,6 +1198,64 @@ func (q *sqlQuerier) UpdateAIBridgeInterceptionEnded(ctx context.Context, arg Up
return i, err
}
const getActiveAISeatCount = `-- name: GetActiveAISeatCount :one
SELECT
COUNT(*)
FROM
ai_seat_state ais
JOIN
users u
ON
ais.user_id = u.id
WHERE
u.status = 'active'::user_status
AND u.deleted = false
AND u.is_system = false
`
func (q *sqlQuerier) GetActiveAISeatCount(ctx context.Context) (int64, error) {
row := q.db.QueryRowContext(ctx, getActiveAISeatCount)
var count int64
err := row.Scan(&count)
return count, err
}
const upsertAISeatState = `-- name: UpsertAISeatState :exec
INSERT INTO ai_seat_state (
user_id,
first_used_at,
last_used_at,
last_event_type,
last_event_description,
updated_at
)
VALUES
($1, $2, $2, $3, $4, $2)
ON CONFLICT (user_id) DO UPDATE
SET
last_used_at = EXCLUDED.last_used_at,
last_event_type = EXCLUDED.last_event_type,
last_event_description = EXCLUDED.last_event_description,
updated_at = EXCLUDED.updated_at
`
type UpsertAISeatStateParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
FirstUsedAt time.Time `db:"first_used_at" json:"first_used_at"`
LastEventType AiSeatUsageReason `db:"last_event_type" json:"last_event_type"`
LastEventDescription string `db:"last_event_description" json:"last_event_description"`
}
func (q *sqlQuerier) UpsertAISeatState(ctx context.Context, arg UpsertAISeatStateParams) error {
_, err := q.db.ExecContext(ctx, upsertAISeatState,
arg.UserID,
arg.FirstUsedAt,
arg.LastEventType,
arg.LastEventDescription,
)
return err
}
const deleteAPIKeyByID = `-- name: DeleteAPIKeyByID :exec
DELETE FROM
api_keys
+32
View File
@@ -0,0 +1,32 @@
-- name: UpsertAISeatState :exec
INSERT INTO ai_seat_state (
user_id,
first_used_at,
last_used_at,
last_event_type,
last_event_description,
updated_at
)
VALUES
($1, $2, $2, $3, $4, $2)
ON CONFLICT (user_id) DO UPDATE
SET
last_used_at = EXCLUDED.last_used_at,
last_event_type = EXCLUDED.last_event_type,
last_event_description = EXCLUDED.last_event_description,
updated_at = EXCLUDED.updated_at
;
-- name: GetActiveAISeatCount :one
SELECT
COUNT(*)
FROM
ai_seat_state ais
JOIN
users u
ON
ais.user_id = u.id
WHERE
u.status = 'active'::user_status
AND u.deleted = false
AND u.is_system = false;
+1
View File
@@ -7,6 +7,7 @@ type UniqueConstraint string
// UniqueConstraint enums.
const (
UniqueAgentStatsPkey UniqueConstraint = "agent_stats_pkey" // ALTER TABLE ONLY workspace_agent_stats ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id);
UniqueAiSeatStatePkey UniqueConstraint = "ai_seat_state_pkey" // ALTER TABLE ONLY ai_seat_state ADD CONSTRAINT ai_seat_state_pkey PRIMARY KEY (user_id);
UniqueAibridgeInterceptionsPkey UniqueConstraint = "aibridge_interceptions_pkey" // ALTER TABLE ONLY aibridge_interceptions ADD CONSTRAINT aibridge_interceptions_pkey PRIMARY KEY (id);
UniqueAibridgeTokenUsagesPkey UniqueConstraint = "aibridge_token_usages_pkey" // ALTER TABLE ONLY aibridge_token_usages ADD CONSTRAINT aibridge_token_usages_pkey PRIMARY KEY (id);
UniqueAibridgeToolUsagesPkey UniqueConstraint = "aibridge_tool_usages_pkey" // ALTER TABLE ONLY aibridge_tool_usages ADD CONSTRAINT aibridge_tool_usages_pkey PRIMARY KEY (id);