Files
coder/enterprise/aiseats/tracker.go
T
Danny Kopping a1330e3a8c refactor: rename Ai* database identifiers to AI* (AIGOV-369) (#26327)
Adds `ai` to sqlc's `gen.go.initialisms` in `coderd/database/sqlc.yaml`
so the generated DB code follows Go's initialism convention. Adds the
matching `ai` -> `AI` case to the dbgen PascalCase helper
(`scripts/dbgen/main.go`) so the corresponding `dbmem` / mock
identifiers stay in sync. `make gen` regenerates the rest; hand-written
call sites that consume DB-generated identifiers
(`enterprise/audit/table.go`, `coderd/database/modelmethods.go`,
`enterprise/coderd/aigatewaykeys.go`, `coderd/database/dbauthz/*`, etc.)
are updated to match.

Scope is deliberately limited to the database layer:

- `coderd/rbac/*` (resource and scope generators) is untouched —
`ResourceAi*` / `ScopeAi*` constants stay on main's casing.
- `codersdk/*` (Go SDK) is untouched — `codersdk.ResourceAi*` /
`codersdk.APIKeyScopeAi*` constants stay on main's casing, so external
Go SDK consumers see no source-level break.
- `Aibridge*` identifiers (one SQL token `aibridge`, not `ai_bridge`)
are out of scope.

On-the-wire values are unchanged: enum strings, RBAC resource type
strings, API key scope strings, and JSON tags all stay the same. The
HTTP/JSON surface is unaffected.

Refs:
[AIGOV-369](https://linear.app/codercom/issue/AIGOV-369/change-ai-references-in-coderddatabasemodelsgo-to-ai)

🤖 Generated with [Coder Agents](https://coder.com)
2026-06-16 09:01:43 +00:00

117 lines
3.7 KiB
Go

package aiseats
import (
"context"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
"cdr.dev/slog/v3"
agplaiseats "github.com/coder/coder/v2/coderd/aiseats"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/quartz"
)
type store interface {
UpsertAISeatState(ctx context.Context, arg database.UpsertAISeatStateParams) (bool, error)
}
// throttleInterval is the minimum time between DB writes for the same user. This
// is to prevent ai seat tracking from consuming more db resources.
//
// These events are not critical to be recorded in real time, so we can afford to
// skip almost all of them. The first write is the most important, as it
// indicates a seat is consumed. Subsequent writes are purely informative and has
// no functional impact.
const (
throttleInterval = 6 * time.Hour
// failedThrottleInterval exists to prevent a transient error from causing no
// usage to be recorded. Still debounce.
failedThrottleInterval = 30 * time.Minute
)
// SeatTracker records current AI seat state for users.
type SeatTracker struct {
db store
logger slog.Logger
clock quartz.Clock
auditor *atomic.Pointer[audit.Auditor]
mu sync.RWMutex
retryAfter map[uuid.UUID]time.Time
}
func New(db store, logger slog.Logger, clock quartz.Clock, auditor *atomic.Pointer[audit.Auditor]) *SeatTracker {
if clock == nil {
clock = quartz.NewReal()
}
return &SeatTracker{db: db, logger: logger, clock: clock, auditor: auditor, retryAfter: make(map[uuid.UUID]time.Time)}
}
// skipRecord returns true when the user is still in the retry cooldown
// window and we should skip a DB write attempt.
func (t *SeatTracker) skipRecord(userID uuid.UUID, now time.Time) bool {
t.mu.RLock()
defer t.mu.RUnlock()
retryAfter, ok := t.retryAfter[userID]
return ok && now.Before(retryAfter)
}
// recordThrottle sets the next time when DB writes for this user are allowed.
func (t *SeatTracker) recordThrottle(userID uuid.UUID, now time.Time, d time.Duration) {
t.mu.Lock()
defer t.mu.Unlock()
t.retryAfter[userID] = now.Add(d)
}
// RecordUsage will record the AI seat usage for the user. There is a race condition between
// checking if the user should be recorded or throttled and actually recording. This is fine, as
// it just means we record the usage twice.
// The throttle just exists to prevent excessive database queries.
func (t *SeatTracker) RecordUsage(ctx context.Context, userID uuid.UUID, reason agplaiseats.Reason) {
now := t.clock.Now()
if t.skipRecord(userID, now) {
return
}
isNew, err := t.db.UpsertAISeatState(ctx, database.UpsertAISeatStateParams{
UserID: userID,
FirstUsedAt: now,
LastEventType: reason.EventType,
LastEventDescription: reason.Description,
})
if err != nil {
t.logger.Warn(ctx, "upsert AI seat state", slog.Error(err), slog.F("user_id", userID), slog.F("event_type", reason.EventType))
t.recordThrottle(userID, now, failedThrottleInterval)
return
}
t.recordThrottle(userID, now, throttleInterval)
if isNew && t.auditor != nil {
// Record an audit log for the first time a user uses an AI seat.
auditor := t.auditor.Load()
if auditor == nil || *auditor == nil {
return
}
audit.BackgroundAudit[database.AISeatState](ctx, &audit.BackgroundAuditParams[database.AISeatState]{
Audit: *auditor,
Log: t.logger,
UserID: userID,
Time: now,
Action: database.AuditActionCreate,
New: database.AISeatState{
UserID: userID,
FirstUsedAt: now,
LastUsedAt: now,
LastEventType: reason.EventType,
LastEventDescription: reason.Description,
UpdatedAt: now,
},
})
}
}