mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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)
117 lines
3.7 KiB
Go
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,
|
|
},
|
|
})
|
|
}
|
|
}
|