mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
1a91d31793
Implements https://linear.app/codercom/issue/AIGOV-285 Follow the structure established in https://github.com/coder/coder/pull/25203 ## Summary Adds the `user_ai_budget_overrides` table and CRUD API at `/api/v2/users/{user}/ai/budget`. An override sets a custom per-user spend cap that supersedes group-budget resolution, attributing spend to a specific group. ## Schema ```sql CREATE TABLE user_ai_budget_overrides ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, spend_limit_micros BIGINT NOT NULL CHECK (spend_limit_micros >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); ``` ## Membership lifecycle The membership invariant — a user must be a member of the attributed group, including when that group is "Everyone" — would naturally be expressed as a composite FK on `(user_id, group_id) → group_members_expanded(user_id, group_id)`. PostgreSQL doesn't allow foreign keys to reference views, so enforcement is split across two mechanisms: - **Write-time check.** A CHECK constraint on the table (`user_ai_budget_overrides_must_be_group_member`) calls a `STABLE` function `is_group_member(user_id, group_id)` that queries `group_members_expanded`. The view surfaces both regular group memberships and the implicit "Everyone" group memberships from `organization_members`. Any INSERT or UPDATE that violates the predicate is rejected with a Postgres `check_violation`, which the handler maps to a 400. `is_group_member` is defined as a general predicate, reusable by any future table that needs the same check. - **Cascade on removal.** Two `BEFORE DELETE` triggers handle membership loss: - `trigger_delete_user_ai_budget_overrides_on_group_member_delete` on `group_members` — covers regular group removals (admin action, OIDC sync). - `trigger_delete_user_ai_budget_overrides_on_org_member_delete` on `organization_members` — covers the "Everyone" group, whose membership lives in `organization_members`. The single-column FKs on `users(id)` and `groups(id)` remain to cascade on user or group deletion (those paths don't pass through `group_members`). ## Authorization The dbauthz layer gates each operation against the `User` and (for writes) `Group` resources: | Operation | User resource | Group resource | |-----------|----------------|----------------| | `GET` | `ActionRead` | — | | `PUT` | `ActionUpdate` | `ActionUpdate` | | `DELETE` | `ActionUpdate` | `ActionUpdate` | For `DELETE`, the dbauthz layer fetches the existing override first to learn the attributed `group_id`, then runs both checks. ### Role matrix | Role | GET | PUT | DELETE | |--------------|-----|-----|--------| | Owner | ✅ | ✅ | ✅ | | UserAdmin | ✅ | ✅ | ✅ | | OrgAdmin | ✅ | ❌ | ❌ | | OrgUserAdmin | ✅ | ❌ | ❌ | Internal discussion: https://codercom.slack.com/archives/C096PFVBZKN/p1779392747885359 ## Audit logs Audit logs will be addressed in a follow-up PR.
2177 lines
71 KiB
Go
2177 lines
71 KiB
Go
// Package db2sdk provides common conversion routines from database types to codersdk types
|
|
package db2sdk
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/hashicorp/hcl/v2"
|
|
"github.com/sqlc-dev/pqtype"
|
|
"golang.org/x/xerrors"
|
|
"tailscale.com/tailcfg"
|
|
|
|
agentproto "github.com/coder/coder/v2/agent/proto"
|
|
aibridgeutils "github.com/coder/coder/v2/aibridge/utils"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/externalauth/gitprovider"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/render"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/coderd/util/slice"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps/appurl"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/provisionersdk/proto"
|
|
"github.com/coder/coder/v2/tailnet"
|
|
previewtypes "github.com/coder/preview/types"
|
|
)
|
|
|
|
func APIAllowListTarget(entry rbac.AllowListElement) codersdk.APIAllowListTarget {
|
|
return codersdk.APIAllowListTarget{
|
|
Type: codersdk.RBACResource(entry.Type),
|
|
ID: entry.ID,
|
|
}
|
|
}
|
|
|
|
// AIProvider converts a database row plus its API keys into the
|
|
// codersdk shape. The caller is responsible for ensuring the row and
|
|
// keys have been decrypted (i.e. fetched through the dbcrypt-wrapped
|
|
// store). Each api_key is masked via aibridge utils.MaskSecret and
|
|
// write-only fields on Settings are stripped, so the result is safe
|
|
// to echo back in API responses.
|
|
func AIProvider(row database.AIProvider, keys []database.AIProviderKey) (codersdk.AIProvider, error) {
|
|
display := row.Name
|
|
if row.DisplayName.Valid && row.DisplayName.String != "" {
|
|
display = row.DisplayName.String
|
|
}
|
|
out := codersdk.AIProvider{
|
|
ID: row.ID,
|
|
Type: codersdk.AIProviderType(row.Type),
|
|
Name: row.Name,
|
|
DisplayName: display,
|
|
Enabled: row.Enabled,
|
|
BaseURL: row.BaseUrl,
|
|
APIKeys: maskAIProviderKeys(keys),
|
|
CreatedAt: row.CreatedAt,
|
|
UpdatedAt: row.UpdatedAt,
|
|
}
|
|
s, err := AIProviderSettings(row.Settings)
|
|
if err != nil {
|
|
return codersdk.AIProvider{}, xerrors.Errorf("decode settings: %w", err)
|
|
}
|
|
out.Settings = redactAIProviderSettings(s)
|
|
return out, nil
|
|
}
|
|
|
|
// AIProviderSettings parses the on-disk JSON form back into a codersdk
|
|
// settings value. SQL NULL and the empty string decode to the zero
|
|
// value.
|
|
func AIProviderSettings(col sql.NullString) (codersdk.AIProviderSettings, error) {
|
|
if !col.Valid || col.String == "" {
|
|
return codersdk.AIProviderSettings{}, nil
|
|
}
|
|
var s codersdk.AIProviderSettings
|
|
if err := json.Unmarshal([]byte(col.String), &s); err != nil {
|
|
return codersdk.AIProviderSettings{}, err
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// maskAIProviderKeys converts the supplied database rows into the
|
|
// public-facing AIProviderKey shape, preserving order. Plaintext is
|
|
// replaced by a non-reversible mask (see aibridgeutils.MaskSecret) so
|
|
// the result is safe to embed in API responses.
|
|
func maskAIProviderKeys(keys []database.AIProviderKey) []codersdk.AIProviderKey {
|
|
out := make([]codersdk.AIProviderKey, 0, len(keys))
|
|
for _, k := range keys {
|
|
out = append(out, codersdk.AIProviderKey{
|
|
ID: k.ID,
|
|
Masked: aibridgeutils.MaskSecret(k.APIKey),
|
|
CreatedAt: k.CreatedAt,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// redactAIProviderSettings strips write-only fields from a settings
|
|
// value so it can be safely echoed back in API responses.
|
|
func redactAIProviderSettings(s codersdk.AIProviderSettings) codersdk.AIProviderSettings {
|
|
out := s
|
|
if out.Bedrock != nil {
|
|
// Deep-copy so we don't mutate the caller's struct.
|
|
b := *out.Bedrock
|
|
b.AccessKey = nil
|
|
b.AccessKeySecret = nil
|
|
out.Bedrock = &b
|
|
}
|
|
return out
|
|
}
|
|
|
|
type ExternalAuthMeta struct {
|
|
Authenticated bool
|
|
ValidateError string
|
|
}
|
|
|
|
func ExternalAuths(auths []database.ExternalAuthLink, meta map[string]ExternalAuthMeta) []codersdk.ExternalAuthLink {
|
|
out := make([]codersdk.ExternalAuthLink, 0, len(auths))
|
|
for _, auth := range auths {
|
|
out = append(out, ExternalAuth(auth, meta[auth.ProviderID]))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func ExternalAuth(auth database.ExternalAuthLink, meta ExternalAuthMeta) codersdk.ExternalAuthLink {
|
|
return codersdk.ExternalAuthLink{
|
|
ProviderID: auth.ProviderID,
|
|
CreatedAt: auth.CreatedAt,
|
|
UpdatedAt: auth.UpdatedAt,
|
|
HasRefreshToken: auth.OAuthRefreshToken != "",
|
|
Expires: auth.OAuthExpiry,
|
|
Authenticated: meta.Authenticated,
|
|
ValidateError: meta.ValidateError,
|
|
}
|
|
}
|
|
|
|
func WorkspaceBuildParameter(p database.WorkspaceBuildParameter) codersdk.WorkspaceBuildParameter {
|
|
return codersdk.WorkspaceBuildParameter{
|
|
Name: p.Name,
|
|
Value: p.Value,
|
|
}
|
|
}
|
|
|
|
func WorkspaceBuildParameters(params []database.WorkspaceBuildParameter) []codersdk.WorkspaceBuildParameter {
|
|
return slice.List(params, WorkspaceBuildParameter)
|
|
}
|
|
|
|
func TemplateVersionParameters(params []database.TemplateVersionParameter) ([]codersdk.TemplateVersionParameter, error) {
|
|
out := make([]codersdk.TemplateVersionParameter, 0, len(params))
|
|
for _, p := range params {
|
|
np, err := TemplateVersionParameter(p)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("convert template version parameter %q: %w", p.Name, err)
|
|
}
|
|
out = append(out, np)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func TemplateVersionParameterFromPreview(param previewtypes.Parameter) (codersdk.TemplateVersionParameter, error) {
|
|
descriptionPlaintext, err := render.PlaintextFromMarkdown(param.Description)
|
|
if err != nil {
|
|
return codersdk.TemplateVersionParameter{}, err
|
|
}
|
|
|
|
sdkParam := codersdk.TemplateVersionParameter{
|
|
Name: param.Name,
|
|
DisplayName: param.DisplayName,
|
|
Description: param.Description,
|
|
DescriptionPlaintext: descriptionPlaintext,
|
|
Type: string(param.Type),
|
|
FormType: string(param.FormType),
|
|
Mutable: param.Mutable,
|
|
DefaultValue: param.DefaultValue.AsString(),
|
|
Icon: param.Icon,
|
|
Required: param.Required,
|
|
Ephemeral: param.Ephemeral,
|
|
Options: slice.List(param.Options, TemplateVersionParameterOptionFromPreview),
|
|
// Validation set after
|
|
}
|
|
if len(param.Validations) > 0 {
|
|
validation := param.Validations[0]
|
|
sdkParam.ValidationError = validation.Error
|
|
if validation.Monotonic != nil {
|
|
sdkParam.ValidationMonotonic = codersdk.ValidationMonotonicOrder(*validation.Monotonic)
|
|
}
|
|
if validation.Regex != nil {
|
|
sdkParam.ValidationRegex = *validation.Regex
|
|
}
|
|
if validation.Min != nil {
|
|
//nolint:gosec // No other choice
|
|
sdkParam.ValidationMin = ptr.Ref(int32(*validation.Min))
|
|
}
|
|
if validation.Max != nil {
|
|
//nolint:gosec // No other choice
|
|
sdkParam.ValidationMax = ptr.Ref(int32(*validation.Max))
|
|
}
|
|
}
|
|
|
|
return sdkParam, nil
|
|
}
|
|
|
|
func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk.TemplateVersionParameter, error) {
|
|
options, err := templateVersionParameterOptions(param.Options)
|
|
if err != nil {
|
|
return codersdk.TemplateVersionParameter{}, err
|
|
}
|
|
|
|
descriptionPlaintext, err := render.PlaintextFromMarkdown(param.Description)
|
|
if err != nil {
|
|
return codersdk.TemplateVersionParameter{}, err
|
|
}
|
|
|
|
var validationMin *int32
|
|
if param.ValidationMin.Valid {
|
|
validationMin = ¶m.ValidationMin.Int32
|
|
}
|
|
|
|
var validationMax *int32
|
|
if param.ValidationMax.Valid {
|
|
validationMax = ¶m.ValidationMax.Int32
|
|
}
|
|
|
|
return codersdk.TemplateVersionParameter{
|
|
Name: param.Name,
|
|
DisplayName: param.DisplayName,
|
|
Description: param.Description,
|
|
DescriptionPlaintext: descriptionPlaintext,
|
|
Type: param.Type,
|
|
FormType: string(param.FormType),
|
|
Mutable: param.Mutable,
|
|
DefaultValue: param.DefaultValue,
|
|
Icon: param.Icon,
|
|
Options: options,
|
|
ValidationRegex: param.ValidationRegex,
|
|
ValidationMin: validationMin,
|
|
ValidationMax: validationMax,
|
|
ValidationError: param.ValidationError,
|
|
ValidationMonotonic: codersdk.ValidationMonotonicOrder(param.ValidationMonotonic),
|
|
Required: param.Required,
|
|
Ephemeral: param.Ephemeral,
|
|
}, nil
|
|
}
|
|
|
|
func MinimalUser(user database.User) codersdk.MinimalUser {
|
|
return codersdk.MinimalUser{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Name: user.Name,
|
|
AvatarURL: user.AvatarURL,
|
|
}
|
|
}
|
|
|
|
func MinimalUserFromVisibleUser(user database.VisibleUser) codersdk.MinimalUser {
|
|
return codersdk.MinimalUser{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Name: user.Name,
|
|
AvatarURL: user.AvatarURL,
|
|
}
|
|
}
|
|
|
|
func ReducedUser(user database.User) codersdk.ReducedUser {
|
|
return codersdk.ReducedUser{
|
|
MinimalUser: MinimalUser(user),
|
|
Email: user.Email,
|
|
CreatedAt: user.CreatedAt,
|
|
UpdatedAt: user.UpdatedAt,
|
|
LastSeenAt: user.LastSeenAt,
|
|
Status: codersdk.UserStatus(user.Status),
|
|
LoginType: codersdk.LoginType(user.LoginType),
|
|
IsServiceAccount: user.IsServiceAccount,
|
|
}
|
|
}
|
|
|
|
func UserFromGroupMember(member database.GroupMember) database.User {
|
|
return database.User{
|
|
ID: member.UserID,
|
|
Email: member.UserEmail,
|
|
Username: member.UserUsername,
|
|
HashedPassword: member.UserHashedPassword,
|
|
CreatedAt: member.UserCreatedAt,
|
|
UpdatedAt: member.UserUpdatedAt,
|
|
Status: member.UserStatus,
|
|
RBACRoles: member.UserRbacRoles,
|
|
LoginType: member.UserLoginType,
|
|
AvatarURL: member.UserAvatarUrl,
|
|
Deleted: member.UserDeleted,
|
|
LastSeenAt: member.UserLastSeenAt,
|
|
QuietHoursSchedule: member.UserQuietHoursSchedule,
|
|
Name: member.UserName,
|
|
GithubComUserID: member.UserGithubComUserID,
|
|
IsServiceAccount: member.UserIsServiceAccount,
|
|
}
|
|
}
|
|
|
|
func ReducedUserFromGroupMember(member database.GroupMember) codersdk.ReducedUser {
|
|
return ReducedUser(UserFromGroupMember(member))
|
|
}
|
|
|
|
func ReducedUsersFromGroupMembers(members []database.GroupMember) []codersdk.ReducedUser {
|
|
return slice.List(members, ReducedUserFromGroupMember)
|
|
}
|
|
|
|
func UserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow) database.User {
|
|
return database.User{
|
|
ID: member.UserID,
|
|
Email: member.UserEmail,
|
|
Username: member.UserUsername,
|
|
HashedPassword: member.UserHashedPassword,
|
|
CreatedAt: member.UserCreatedAt,
|
|
UpdatedAt: member.UserUpdatedAt,
|
|
Status: member.UserStatus,
|
|
RBACRoles: member.UserRbacRoles,
|
|
LoginType: member.UserLoginType,
|
|
AvatarURL: member.UserAvatarUrl,
|
|
Deleted: member.UserDeleted,
|
|
LastSeenAt: member.UserLastSeenAt,
|
|
QuietHoursSchedule: member.UserQuietHoursSchedule,
|
|
Name: member.UserName,
|
|
GithubComUserID: member.UserGithubComUserID,
|
|
IsServiceAccount: member.UserIsServiceAccount,
|
|
}
|
|
}
|
|
|
|
func ReducedUserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow) codersdk.ReducedUser {
|
|
return ReducedUser(UserFromGroupMemberRow(member))
|
|
}
|
|
|
|
func ReducedUsersFromGroupMemberRows(members []database.GetGroupMembersByGroupIDPaginatedRow) []codersdk.ReducedUser {
|
|
return slice.List(members, ReducedUserFromGroupMemberRow)
|
|
}
|
|
|
|
func ReducedUsers(users []database.User) []codersdk.ReducedUser {
|
|
return slice.List(users, ReducedUser)
|
|
}
|
|
|
|
func User(user database.User, organizationIDs []uuid.UUID) codersdk.User {
|
|
convertedUser := codersdk.User{
|
|
ReducedUser: ReducedUser(user),
|
|
OrganizationIDs: organizationIDs,
|
|
Roles: SlimRolesFromNames(user.RBACRoles),
|
|
}
|
|
|
|
return convertedUser
|
|
}
|
|
|
|
func Users(users []database.User, organizationIDs map[uuid.UUID][]uuid.UUID) []codersdk.User {
|
|
return slice.List(users, func(user database.User) codersdk.User {
|
|
return User(user, organizationIDs[user.ID])
|
|
})
|
|
}
|
|
|
|
func Group(row database.GetGroupsRow, members []database.GroupMember, totalMemberCount int) codersdk.Group {
|
|
return codersdk.Group{
|
|
ID: row.Group.ID,
|
|
Name: row.Group.Name,
|
|
DisplayName: row.Group.DisplayName,
|
|
OrganizationID: row.Group.OrganizationID,
|
|
AvatarURL: row.Group.AvatarURL,
|
|
Members: ReducedUsersFromGroupMembers(members),
|
|
TotalMemberCount: totalMemberCount,
|
|
QuotaAllowance: int(row.Group.QuotaAllowance),
|
|
Source: codersdk.GroupSource(row.Group.Source),
|
|
OrganizationName: row.OrganizationName,
|
|
OrganizationDisplayName: row.OrganizationDisplayName,
|
|
}
|
|
}
|
|
|
|
func TemplateInsightsParameters(parameterRows []database.GetTemplateParameterInsightsRow) ([]codersdk.TemplateParameterUsage, error) {
|
|
// Use a stable sort, similarly to how we would sort in the query, note that
|
|
// we don't sort in the query because order varies depending on the table
|
|
// collation.
|
|
//
|
|
// ORDER BY utp.name, utp.type, utp.display_name, utp.description, utp.options, wbp.value
|
|
slices.SortFunc(parameterRows, func(a, b database.GetTemplateParameterInsightsRow) int {
|
|
if a.Name != b.Name {
|
|
return strings.Compare(a.Name, b.Name)
|
|
}
|
|
if a.Type != b.Type {
|
|
return strings.Compare(a.Type, b.Type)
|
|
}
|
|
if a.DisplayName != b.DisplayName {
|
|
return strings.Compare(a.DisplayName, b.DisplayName)
|
|
}
|
|
if a.Description != b.Description {
|
|
return strings.Compare(a.Description, b.Description)
|
|
}
|
|
if string(a.Options) != string(b.Options) {
|
|
return strings.Compare(string(a.Options), string(b.Options))
|
|
}
|
|
return strings.Compare(a.Value, b.Value)
|
|
})
|
|
|
|
parametersUsage := []codersdk.TemplateParameterUsage{}
|
|
indexByNum := make(map[int64]int)
|
|
for _, param := range parameterRows {
|
|
if _, ok := indexByNum[param.Num]; !ok {
|
|
var opts []codersdk.TemplateVersionParameterOption
|
|
err := json.Unmarshal(param.Options, &opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
plaintextDescription, err := render.PlaintextFromMarkdown(param.Description)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parametersUsage = append(parametersUsage, codersdk.TemplateParameterUsage{
|
|
TemplateIDs: param.TemplateIDs,
|
|
Name: param.Name,
|
|
Type: param.Type,
|
|
DisplayName: param.DisplayName,
|
|
Description: plaintextDescription,
|
|
Options: opts,
|
|
})
|
|
indexByNum[param.Num] = len(parametersUsage) - 1
|
|
}
|
|
|
|
i := indexByNum[param.Num]
|
|
parametersUsage[i].Values = append(parametersUsage[i].Values, codersdk.TemplateParameterValue{
|
|
Value: param.Value,
|
|
Count: param.Count,
|
|
})
|
|
}
|
|
|
|
return parametersUsage, nil
|
|
}
|
|
|
|
func templateVersionParameterOptions(rawOptions json.RawMessage) ([]codersdk.TemplateVersionParameterOption, error) {
|
|
var protoOptions []*proto.RichParameterOption
|
|
err := json.Unmarshal(rawOptions, &protoOptions)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
options := make([]codersdk.TemplateVersionParameterOption, 0)
|
|
for _, option := range protoOptions {
|
|
options = append(options, codersdk.TemplateVersionParameterOption{
|
|
Name: option.Name,
|
|
Description: option.Description,
|
|
Value: option.Value,
|
|
Icon: option.Icon,
|
|
})
|
|
}
|
|
return options, nil
|
|
}
|
|
|
|
func TemplateVersionParameterOptionFromPreview(option *previewtypes.ParameterOption) codersdk.TemplateVersionParameterOption {
|
|
return codersdk.TemplateVersionParameterOption{
|
|
Name: option.Name,
|
|
Description: option.Description,
|
|
Value: option.Value.AsString(),
|
|
Icon: option.Icon,
|
|
}
|
|
}
|
|
|
|
func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
|
|
return codersdk.OAuth2ProviderApp{
|
|
ID: dbApp.ID,
|
|
Name: dbApp.Name,
|
|
CallbackURL: dbApp.CallbackURL,
|
|
Icon: dbApp.Icon,
|
|
Endpoints: codersdk.OAuth2AppEndpoints{
|
|
Authorization: accessURL.ResolveReference(&url.URL{
|
|
Path: "/oauth2/authorize",
|
|
}).String(),
|
|
Token: accessURL.ResolveReference(&url.URL{
|
|
Path: "/oauth2/tokens",
|
|
}).String(),
|
|
// We do not currently support DeviceAuth.
|
|
DeviceAuth: "",
|
|
TokenRevoke: accessURL.ResolveReference(&url.URL{
|
|
Path: "/oauth2/revoke",
|
|
}).String(),
|
|
},
|
|
}
|
|
}
|
|
|
|
func OAuth2ProviderApps(accessURL *url.URL, dbApps []database.OAuth2ProviderApp) []codersdk.OAuth2ProviderApp {
|
|
return slice.List(dbApps, func(dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
|
|
return OAuth2ProviderApp(accessURL, dbApp)
|
|
})
|
|
}
|
|
|
|
func convertDisplayApps(apps []database.DisplayApp) []codersdk.DisplayApp {
|
|
dapps := make([]codersdk.DisplayApp, 0, len(apps))
|
|
for _, app := range apps {
|
|
switch codersdk.DisplayApp(app) {
|
|
case codersdk.DisplayAppVSCodeDesktop, codersdk.DisplayAppVSCodeInsiders, codersdk.DisplayAppPortForward, codersdk.DisplayAppWebTerminal, codersdk.DisplayAppSSH:
|
|
dapps = append(dapps, codersdk.DisplayApp(app))
|
|
}
|
|
}
|
|
|
|
return dapps
|
|
}
|
|
|
|
func WorkspaceAgentEnvironment(workspaceAgent database.WorkspaceAgent) (map[string]string, error) {
|
|
var envs map[string]string
|
|
if workspaceAgent.EnvironmentVariables.Valid {
|
|
err := json.Unmarshal(workspaceAgent.EnvironmentVariables.RawMessage, &envs)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("unmarshal environment variables: %w", err)
|
|
}
|
|
}
|
|
|
|
return envs, nil
|
|
}
|
|
|
|
func WorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator tailnet.Coordinator,
|
|
dbAgent database.WorkspaceAgent, apps []codersdk.WorkspaceApp, scripts []codersdk.WorkspaceAgentScript, logSources []codersdk.WorkspaceAgentLogSource,
|
|
agentInactiveDisconnectTimeout time.Duration, agentFallbackTroubleshootingURL string,
|
|
) (codersdk.WorkspaceAgent, error) {
|
|
envs, err := WorkspaceAgentEnvironment(dbAgent)
|
|
if err != nil {
|
|
return codersdk.WorkspaceAgent{}, err
|
|
}
|
|
troubleshootingURL := agentFallbackTroubleshootingURL
|
|
if dbAgent.TroubleshootingURL != "" {
|
|
troubleshootingURL = dbAgent.TroubleshootingURL
|
|
}
|
|
subsystems := make([]codersdk.AgentSubsystem, len(dbAgent.Subsystems))
|
|
for i, subsystem := range dbAgent.Subsystems {
|
|
subsystems[i] = codersdk.AgentSubsystem(subsystem)
|
|
}
|
|
|
|
legacyStartupScriptBehavior := codersdk.WorkspaceAgentStartupScriptBehaviorNonBlocking
|
|
for _, script := range scripts {
|
|
if !script.RunOnStart {
|
|
continue
|
|
}
|
|
if !script.StartBlocksLogin {
|
|
continue
|
|
}
|
|
legacyStartupScriptBehavior = codersdk.WorkspaceAgentStartupScriptBehaviorBlocking
|
|
}
|
|
|
|
workspaceAgent := codersdk.WorkspaceAgent{
|
|
ID: dbAgent.ID,
|
|
ParentID: dbAgent.ParentID,
|
|
CreatedAt: dbAgent.CreatedAt,
|
|
UpdatedAt: dbAgent.UpdatedAt,
|
|
ResourceID: dbAgent.ResourceID,
|
|
InstanceID: dbAgent.AuthInstanceID.String,
|
|
Name: dbAgent.Name,
|
|
Architecture: dbAgent.Architecture,
|
|
OperatingSystem: dbAgent.OperatingSystem,
|
|
Scripts: scripts,
|
|
StartupScriptBehavior: legacyStartupScriptBehavior,
|
|
LogsLength: dbAgent.LogsLength,
|
|
LogsOverflowed: dbAgent.LogsOverflowed,
|
|
LogSources: logSources,
|
|
Version: dbAgent.Version,
|
|
APIVersion: dbAgent.APIVersion,
|
|
EnvironmentVariables: envs,
|
|
Directory: dbAgent.Directory,
|
|
ExpandedDirectory: dbAgent.ExpandedDirectory,
|
|
Apps: apps,
|
|
ConnectionTimeoutSeconds: dbAgent.ConnectionTimeoutSeconds,
|
|
TroubleshootingURL: troubleshootingURL,
|
|
LifecycleState: codersdk.WorkspaceAgentLifecycle(dbAgent.LifecycleState),
|
|
Subsystems: subsystems,
|
|
DisplayApps: convertDisplayApps(dbAgent.DisplayApps),
|
|
}
|
|
node := coordinator.Node(dbAgent.ID)
|
|
if node != nil {
|
|
workspaceAgent.DERPLatency = map[string]codersdk.DERPRegion{}
|
|
for rawRegion, latency := range node.DERPLatency {
|
|
regionParts := strings.SplitN(rawRegion, "-", 2)
|
|
regionID, err := strconv.Atoi(regionParts[0])
|
|
if err != nil {
|
|
return codersdk.WorkspaceAgent{}, xerrors.Errorf("convert derp region id %q: %w", rawRegion, err)
|
|
}
|
|
region, found := derpMap.Regions[regionID]
|
|
if !found {
|
|
// It's possible that a workspace agent is using an old DERPMap
|
|
// and reports regions that do not exist. If that's the case,
|
|
// report the region as unknown!
|
|
region = &tailcfg.DERPRegion{
|
|
RegionID: regionID,
|
|
RegionName: fmt.Sprintf("Unnamed %d", regionID),
|
|
}
|
|
}
|
|
workspaceAgent.DERPLatency[region.RegionName] = codersdk.DERPRegion{
|
|
Preferred: node.PreferredDERP == regionID,
|
|
LatencyMilliseconds: latency * 1000,
|
|
}
|
|
}
|
|
}
|
|
|
|
status := dbAgent.Status(dbtime.Now(), agentInactiveDisconnectTimeout)
|
|
workspaceAgent.Status = codersdk.WorkspaceAgentStatus(status.Status)
|
|
workspaceAgent.FirstConnectedAt = status.FirstConnectedAt
|
|
workspaceAgent.LastConnectedAt = status.LastConnectedAt
|
|
workspaceAgent.DisconnectedAt = status.DisconnectedAt
|
|
|
|
if dbAgent.StartedAt.Valid {
|
|
workspaceAgent.StartedAt = &dbAgent.StartedAt.Time
|
|
}
|
|
if dbAgent.ReadyAt.Valid {
|
|
workspaceAgent.ReadyAt = &dbAgent.ReadyAt.Time
|
|
}
|
|
|
|
switch {
|
|
case workspaceAgent.Status != codersdk.WorkspaceAgentConnected && workspaceAgent.LifecycleState == codersdk.WorkspaceAgentLifecycleOff:
|
|
workspaceAgent.Health.Reason = "agent is not running"
|
|
case workspaceAgent.Status == codersdk.WorkspaceAgentConnecting:
|
|
// Note: the case above catches connecting+off as "not running".
|
|
// This case handles connecting agents with a non-off lifecycle
|
|
// (e.g. "created" or "starting"), where the agent binary has
|
|
// not yet established a connection to coderd.
|
|
workspaceAgent.Health.Reason = "agent has not yet connected"
|
|
case workspaceAgent.Status == codersdk.WorkspaceAgentTimeout:
|
|
workspaceAgent.Health.Reason = "agent is taking too long to connect"
|
|
case workspaceAgent.Status == codersdk.WorkspaceAgentDisconnected:
|
|
workspaceAgent.Health.Reason = "agent has lost connection"
|
|
// Note: We could also handle codersdk.WorkspaceAgentLifecycleStartTimeout
|
|
// here, but it's more of a soft issue, so we don't want to mark the agent
|
|
// as unhealthy.
|
|
case workspaceAgent.LifecycleState == codersdk.WorkspaceAgentLifecycleStartError:
|
|
workspaceAgent.Health.Reason = "agent startup script exited with an error"
|
|
case workspaceAgent.LifecycleState.ShuttingDown():
|
|
workspaceAgent.Health.Reason = "agent is shutting down"
|
|
default:
|
|
workspaceAgent.Health.Healthy = true
|
|
}
|
|
|
|
return workspaceAgent, nil
|
|
}
|
|
|
|
func AppSubdomain(dbApp database.WorkspaceApp, agentName, workspaceName, ownerName string) string {
|
|
if !dbApp.Subdomain || agentName == "" || ownerName == "" || workspaceName == "" {
|
|
return ""
|
|
}
|
|
|
|
appSlug := dbApp.Slug
|
|
if appSlug == "" {
|
|
appSlug = dbApp.DisplayName
|
|
}
|
|
|
|
// Agent name is optional when app slug is present
|
|
normalizedAgentName := agentName
|
|
if !appurl.PortRegex.MatchString(appSlug) {
|
|
normalizedAgentName = ""
|
|
}
|
|
|
|
return appurl.ApplicationURL{
|
|
// We never generate URLs with a prefix. We only allow prefixes when
|
|
// parsing URLs from the hostname. Users that want this feature can
|
|
// write out their own URLs.
|
|
Prefix: "",
|
|
AppSlugOrPort: appSlug,
|
|
AgentName: normalizedAgentName,
|
|
WorkspaceName: workspaceName,
|
|
Username: ownerName,
|
|
}.String()
|
|
}
|
|
|
|
func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus, agent database.WorkspaceAgent, ownerName string, workspace database.WorkspaceTable) []codersdk.WorkspaceApp {
|
|
sort.Slice(dbApps, func(i, j int) bool {
|
|
if dbApps[i].DisplayOrder != dbApps[j].DisplayOrder {
|
|
return dbApps[i].DisplayOrder < dbApps[j].DisplayOrder
|
|
}
|
|
if dbApps[i].DisplayName != dbApps[j].DisplayName {
|
|
return dbApps[i].DisplayName < dbApps[j].DisplayName
|
|
}
|
|
return dbApps[i].Slug < dbApps[j].Slug
|
|
})
|
|
|
|
statusesByAppID := map[uuid.UUID][]database.WorkspaceAppStatus{}
|
|
for _, status := range statuses {
|
|
statusesByAppID[status.AppID] = append(statusesByAppID[status.AppID], status)
|
|
}
|
|
|
|
apps := make([]codersdk.WorkspaceApp, 0)
|
|
for _, dbApp := range dbApps {
|
|
statuses := statusesByAppID[dbApp.ID]
|
|
apps = append(apps, codersdk.WorkspaceApp{
|
|
ID: dbApp.ID,
|
|
URL: dbApp.Url.String,
|
|
External: dbApp.External,
|
|
Slug: dbApp.Slug,
|
|
DisplayName: dbApp.DisplayName,
|
|
Command: dbApp.Command.String,
|
|
Icon: dbApp.Icon,
|
|
Subdomain: dbApp.Subdomain,
|
|
SubdomainName: AppSubdomain(dbApp, agent.Name, workspace.Name, ownerName),
|
|
SharingLevel: codersdk.WorkspaceAppSharingLevel(dbApp.SharingLevel),
|
|
Healthcheck: codersdk.Healthcheck{
|
|
URL: dbApp.HealthcheckUrl,
|
|
Interval: dbApp.HealthcheckInterval,
|
|
Threshold: dbApp.HealthcheckThreshold,
|
|
},
|
|
Health: codersdk.WorkspaceAppHealth(dbApp.Health),
|
|
Group: dbApp.DisplayGroup.String,
|
|
Hidden: dbApp.Hidden,
|
|
OpenIn: codersdk.WorkspaceAppOpenIn(dbApp.OpenIn),
|
|
Tooltip: dbApp.Tooltip,
|
|
Statuses: WorkspaceAppStatuses(statuses),
|
|
})
|
|
}
|
|
return apps
|
|
}
|
|
|
|
func WorkspaceAppStatuses(statuses []database.WorkspaceAppStatus) []codersdk.WorkspaceAppStatus {
|
|
return slice.List(statuses, WorkspaceAppStatus)
|
|
}
|
|
|
|
func WorkspaceAppStatus(status database.WorkspaceAppStatus) codersdk.WorkspaceAppStatus {
|
|
return codersdk.WorkspaceAppStatus{
|
|
ID: status.ID,
|
|
CreatedAt: status.CreatedAt,
|
|
WorkspaceID: status.WorkspaceID,
|
|
AgentID: status.AgentID,
|
|
AppID: status.AppID,
|
|
URI: status.Uri.String,
|
|
Message: status.Message,
|
|
State: codersdk.WorkspaceAppStatusState(status.State),
|
|
}
|
|
}
|
|
|
|
func ProvisionerJobLog(log database.ProvisionerJobLog) codersdk.ProvisionerJobLog {
|
|
return codersdk.ProvisionerJobLog{
|
|
ID: log.ID,
|
|
CreatedAt: log.CreatedAt,
|
|
Source: codersdk.LogSource(log.Source),
|
|
Level: codersdk.LogLevel(log.Level),
|
|
Stage: log.Stage,
|
|
Output: log.Output,
|
|
}
|
|
}
|
|
|
|
func WorkspaceAgentLog(log database.WorkspaceAgentLog) codersdk.WorkspaceAgentLog {
|
|
return codersdk.WorkspaceAgentLog{
|
|
ID: log.ID,
|
|
CreatedAt: log.CreatedAt,
|
|
Output: log.Output,
|
|
Level: codersdk.LogLevel(log.Level),
|
|
SourceID: log.LogSourceID,
|
|
}
|
|
}
|
|
|
|
func WorkspaceAgentScript(dbScript database.GetWorkspaceAgentScriptsByAgentIDsRow) codersdk.WorkspaceAgentScript {
|
|
script := codersdk.WorkspaceAgentScript{
|
|
ID: dbScript.ID,
|
|
LogPath: dbScript.LogPath,
|
|
LogSourceID: dbScript.LogSourceID,
|
|
Script: dbScript.Script,
|
|
Cron: dbScript.Cron,
|
|
RunOnStart: dbScript.RunOnStart,
|
|
RunOnStop: dbScript.RunOnStop,
|
|
StartBlocksLogin: dbScript.StartBlocksLogin,
|
|
Timeout: time.Duration(dbScript.TimeoutSeconds) * time.Second,
|
|
DisplayName: dbScript.DisplayName,
|
|
ExitCode: nullInt32Ptr(dbScript.ExitCode),
|
|
}
|
|
if dbScript.Status.Valid {
|
|
status := codersdk.WorkspaceAgentScriptStatus(dbScript.Status.WorkspaceAgentScriptTimingStatus)
|
|
script.Status = &status
|
|
}
|
|
return script
|
|
}
|
|
|
|
func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.ProvisionerDaemon {
|
|
result := codersdk.ProvisionerDaemon{
|
|
ID: dbDaemon.ID,
|
|
OrganizationID: dbDaemon.OrganizationID,
|
|
CreatedAt: dbDaemon.CreatedAt,
|
|
LastSeenAt: codersdk.NullTime{NullTime: dbDaemon.LastSeenAt},
|
|
Name: dbDaemon.Name,
|
|
Tags: dbDaemon.Tags,
|
|
Version: dbDaemon.Version,
|
|
APIVersion: dbDaemon.APIVersion,
|
|
KeyID: dbDaemon.KeyID,
|
|
}
|
|
for _, provisionerType := range dbDaemon.Provisioners {
|
|
result.Provisioners = append(result.Provisioners, codersdk.ProvisionerType(provisionerType))
|
|
}
|
|
return result
|
|
}
|
|
|
|
func RecentProvisionerDaemons(now time.Time, staleInterval time.Duration, daemons []database.ProvisionerDaemon) []codersdk.ProvisionerDaemon {
|
|
results := []codersdk.ProvisionerDaemon{}
|
|
|
|
for _, daemon := range daemons {
|
|
// Daemon never connected, skip.
|
|
if !daemon.LastSeenAt.Valid {
|
|
continue
|
|
}
|
|
// Daemon has gone away, skip.
|
|
if now.Sub(daemon.LastSeenAt.Time) > staleInterval {
|
|
continue
|
|
}
|
|
|
|
results = append(results, ProvisionerDaemon(daemon))
|
|
}
|
|
|
|
// Ensure stable order for display and for tests
|
|
sort.Slice(results, func(i, j int) bool {
|
|
return results[i].Name < results[j].Name
|
|
})
|
|
|
|
return results
|
|
}
|
|
|
|
func SlimRole(role rbac.Role) codersdk.SlimRole {
|
|
orgID := ""
|
|
if role.Identifier.OrganizationID != uuid.Nil {
|
|
orgID = role.Identifier.OrganizationID.String()
|
|
}
|
|
|
|
return codersdk.SlimRole{
|
|
DisplayName: role.DisplayName,
|
|
Name: role.Identifier.Name,
|
|
OrganizationID: orgID,
|
|
}
|
|
}
|
|
|
|
func SlimRolesFromNames(names []string) []codersdk.SlimRole {
|
|
convertedRoles := make([]codersdk.SlimRole, 0, len(names))
|
|
|
|
for _, name := range names {
|
|
convertedRoles = append(convertedRoles, SlimRoleFromName(name))
|
|
}
|
|
|
|
return convertedRoles
|
|
}
|
|
|
|
func SlimRoleFromName(name string) codersdk.SlimRole {
|
|
rbacRole, err := rbac.RoleByName(rbac.RoleIdentifier{Name: name})
|
|
var convertedRole codersdk.SlimRole
|
|
if err == nil {
|
|
convertedRole = SlimRole(rbacRole)
|
|
} else {
|
|
convertedRole = codersdk.SlimRole{Name: name}
|
|
}
|
|
return convertedRole
|
|
}
|
|
|
|
func RBACRole(role rbac.Role) codersdk.Role {
|
|
slim := SlimRole(role)
|
|
|
|
orgPerms := role.ByOrgID[slim.OrganizationID]
|
|
return codersdk.Role{
|
|
Name: slim.Name,
|
|
OrganizationID: slim.OrganizationID,
|
|
DisplayName: slim.DisplayName,
|
|
SitePermissions: slice.List(role.Site, RBACPermission),
|
|
UserPermissions: slice.List(role.User, RBACPermission),
|
|
OrganizationPermissions: slice.List(orgPerms.Org, RBACPermission),
|
|
OrganizationMemberPermissions: slice.List(orgPerms.Member, RBACPermission),
|
|
}
|
|
}
|
|
|
|
func Role(role database.CustomRole) codersdk.Role {
|
|
orgID := ""
|
|
if role.OrganizationID.UUID != uuid.Nil {
|
|
orgID = role.OrganizationID.UUID.String()
|
|
}
|
|
|
|
return codersdk.Role{
|
|
Name: role.Name,
|
|
OrganizationID: orgID,
|
|
DisplayName: role.DisplayName,
|
|
SitePermissions: slice.List(role.SitePermissions, Permission),
|
|
UserPermissions: slice.List(role.UserPermissions, Permission),
|
|
OrganizationPermissions: slice.List(role.OrgPermissions, Permission),
|
|
}
|
|
}
|
|
|
|
func Permission(permission database.CustomRolePermission) codersdk.Permission {
|
|
return codersdk.Permission{
|
|
Negate: permission.Negate,
|
|
ResourceType: codersdk.RBACResource(permission.ResourceType),
|
|
Action: codersdk.RBACAction(permission.Action),
|
|
}
|
|
}
|
|
|
|
func RBACPermission(permission rbac.Permission) codersdk.Permission {
|
|
return codersdk.Permission{
|
|
Negate: permission.Negate,
|
|
ResourceType: codersdk.RBACResource(permission.ResourceType),
|
|
Action: codersdk.RBACAction(permission.Action),
|
|
}
|
|
}
|
|
|
|
func Organization(organization database.Organization) codersdk.Organization {
|
|
return codersdk.Organization{
|
|
MinimalOrganization: codersdk.MinimalOrganization{
|
|
ID: organization.ID,
|
|
Name: organization.Name,
|
|
DisplayName: organization.DisplayName,
|
|
Icon: organization.Icon,
|
|
},
|
|
Description: organization.Description,
|
|
CreatedAt: organization.CreatedAt,
|
|
UpdatedAt: organization.UpdatedAt,
|
|
IsDefault: organization.IsDefault,
|
|
}
|
|
}
|
|
|
|
func CryptoKeys(keys []database.CryptoKey) []codersdk.CryptoKey {
|
|
return slice.List(keys, CryptoKey)
|
|
}
|
|
|
|
func CryptoKey(key database.CryptoKey) codersdk.CryptoKey {
|
|
return codersdk.CryptoKey{
|
|
Feature: codersdk.CryptoKeyFeature(key.Feature),
|
|
Sequence: key.Sequence,
|
|
StartsAt: key.StartsAt,
|
|
DeletesAt: key.DeletesAt.Time,
|
|
Secret: key.Secret.String,
|
|
}
|
|
}
|
|
|
|
func MatchedProvisioners(provisionerDaemons []database.ProvisionerDaemon, now time.Time, staleInterval time.Duration) codersdk.MatchedProvisioners {
|
|
minLastSeenAt := now.Add(-staleInterval)
|
|
mostRecentlySeen := codersdk.NullTime{}
|
|
var matched codersdk.MatchedProvisioners
|
|
for _, provisioner := range provisionerDaemons {
|
|
if !provisioner.LastSeenAt.Valid {
|
|
continue
|
|
}
|
|
matched.Count++
|
|
if provisioner.LastSeenAt.Time.After(minLastSeenAt) {
|
|
matched.Available++
|
|
}
|
|
if provisioner.LastSeenAt.Time.After(mostRecentlySeen.Time) {
|
|
matched.MostRecentlySeen.Valid = true
|
|
matched.MostRecentlySeen.Time = provisioner.LastSeenAt.Time
|
|
}
|
|
}
|
|
return matched
|
|
}
|
|
|
|
func TemplateRoleActions(role codersdk.TemplateRole) []policy.Action {
|
|
switch role {
|
|
case codersdk.TemplateRoleAdmin:
|
|
return []policy.Action{policy.WildcardSymbol}
|
|
case codersdk.TemplateRoleUse:
|
|
return []policy.Action{policy.ActionRead, policy.ActionUse}
|
|
}
|
|
return []policy.Action{}
|
|
}
|
|
|
|
func WorkspaceRoleActions(role codersdk.WorkspaceRole) []policy.Action {
|
|
switch role {
|
|
case codersdk.WorkspaceRoleAdmin:
|
|
return slice.Omit(
|
|
// Small note: This intentionally includes "create" because it's sort of
|
|
// double purposed as "can edit ACL". That's maybe a bit "incorrect", but
|
|
// it's what templates do already and we're copying that implementation.
|
|
rbac.ResourceWorkspace.AvailableActions(),
|
|
// Don't let anyone delete something they can't recreate.
|
|
policy.ActionDelete,
|
|
)
|
|
case codersdk.WorkspaceRoleUse:
|
|
return []policy.Action{
|
|
policy.ActionApplicationConnect,
|
|
policy.ActionRead,
|
|
policy.ActionSSH,
|
|
policy.ActionWorkspaceStart,
|
|
policy.ActionWorkspaceStop,
|
|
}
|
|
}
|
|
return []policy.Action{}
|
|
}
|
|
|
|
func ChatRoleActions(role codersdk.ChatRole) []policy.Action {
|
|
if role == codersdk.ChatRoleRead {
|
|
return []policy.Action{policy.ActionRead}
|
|
}
|
|
return []policy.Action{}
|
|
}
|
|
|
|
func ConnectionLogConnectionTypeFromAgentProtoConnectionType(typ agentproto.Connection_Type) (database.ConnectionType, error) {
|
|
switch typ {
|
|
case agentproto.Connection_SSH:
|
|
return database.ConnectionTypeSsh, nil
|
|
case agentproto.Connection_JETBRAINS:
|
|
return database.ConnectionTypeJetbrains, nil
|
|
case agentproto.Connection_VSCODE:
|
|
return database.ConnectionTypeVscode, nil
|
|
case agentproto.Connection_RECONNECTING_PTY:
|
|
return database.ConnectionTypeReconnectingPty, nil
|
|
default:
|
|
// Also Connection_TYPE_UNSPECIFIED, no mapping.
|
|
return "", xerrors.Errorf("unknown agent connection type %q", typ)
|
|
}
|
|
}
|
|
|
|
func ConnectionLogStatusFromAgentProtoConnectionAction(action agentproto.Connection_Action) (database.ConnectionStatus, error) {
|
|
switch action {
|
|
case agentproto.Connection_CONNECT:
|
|
return database.ConnectionStatusConnected, nil
|
|
case agentproto.Connection_DISCONNECT:
|
|
return database.ConnectionStatusDisconnected, nil
|
|
default:
|
|
// Also Connection_ACTION_UNSPECIFIED, no mapping.
|
|
return "", xerrors.Errorf("unknown agent connection action %q", action)
|
|
}
|
|
}
|
|
|
|
func PreviewParameter(param previewtypes.Parameter) codersdk.PreviewParameter {
|
|
return codersdk.PreviewParameter{
|
|
PreviewParameterData: codersdk.PreviewParameterData{
|
|
Name: param.Name,
|
|
DisplayName: param.DisplayName,
|
|
Description: param.Description,
|
|
Type: codersdk.OptionType(param.Type),
|
|
FormType: codersdk.ParameterFormType(param.FormType),
|
|
Styling: codersdk.PreviewParameterStyling{
|
|
Placeholder: param.Styling.Placeholder,
|
|
Disabled: param.Styling.Disabled,
|
|
Label: param.Styling.Label,
|
|
MaskInput: param.Styling.MaskInput,
|
|
},
|
|
Mutable: param.Mutable,
|
|
DefaultValue: PreviewHCLString(param.DefaultValue),
|
|
Icon: param.Icon,
|
|
Options: slice.List(param.Options, PreviewParameterOption),
|
|
Validations: slice.List(param.Validations, PreviewParameterValidation),
|
|
Required: param.Required,
|
|
Order: param.Order,
|
|
Ephemeral: param.Ephemeral,
|
|
},
|
|
Value: PreviewHCLString(param.Value),
|
|
Diagnostics: PreviewDiagnostics(param.Diagnostics),
|
|
}
|
|
}
|
|
|
|
func HCLDiagnostics(d hcl.Diagnostics) []codersdk.FriendlyDiagnostic {
|
|
return PreviewDiagnostics(previewtypes.Diagnostics(d))
|
|
}
|
|
|
|
func PreviewDiagnostics(d previewtypes.Diagnostics) []codersdk.FriendlyDiagnostic {
|
|
f := d.FriendlyDiagnostics()
|
|
return slice.List(f, func(f previewtypes.FriendlyDiagnostic) codersdk.FriendlyDiagnostic {
|
|
return codersdk.FriendlyDiagnostic{
|
|
Severity: codersdk.DiagnosticSeverityString(f.Severity),
|
|
Summary: f.Summary,
|
|
Detail: f.Detail,
|
|
Extra: codersdk.DiagnosticExtra{
|
|
Code: f.Extra.Code,
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
func PreviewHCLString(h previewtypes.HCLString) codersdk.NullHCLString {
|
|
n := h.NullHCLString()
|
|
return codersdk.NullHCLString{
|
|
Value: n.Value,
|
|
Valid: n.Valid,
|
|
}
|
|
}
|
|
|
|
func PreviewParameterOption(o *previewtypes.ParameterOption) codersdk.PreviewParameterOption {
|
|
if o == nil {
|
|
// This should never be sent
|
|
return codersdk.PreviewParameterOption{}
|
|
}
|
|
return codersdk.PreviewParameterOption{
|
|
Name: o.Name,
|
|
Description: o.Description,
|
|
Value: PreviewHCLString(o.Value),
|
|
Icon: o.Icon,
|
|
}
|
|
}
|
|
|
|
func PreviewParameterValidation(v *previewtypes.ParameterValidation) codersdk.PreviewParameterValidation {
|
|
if v == nil {
|
|
// This should never be sent
|
|
return codersdk.PreviewParameterValidation{}
|
|
}
|
|
return codersdk.PreviewParameterValidation{
|
|
Error: v.Error,
|
|
Regex: v.Regex,
|
|
Min: v.Min,
|
|
Max: v.Max,
|
|
Monotonic: v.Monotonic,
|
|
}
|
|
}
|
|
|
|
func AIBridgeInterception(interception database.AIBridgeInterception, initiator database.VisibleUser, tokenUsages []database.AIBridgeTokenUsage, userPrompts []database.AIBridgeUserPrompt, toolUsages []database.AIBridgeToolUsage) codersdk.AIBridgeInterception {
|
|
sdkTokenUsages := slice.List(tokenUsages, AIBridgeTokenUsage)
|
|
sort.Slice(sdkTokenUsages, func(i, j int) bool {
|
|
// created_at ASC
|
|
return sdkTokenUsages[i].CreatedAt.Before(sdkTokenUsages[j].CreatedAt)
|
|
})
|
|
sdkUserPrompts := slice.List(userPrompts, AIBridgeUserPrompt)
|
|
sort.Slice(sdkUserPrompts, func(i, j int) bool {
|
|
// created_at ASC
|
|
return sdkUserPrompts[i].CreatedAt.Before(sdkUserPrompts[j].CreatedAt)
|
|
})
|
|
sdkToolUsages := slice.List(toolUsages, AIBridgeToolUsage)
|
|
sort.Slice(sdkToolUsages, func(i, j int) bool {
|
|
// created_at ASC
|
|
return sdkToolUsages[i].CreatedAt.Before(sdkToolUsages[j].CreatedAt)
|
|
})
|
|
intc := codersdk.AIBridgeInterception{
|
|
ID: interception.ID,
|
|
Initiator: MinimalUserFromVisibleUser(initiator),
|
|
Provider: interception.Provider,
|
|
ProviderName: interception.ProviderName,
|
|
Model: interception.Model,
|
|
Metadata: jsonOrEmptyMap(interception.Metadata),
|
|
StartedAt: interception.StartedAt,
|
|
TokenUsages: sdkTokenUsages,
|
|
UserPrompts: sdkUserPrompts,
|
|
ToolUsages: sdkToolUsages,
|
|
}
|
|
if interception.APIKeyID.Valid {
|
|
intc.APIKeyID = &interception.APIKeyID.String
|
|
}
|
|
if interception.EndedAt.Valid {
|
|
intc.EndedAt = &interception.EndedAt.Time
|
|
}
|
|
if interception.Client.Valid {
|
|
intc.Client = &interception.Client.String
|
|
}
|
|
return intc
|
|
}
|
|
|
|
func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSession {
|
|
session := codersdk.AIBridgeSession{
|
|
ID: row.SessionID,
|
|
Initiator: MinimalUserFromVisibleUser(database.VisibleUser{
|
|
ID: row.UserID,
|
|
Username: row.UserUsername,
|
|
Name: row.UserName,
|
|
AvatarURL: row.UserAvatarUrl,
|
|
}),
|
|
Providers: row.Providers,
|
|
Models: row.Models,
|
|
Metadata: jsonOrEmptyMap(pqtype.NullRawMessage{RawMessage: row.Metadata, Valid: len(row.Metadata) > 0}),
|
|
StartedAt: row.StartedAt,
|
|
Threads: row.Threads,
|
|
LastActiveAt: row.LastActiveAt,
|
|
TokenUsageSummary: codersdk.AIBridgeSessionTokenUsageSummary{
|
|
InputTokens: row.InputTokens,
|
|
OutputTokens: row.OutputTokens,
|
|
CacheReadInputTokens: row.CacheReadInputTokens,
|
|
CacheWriteInputTokens: row.CacheWriteInputTokens,
|
|
},
|
|
}
|
|
// Ensure non-nil slices for JSON serialization.
|
|
if session.Providers == nil {
|
|
session.Providers = []string{}
|
|
}
|
|
if session.Models == nil {
|
|
session.Models = []string{}
|
|
}
|
|
if row.Client != "" {
|
|
session.Client = &row.Client
|
|
}
|
|
if !row.EndedAt.IsZero() {
|
|
session.EndedAt = &row.EndedAt
|
|
}
|
|
if row.LastPrompt != "" {
|
|
session.LastPrompt = &row.LastPrompt
|
|
}
|
|
return session
|
|
}
|
|
|
|
func AIBridgeTokenUsage(usage database.AIBridgeTokenUsage) codersdk.AIBridgeTokenUsage {
|
|
return codersdk.AIBridgeTokenUsage{
|
|
ID: usage.ID,
|
|
InterceptionID: usage.InterceptionID,
|
|
ProviderResponseID: usage.ProviderResponseID,
|
|
InputTokens: usage.InputTokens,
|
|
OutputTokens: usage.OutputTokens,
|
|
CacheReadInputTokens: usage.CacheReadInputTokens,
|
|
CacheWriteInputTokens: usage.CacheWriteInputTokens,
|
|
Metadata: jsonOrEmptyMap(usage.Metadata),
|
|
CreatedAt: usage.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func AIBridgeUserPrompt(prompt database.AIBridgeUserPrompt) codersdk.AIBridgeUserPrompt {
|
|
return codersdk.AIBridgeUserPrompt{
|
|
ID: prompt.ID,
|
|
InterceptionID: prompt.InterceptionID,
|
|
ProviderResponseID: prompt.ProviderResponseID,
|
|
Prompt: prompt.Prompt,
|
|
Metadata: jsonOrEmptyMap(prompt.Metadata),
|
|
CreatedAt: prompt.CreatedAt,
|
|
}
|
|
}
|
|
|
|
func AIBridgeToolUsage(usage database.AIBridgeToolUsage) codersdk.AIBridgeToolUsage {
|
|
return codersdk.AIBridgeToolUsage{
|
|
ID: usage.ID,
|
|
InterceptionID: usage.InterceptionID,
|
|
ProviderResponseID: usage.ProviderResponseID,
|
|
ServerURL: usage.ServerUrl.String,
|
|
Tool: usage.Tool,
|
|
Input: usage.Input,
|
|
Injected: usage.Injected,
|
|
InvocationError: usage.InvocationError.String,
|
|
Metadata: jsonOrEmptyMap(usage.Metadata),
|
|
CreatedAt: usage.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// AIBridgeSessionThreads converts session metadata and thread interceptions
|
|
// into the threads response. It groups interceptions into threads, builds
|
|
// agentic actions from tool usages and model thoughts, and aggregates
|
|
// token usage with metadata.
|
|
func AIBridgeSessionThreads(
|
|
session database.ListAIBridgeSessionsRow,
|
|
interceptions []database.ListAIBridgeSessionThreadsRow,
|
|
tokenUsages []database.AIBridgeTokenUsage,
|
|
toolUsages []database.AIBridgeToolUsage,
|
|
userPrompts []database.AIBridgeUserPrompt,
|
|
modelThoughts []database.AIBridgeModelThought,
|
|
) codersdk.AIBridgeSessionThreadsResponse {
|
|
// Index subresources by interception ID.
|
|
tokensByInterception := make(map[uuid.UUID][]database.AIBridgeTokenUsage, len(interceptions))
|
|
for _, tu := range tokenUsages {
|
|
tokensByInterception[tu.InterceptionID] = append(tokensByInterception[tu.InterceptionID], tu)
|
|
}
|
|
toolsByInterception := make(map[uuid.UUID][]database.AIBridgeToolUsage, len(interceptions))
|
|
for _, tu := range toolUsages {
|
|
toolsByInterception[tu.InterceptionID] = append(toolsByInterception[tu.InterceptionID], tu)
|
|
}
|
|
promptsByInterception := make(map[uuid.UUID][]database.AIBridgeUserPrompt, len(interceptions))
|
|
for _, up := range userPrompts {
|
|
promptsByInterception[up.InterceptionID] = append(promptsByInterception[up.InterceptionID], up)
|
|
}
|
|
thoughtsByInterception := make(map[uuid.UUID][]database.AIBridgeModelThought, len(interceptions))
|
|
for _, mt := range modelThoughts {
|
|
thoughtsByInterception[mt.InterceptionID] = append(thoughtsByInterception[mt.InterceptionID], mt)
|
|
}
|
|
|
|
// Group interceptions by thread_id, preserving the order returned by the
|
|
// SQL query.
|
|
interceptionsByThread := make(map[uuid.UUID][]database.AIBridgeInterception, len(interceptions))
|
|
var threadIDs []uuid.UUID
|
|
for _, row := range interceptions {
|
|
if _, ok := interceptionsByThread[row.ThreadID]; !ok {
|
|
threadIDs = append(threadIDs, row.ThreadID)
|
|
}
|
|
interceptionsByThread[row.ThreadID] = append(interceptionsByThread[row.ThreadID], row.AIBridgeInterception)
|
|
}
|
|
|
|
// Build threads and track page time bounds.
|
|
threads := make([]codersdk.AIBridgeThread, 0, len(threadIDs))
|
|
var pageStartedAt, pageEndedAt *time.Time
|
|
for _, threadID := range threadIDs {
|
|
intcs := interceptionsByThread[threadID]
|
|
thread := buildAIBridgeThread(threadID, intcs, tokensByInterception, toolsByInterception, promptsByInterception, thoughtsByInterception)
|
|
for _, intc := range intcs {
|
|
if pageStartedAt == nil || intc.StartedAt.Before(*pageStartedAt) {
|
|
t := intc.StartedAt
|
|
pageStartedAt = &t
|
|
}
|
|
if intc.EndedAt.Valid {
|
|
if pageEndedAt == nil || intc.EndedAt.Time.After(*pageEndedAt) {
|
|
t := intc.EndedAt.Time
|
|
pageEndedAt = &t
|
|
}
|
|
}
|
|
}
|
|
threads = append(threads, thread)
|
|
}
|
|
|
|
// Aggregate session-level token usage metadata from all token
|
|
// usages in the session (not just the page).
|
|
sessionTokenMeta := aggregateTokenMetadata(tokenUsages)
|
|
|
|
resp := codersdk.AIBridgeSessionThreadsResponse{
|
|
ID: session.SessionID,
|
|
Initiator: MinimalUserFromVisibleUser(database.VisibleUser{
|
|
ID: session.UserID,
|
|
Username: session.UserUsername,
|
|
Name: session.UserName,
|
|
AvatarURL: session.UserAvatarUrl,
|
|
}),
|
|
Providers: session.Providers,
|
|
Models: session.Models,
|
|
Metadata: jsonOrEmptyMap(pqtype.NullRawMessage{RawMessage: session.Metadata, Valid: len(session.Metadata) > 0}),
|
|
StartedAt: session.StartedAt,
|
|
PageStartedAt: pageStartedAt,
|
|
PageEndedAt: pageEndedAt,
|
|
TokenUsageSummary: codersdk.AIBridgeSessionThreadsTokenUsage{
|
|
InputTokens: session.InputTokens,
|
|
OutputTokens: session.OutputTokens,
|
|
CacheReadInputTokens: session.CacheReadInputTokens,
|
|
CacheWriteInputTokens: session.CacheWriteInputTokens,
|
|
Metadata: sessionTokenMeta,
|
|
},
|
|
Threads: threads,
|
|
}
|
|
if resp.Providers == nil {
|
|
resp.Providers = []string{}
|
|
}
|
|
if resp.Models == nil {
|
|
resp.Models = []string{}
|
|
}
|
|
if session.Client != "" {
|
|
resp.Client = &session.Client
|
|
}
|
|
if !session.EndedAt.IsZero() {
|
|
resp.EndedAt = &session.EndedAt
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func buildAIBridgeThread(
|
|
threadID uuid.UUID,
|
|
interceptions []database.AIBridgeInterception,
|
|
tokensByInterception map[uuid.UUID][]database.AIBridgeTokenUsage,
|
|
toolsByInterception map[uuid.UUID][]database.AIBridgeToolUsage,
|
|
promptsByInterception map[uuid.UUID][]database.AIBridgeUserPrompt,
|
|
thoughtsByInterception map[uuid.UUID][]database.AIBridgeModelThought,
|
|
) codersdk.AIBridgeThread {
|
|
// Find the root interception (where id == threadID) to get the
|
|
// thread prompt and model.
|
|
var rootIntc *database.AIBridgeInterception
|
|
for i := range interceptions {
|
|
if interceptions[i].ID == threadID {
|
|
rootIntc = &interceptions[i]
|
|
break
|
|
}
|
|
}
|
|
// Fallback to first interception if root not found.
|
|
if rootIntc == nil && len(interceptions) > 0 {
|
|
rootIntc = &interceptions[0]
|
|
}
|
|
|
|
thread := codersdk.AIBridgeThread{
|
|
ID: threadID,
|
|
}
|
|
if rootIntc != nil {
|
|
thread.Model = rootIntc.Model
|
|
thread.Provider = rootIntc.Provider
|
|
thread.CredentialKind = string(rootIntc.CredentialKind)
|
|
thread.CredentialHint = sanitizeCredentialHint(rootIntc.CredentialHint)
|
|
// Get first user prompt from root interception.
|
|
// A thread can only have one prompt, by definition, since we currently
|
|
// only store the last prompt observed in an interception.
|
|
if prompts := promptsByInterception[rootIntc.ID]; len(prompts) > 0 {
|
|
thread.Prompt = &prompts[0].Prompt
|
|
}
|
|
}
|
|
|
|
// Compute thread time bounds from interceptions.
|
|
for _, intc := range interceptions {
|
|
if thread.StartedAt.IsZero() || intc.StartedAt.Before(thread.StartedAt) {
|
|
thread.StartedAt = intc.StartedAt
|
|
}
|
|
if intc.EndedAt.Valid {
|
|
if thread.EndedAt == nil || intc.EndedAt.Time.After(*thread.EndedAt) {
|
|
t := intc.EndedAt.Time
|
|
thread.EndedAt = &t
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build agentic actions grouped by interception. Each interception that
|
|
// has tool calls produces one action with all its tool calls, thinking
|
|
// blocks, and token usage.
|
|
var actions []codersdk.AIBridgeAgenticAction
|
|
for _, intc := range interceptions {
|
|
tools := toolsByInterception[intc.ID]
|
|
if len(tools) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Thinking blocks for this interception.
|
|
thoughts := thoughtsByInterception[intc.ID]
|
|
thinking := make([]codersdk.AIBridgeModelThought, 0, len(thoughts))
|
|
for _, mt := range thoughts {
|
|
thinking = append(thinking, codersdk.AIBridgeModelThought{
|
|
Text: mt.Content,
|
|
})
|
|
}
|
|
|
|
// Token usage for the interception.
|
|
actionTokenUsage := aggregateTokenUsage(tokensByInterception[intc.ID])
|
|
|
|
// Build tool call list.
|
|
toolCalls := make([]codersdk.AIBridgeToolCall, 0, len(tools))
|
|
for _, tu := range tools {
|
|
toolCalls = append(toolCalls, codersdk.AIBridgeToolCall{
|
|
ID: tu.ID,
|
|
InterceptionID: tu.InterceptionID,
|
|
ProviderResponseID: tu.ProviderResponseID,
|
|
ServerURL: tu.ServerUrl.String,
|
|
Tool: tu.Tool,
|
|
Injected: tu.Injected,
|
|
Input: tu.Input,
|
|
Metadata: jsonOrEmptyMap(tu.Metadata),
|
|
CreatedAt: tu.CreatedAt,
|
|
})
|
|
}
|
|
|
|
actions = append(actions, codersdk.AIBridgeAgenticAction{
|
|
Model: intc.Model,
|
|
TokenUsage: actionTokenUsage,
|
|
Thinking: thinking,
|
|
ToolCalls: toolCalls,
|
|
})
|
|
}
|
|
|
|
if actions == nil {
|
|
// Make an empty slice so we don't serialize `null`.
|
|
actions = make([]codersdk.AIBridgeAgenticAction, 0)
|
|
}
|
|
|
|
thread.AgenticActions = actions
|
|
|
|
// Aggregate thread-level token usage.
|
|
var threadTokens []database.AIBridgeTokenUsage
|
|
for _, intc := range interceptions {
|
|
threadTokens = append(threadTokens, tokensByInterception[intc.ID]...)
|
|
}
|
|
thread.TokenUsage = aggregateTokenUsage(threadTokens)
|
|
|
|
return thread
|
|
}
|
|
|
|
// aggregateTokenUsage sums token usage rows and aggregates metadata.
|
|
func aggregateTokenUsage(tokens []database.AIBridgeTokenUsage) codersdk.AIBridgeSessionThreadsTokenUsage {
|
|
var inputTokens, outputTokens, cacheRead, cacheWrite int64
|
|
for _, tu := range tokens {
|
|
inputTokens += tu.InputTokens
|
|
outputTokens += tu.OutputTokens
|
|
cacheRead += tu.CacheReadInputTokens
|
|
cacheWrite += tu.CacheWriteInputTokens
|
|
}
|
|
return codersdk.AIBridgeSessionThreadsTokenUsage{
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
CacheReadInputTokens: cacheRead,
|
|
CacheWriteInputTokens: cacheWrite,
|
|
Metadata: aggregateTokenMetadata(tokens),
|
|
}
|
|
}
|
|
|
|
// aggregateTokenMetadata sums all numeric values from the metadata
|
|
// JSONB across the given token usage rows by key. Nested objects are
|
|
// flattened using dot-notation (e.g. {"cache": {"read_tokens": 10}}
|
|
// becomes "cache.read_tokens"). Non-numeric leaves (strings,
|
|
// booleans, arrays, nulls) are silently skipped.
|
|
func aggregateTokenMetadata(tokens []database.AIBridgeTokenUsage) map[string]any {
|
|
sums := make(map[string]int64)
|
|
for _, tu := range tokens {
|
|
if !tu.Metadata.Valid || len(tu.Metadata.RawMessage) == 0 {
|
|
continue
|
|
}
|
|
var m map[string]json.RawMessage
|
|
if err := json.Unmarshal(tu.Metadata.RawMessage, &m); err != nil {
|
|
continue
|
|
}
|
|
flattenAndSum(sums, "", m)
|
|
}
|
|
result := make(map[string]any, len(sums))
|
|
for k, v := range sums {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// flattenAndSum recursively walks a JSON object and sums all numeric
|
|
// leaf values into sums, using dot-separated keys for nested objects.
|
|
func flattenAndSum(sums map[string]int64, prefix string, m map[string]json.RawMessage) {
|
|
for k, raw := range m {
|
|
key := k
|
|
if prefix != "" {
|
|
key = prefix + "." + k
|
|
}
|
|
|
|
// Try as a number first.
|
|
var n json.Number
|
|
if err := json.Unmarshal(raw, &n); err == nil {
|
|
if v, err := n.Int64(); err == nil {
|
|
sums[key] += v
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Try as a nested object.
|
|
var nested map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &nested); err == nil {
|
|
flattenAndSum(sums, key, nested)
|
|
}
|
|
// Arrays, strings, booleans, nulls are skipped.
|
|
}
|
|
}
|
|
|
|
func GroupAIBudget(b database.GroupAiBudget) codersdk.GroupAIBudget {
|
|
return codersdk.GroupAIBudget{
|
|
GroupID: b.GroupID,
|
|
SpendLimitMicros: b.SpendLimitMicros,
|
|
CreatedAt: b.CreatedAt,
|
|
UpdatedAt: b.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func UserAIBudgetOverride(o database.UserAiBudgetOverride) codersdk.UserAIBudgetOverride {
|
|
return codersdk.UserAIBudgetOverride{
|
|
UserID: o.UserID,
|
|
GroupID: o.GroupID,
|
|
SpendLimitMicros: o.SpendLimitMicros,
|
|
CreatedAt: o.CreatedAt,
|
|
UpdatedAt: o.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidatedAtRow) []codersdk.InvalidatedPreset {
|
|
var presets []codersdk.InvalidatedPreset
|
|
for _, p := range invalidatedPresets {
|
|
presets = append(presets, codersdk.InvalidatedPreset{
|
|
TemplateName: p.TemplateName,
|
|
TemplateVersionName: p.TemplateVersionName,
|
|
PresetName: p.TemplateVersionPresetName,
|
|
})
|
|
}
|
|
return presets
|
|
}
|
|
|
|
// sanitizeCredentialHint ensures the hint looks masked before exposing
|
|
// it in the API. The aibridge library uses "..." as the masking
|
|
// delimiter (e.g. "sk-a...efgh"), so we check for its presence. If
|
|
// the hint doesn't contain "..." or exceeds the max length, it's
|
|
// replaced with "..." to prevent leaking raw secrets.
|
|
func sanitizeCredentialHint(hint string) string {
|
|
// Matches the VARCHAR(15) DB constraint.
|
|
const maxCredentialHintLength = 15
|
|
|
|
if hint == "" {
|
|
return ""
|
|
}
|
|
|
|
if len(hint) > maxCredentialHintLength || !strings.Contains(hint, "...") {
|
|
return "..."
|
|
}
|
|
return hint
|
|
}
|
|
|
|
func jsonOrEmptyMap(rawMessage pqtype.NullRawMessage) map[string]any {
|
|
var m map[string]any
|
|
if !rawMessage.Valid {
|
|
return m
|
|
}
|
|
|
|
err := json.Unmarshal(rawMessage.RawMessage, &m)
|
|
if err != nil {
|
|
// Don't reuse m
|
|
return map[string]any{}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func ChatMessage(m database.ChatMessage) codersdk.ChatMessage {
|
|
modelConfigID := &m.ModelConfigID.UUID
|
|
if !m.ModelConfigID.Valid {
|
|
modelConfigID = nil
|
|
}
|
|
createdBy := &m.CreatedBy.UUID
|
|
if !m.CreatedBy.Valid {
|
|
createdBy = nil
|
|
}
|
|
msg := codersdk.ChatMessage{
|
|
ID: m.ID,
|
|
ChatID: m.ChatID,
|
|
CreatedBy: createdBy,
|
|
ModelConfigID: modelConfigID,
|
|
CreatedAt: m.CreatedAt,
|
|
Role: codersdk.ChatMessageRole(m.Role),
|
|
}
|
|
if m.Content.Valid {
|
|
parts, err := chatMessageParts(m)
|
|
if err == nil {
|
|
msg.Content = parts
|
|
}
|
|
}
|
|
usage := chatMessageUsage(m)
|
|
if usage != nil {
|
|
msg.Usage = usage
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// chatMessageUsage builds a ChatMessageUsage from the database row,
|
|
// returning nil when no token fields are populated.
|
|
func chatMessageUsage(m database.ChatMessage) *codersdk.ChatMessageUsage {
|
|
inputTokens := nullInt64Ptr(m.InputTokens)
|
|
outputTokens := nullInt64Ptr(m.OutputTokens)
|
|
totalTokens := nullInt64Ptr(m.TotalTokens)
|
|
reasoningTokens := nullInt64Ptr(m.ReasoningTokens)
|
|
cacheCreationTokens := nullInt64Ptr(m.CacheCreationTokens)
|
|
cacheReadTokens := nullInt64Ptr(m.CacheReadTokens)
|
|
contextLimit := nullInt64Ptr(m.ContextLimit)
|
|
|
|
if inputTokens == nil && outputTokens == nil && totalTokens == nil &&
|
|
reasoningTokens == nil && cacheCreationTokens == nil &&
|
|
cacheReadTokens == nil && contextLimit == nil {
|
|
return nil
|
|
}
|
|
|
|
return &codersdk.ChatMessageUsage{
|
|
InputTokens: inputTokens,
|
|
OutputTokens: outputTokens,
|
|
TotalTokens: totalTokens,
|
|
ReasoningTokens: reasoningTokens,
|
|
CacheCreationTokens: cacheCreationTokens,
|
|
CacheReadTokens: cacheReadTokens,
|
|
ContextLimit: contextLimit,
|
|
}
|
|
}
|
|
|
|
// ChatQueuedMessage converts a queued message to its SDK representation.
|
|
func ChatQueuedMessage(message database.ChatQueuedMessage) codersdk.ChatQueuedMessage {
|
|
// Queued messages are always written by current code via
|
|
// MarshalParts, so they are always current content version.
|
|
parts, err := chatMessageParts(database.ChatMessage{
|
|
Role: database.ChatMessageRoleUser,
|
|
Content: pqtype.NullRawMessage{
|
|
RawMessage: message.Content,
|
|
Valid: len(message.Content) > 0,
|
|
},
|
|
ContentVersion: chatprompt.CurrentContentVersion,
|
|
})
|
|
if err != nil {
|
|
parts = nil
|
|
}
|
|
|
|
return codersdk.ChatQueuedMessage{
|
|
ID: message.ID,
|
|
ChatID: message.ChatID,
|
|
ModelConfigID: nullUUIDPtr(message.ModelConfigID),
|
|
Content: parts,
|
|
CreatedAt: message.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// ChatQueuedMessages converts a slice of database queued messages
|
|
// to their SDK representation.
|
|
func ChatQueuedMessages(messages []database.ChatQueuedMessage) []codersdk.ChatQueuedMessage {
|
|
out := make([]codersdk.ChatQueuedMessage, 0, len(messages))
|
|
for _, message := range messages {
|
|
out = append(out, ChatQueuedMessage(message))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func chatMessageParts(m database.ChatMessage) ([]codersdk.ChatMessagePart, error) {
|
|
parts, err := chatprompt.ParseContent(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Strip internal-only fields before API responses.
|
|
for i := range parts {
|
|
parts[i].StripInternal()
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
func nullUUIDPtr(v uuid.NullUUID) *uuid.UUID {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
value := v.UUID
|
|
return &value
|
|
}
|
|
|
|
func nullInt64Ptr(v sql.NullInt64) *int64 {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
value := v.Int64
|
|
return &value
|
|
}
|
|
|
|
func nullInt32Ptr(n sql.NullInt32) *int32 {
|
|
if !n.Valid {
|
|
return nil
|
|
}
|
|
return &n.Int32
|
|
}
|
|
|
|
func nullStringPtr(v sql.NullString) *string {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
value := v.String
|
|
return &value
|
|
}
|
|
|
|
func nullTimePtr(v sql.NullTime) *time.Time {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
value := v.Time
|
|
return &value
|
|
}
|
|
|
|
const fallbackChatLastErrorMessage = "The chat request failed unexpectedly."
|
|
|
|
func decodeChatLastError(raw pqtype.NullRawMessage) *codersdk.ChatError {
|
|
if !raw.Valid {
|
|
return nil
|
|
}
|
|
|
|
var payload codersdk.ChatError
|
|
if err := json.Unmarshal(raw.RawMessage, &payload); err != nil {
|
|
return &codersdk.ChatError{
|
|
Message: fallbackChatLastErrorMessage,
|
|
Kind: codersdk.ChatErrorKindGeneric,
|
|
}
|
|
}
|
|
|
|
payload.Message = strings.TrimSpace(payload.Message)
|
|
payload.Detail = strings.TrimSpace(payload.Detail)
|
|
payload.Kind = codersdk.ChatErrorKind(strings.TrimSpace(string(payload.Kind)))
|
|
payload.Provider = strings.TrimSpace(payload.Provider)
|
|
if payload.Kind == "" {
|
|
payload.Kind = codersdk.ChatErrorKindGeneric
|
|
}
|
|
if payload.Message == "" {
|
|
payload.Message = fallbackChatLastErrorMessage
|
|
}
|
|
return &payload
|
|
}
|
|
|
|
// Chat converts a database.Chat to a codersdk.Chat. It coalesces
|
|
// nil slices and maps to empty values for JSON serialization and
|
|
// derives RootChatID from the parent chain when not explicitly set.
|
|
// When diffStatus is non-nil the response includes diff metadata.
|
|
// When files is non-empty the response includes file metadata;
|
|
// pass nil to omit the files field (e.g. list endpoints).
|
|
func Chat(c database.Chat, diffStatus *database.ChatDiffStatus, files []database.GetChatFileMetadataByChatIDRow) codersdk.Chat {
|
|
mcpServerIDs := c.MCPServerIDs
|
|
if mcpServerIDs == nil {
|
|
mcpServerIDs = []uuid.UUID{}
|
|
}
|
|
labels := map[string]string(c.Labels)
|
|
if labels == nil {
|
|
labels = map[string]string{}
|
|
}
|
|
lastError := decodeChatLastError(c.LastError)
|
|
chat := codersdk.Chat{
|
|
ID: c.ID,
|
|
OrganizationID: c.OrganizationID,
|
|
OwnerID: c.OwnerID,
|
|
OwnerUsername: c.OwnerUsername,
|
|
OwnerName: c.OwnerName,
|
|
LastModelConfigID: c.LastModelConfigID,
|
|
Title: c.Title,
|
|
Status: codersdk.ChatStatus(c.Status),
|
|
Archived: c.Archived,
|
|
PinOrder: c.PinOrder,
|
|
CreatedAt: c.CreatedAt,
|
|
UpdatedAt: c.UpdatedAt,
|
|
MCPServerIDs: mcpServerIDs,
|
|
Labels: labels,
|
|
ClientType: codersdk.ChatClientType(c.ClientType),
|
|
LastError: lastError,
|
|
}
|
|
if c.LastTurnSummary.Valid {
|
|
chat.LastTurnSummary = &c.LastTurnSummary.String
|
|
}
|
|
if c.PlanMode.Valid {
|
|
chat.PlanMode = codersdk.ChatPlanMode(c.PlanMode.ChatPlanMode)
|
|
}
|
|
if c.ParentChatID.Valid {
|
|
parentChatID := c.ParentChatID.UUID
|
|
chat.ParentChatID = &parentChatID
|
|
}
|
|
// Always initialize Children to an empty slice so the JSON
|
|
// field serializes as [] rather than null. Root chats may
|
|
// later have children populated; child chats remain empty
|
|
// because nesting depth is capped at 1.
|
|
chat.Children = []codersdk.Chat{}
|
|
switch {
|
|
case c.RootChatID.Valid:
|
|
rootChatID := c.RootChatID.UUID
|
|
chat.RootChatID = &rootChatID
|
|
case c.ParentChatID.Valid:
|
|
rootChatID := c.ParentChatID.UUID
|
|
chat.RootChatID = &rootChatID
|
|
default:
|
|
rootChatID := c.ID
|
|
chat.RootChatID = &rootChatID
|
|
}
|
|
if c.WorkspaceID.Valid {
|
|
chat.WorkspaceID = &c.WorkspaceID.UUID
|
|
}
|
|
if c.BuildID.Valid {
|
|
chat.BuildID = &c.BuildID.UUID
|
|
}
|
|
if c.AgentID.Valid {
|
|
chat.AgentID = &c.AgentID.UUID
|
|
}
|
|
if diffStatus != nil {
|
|
convertedDiffStatus := ChatDiffStatus(c.ID, diffStatus)
|
|
chat.DiffStatus = &convertedDiffStatus
|
|
}
|
|
if len(files) > 0 {
|
|
chat.Files = make([]codersdk.ChatFileMetadata, 0, len(files))
|
|
for _, row := range files {
|
|
chat.Files = append(chat.Files, codersdk.ChatFileMetadata{
|
|
ID: row.ID,
|
|
OwnerID: row.OwnerID,
|
|
OrganizationID: row.OrganizationID,
|
|
Name: row.Name,
|
|
MimeType: row.Mimetype,
|
|
CreatedAt: row.CreatedAt,
|
|
})
|
|
}
|
|
}
|
|
if c.LastInjectedContext.Valid {
|
|
var parts []codersdk.ChatMessagePart
|
|
// Internal fields are stripped at write time in
|
|
// chatd.updateLastInjectedContext, so no
|
|
// StripInternal call is needed here. Unmarshal
|
|
// errors are suppressed — the column is written by
|
|
// us with a known schema.
|
|
if err := json.Unmarshal(c.LastInjectedContext.RawMessage, &parts); err == nil {
|
|
chat.LastInjectedContext = parts
|
|
}
|
|
}
|
|
return chat
|
|
}
|
|
|
|
func chatDebugAttempts(raw json.RawMessage) []map[string]any {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var attempts []map[string]any
|
|
if err := json.Unmarshal(raw, &attempts); err != nil {
|
|
return []map[string]any{{
|
|
"error": "malformed attempts payload",
|
|
"parse_error": err.Error(),
|
|
"raw": string(raw),
|
|
}}
|
|
}
|
|
// Guard against JSON literal "null" which unmarshals successfully
|
|
// but leaves the slice nil. The DB column is JSONB NOT NULL but
|
|
// that only rejects SQL NULL, not JSONB null.
|
|
if attempts == nil {
|
|
return []map[string]any{}
|
|
}
|
|
return attempts
|
|
}
|
|
|
|
// rawJSONObject deserializes a JSON object payload for debug display.
|
|
// If the payload is malformed, it returns a map with "error" and "raw"
|
|
// keys preserving the original content for diagnostics. Callers that
|
|
// consume the result programmatically should check for the "error" key.
|
|
func rawJSONObject(raw json.RawMessage) map[string]any {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var object map[string]any
|
|
if err := json.Unmarshal(raw, &object); err != nil {
|
|
return map[string]any{
|
|
"error": "malformed debug payload",
|
|
"parse_error": err.Error(),
|
|
"raw": string(raw),
|
|
}
|
|
}
|
|
// Guard against JSON literal "null" which unmarshals successfully
|
|
// but leaves the map nil. The DB column is JSONB NOT NULL but
|
|
// that only rejects SQL NULL, not JSONB null.
|
|
if object == nil {
|
|
return map[string]any{}
|
|
}
|
|
return object
|
|
}
|
|
|
|
func nullRawJSONObject(raw pqtype.NullRawMessage) map[string]any {
|
|
if !raw.Valid {
|
|
return nil
|
|
}
|
|
return rawJSONObject(raw.RawMessage)
|
|
}
|
|
|
|
// ChatDebugRunSummary converts a database.ChatDebugRun to a
|
|
// codersdk.ChatDebugRunSummary.
|
|
func ChatDebugRunSummary(r database.ChatDebugRun) codersdk.ChatDebugRunSummary {
|
|
return codersdk.ChatDebugRunSummary{
|
|
ID: r.ID,
|
|
ChatID: r.ChatID,
|
|
Kind: codersdk.ChatDebugRunKind(r.Kind),
|
|
Status: codersdk.ChatDebugStatus(r.Status),
|
|
Provider: nullStringPtr(r.Provider),
|
|
Model: nullStringPtr(r.Model),
|
|
Summary: rawJSONObject(r.Summary),
|
|
StartedAt: r.StartedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
FinishedAt: nullTimePtr(r.FinishedAt),
|
|
}
|
|
}
|
|
|
|
// ChatDebugStep converts a database.ChatDebugStep to a
|
|
// codersdk.ChatDebugStep.
|
|
func ChatDebugStep(s database.ChatDebugStep) codersdk.ChatDebugStep {
|
|
return codersdk.ChatDebugStep{
|
|
ID: s.ID,
|
|
RunID: s.RunID,
|
|
ChatID: s.ChatID,
|
|
StepNumber: s.StepNumber,
|
|
Operation: codersdk.ChatDebugStepOperation(s.Operation),
|
|
Status: codersdk.ChatDebugStatus(s.Status),
|
|
HistoryTipMessageID: nullInt64Ptr(s.HistoryTipMessageID),
|
|
AssistantMessageID: nullInt64Ptr(s.AssistantMessageID),
|
|
NormalizedRequest: rawJSONObject(s.NormalizedRequest),
|
|
NormalizedResponse: nullRawJSONObject(s.NormalizedResponse),
|
|
Usage: nullRawJSONObject(s.Usage),
|
|
Attempts: chatDebugAttempts(s.Attempts),
|
|
Error: nullRawJSONObject(s.Error),
|
|
Metadata: rawJSONObject(s.Metadata),
|
|
StartedAt: s.StartedAt,
|
|
UpdatedAt: s.UpdatedAt,
|
|
FinishedAt: nullTimePtr(s.FinishedAt),
|
|
}
|
|
}
|
|
|
|
// ChatDebugRunDetail converts a database.ChatDebugRun and its steps
|
|
// to a codersdk.ChatDebugRun.
|
|
func ChatDebugRunDetail(r database.ChatDebugRun, steps []database.ChatDebugStep) codersdk.ChatDebugRun {
|
|
sdkSteps := make([]codersdk.ChatDebugStep, 0, len(steps))
|
|
for _, s := range steps {
|
|
sdkSteps = append(sdkSteps, ChatDebugStep(s))
|
|
}
|
|
return codersdk.ChatDebugRun{
|
|
ID: r.ID,
|
|
ChatID: r.ChatID,
|
|
RootChatID: nullUUIDPtr(r.RootChatID),
|
|
ParentChatID: nullUUIDPtr(r.ParentChatID),
|
|
ModelConfigID: nullUUIDPtr(r.ModelConfigID),
|
|
TriggerMessageID: nullInt64Ptr(r.TriggerMessageID),
|
|
HistoryTipMessageID: nullInt64Ptr(r.HistoryTipMessageID),
|
|
Kind: codersdk.ChatDebugRunKind(r.Kind),
|
|
Status: codersdk.ChatDebugStatus(r.Status),
|
|
Provider: nullStringPtr(r.Provider),
|
|
Model: nullStringPtr(r.Model),
|
|
Summary: rawJSONObject(r.Summary),
|
|
StartedAt: r.StartedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
FinishedAt: nullTimePtr(r.FinishedAt),
|
|
Steps: sdkSteps,
|
|
}
|
|
}
|
|
|
|
// ChildChatRows converts child chat rows to codersdk.Chat values,
|
|
// resolving diff statuses from the shared map. When diffStatuses
|
|
// is non-nil, children without an entry receive an empty DiffStatus.
|
|
func ChildChatRows(
|
|
children []database.GetChildChatsByParentIDsRow,
|
|
diffStatuses map[uuid.UUID]database.ChatDiffStatus,
|
|
) []codersdk.Chat {
|
|
result := make([]codersdk.Chat, len(children))
|
|
for i, row := range children {
|
|
diffStatus, ok := diffStatuses[row.Chat.ID]
|
|
if ok {
|
|
result[i] = Chat(row.Chat, &diffStatus, nil)
|
|
} else {
|
|
result[i] = Chat(row.Chat, nil, nil)
|
|
if diffStatuses != nil {
|
|
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
|
|
result[i].DiffStatus = &emptyDiffStatus
|
|
}
|
|
}
|
|
result[i].HasUnread = row.HasUnread
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ChatRowsWithChildren converts root chat rows and their child rows
|
|
// into codersdk.Chat values with children embedded under each parent.
|
|
// Both root and child diff statuses are resolved from the shared map.
|
|
func ChatRowsWithChildren(
|
|
roots []database.GetChatsRow,
|
|
children []database.GetChildChatsByParentIDsRow,
|
|
diffStatuses map[uuid.UUID]database.ChatDiffStatus,
|
|
) []codersdk.Chat {
|
|
// Group children by parent ID.
|
|
childrenByParent := make(map[uuid.UUID][]database.GetChildChatsByParentIDsRow, len(children))
|
|
for _, row := range children {
|
|
parentID := row.Chat.ParentChatID.UUID
|
|
childrenByParent[parentID] = append(childrenByParent[parentID], row)
|
|
}
|
|
|
|
result := make([]codersdk.Chat, len(roots))
|
|
for i, row := range roots {
|
|
diffStatus, ok := diffStatuses[row.Chat.ID]
|
|
if ok {
|
|
result[i] = Chat(row.Chat, &diffStatus, nil)
|
|
} else {
|
|
result[i] = Chat(row.Chat, nil, nil)
|
|
if diffStatuses != nil {
|
|
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
|
|
result[i].DiffStatus = &emptyDiffStatus
|
|
}
|
|
}
|
|
result[i].HasUnread = row.HasUnread
|
|
|
|
// Embed child chats.
|
|
if childRows, ok := childrenByParent[row.Chat.ID]; ok {
|
|
result[i].Children = ChildChatRows(childRows, diffStatuses)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ChatDiffStatus converts a database.ChatDiffStatus to a
|
|
// codersdk.ChatDiffStatus. When status is nil an empty value
|
|
// containing only the chatID is returned.
|
|
func ChatDiffStatus(chatID uuid.UUID, status *database.ChatDiffStatus) codersdk.ChatDiffStatus {
|
|
result := codersdk.ChatDiffStatus{
|
|
ChatID: chatID,
|
|
}
|
|
if status == nil {
|
|
return result
|
|
}
|
|
|
|
result.ChatID = status.ChatID
|
|
if status.Url.Valid {
|
|
u := strings.TrimSpace(status.Url.String)
|
|
if u != "" {
|
|
result.URL = &u
|
|
}
|
|
}
|
|
if result.URL == nil {
|
|
// Try to build a branch URL from the stored origin.
|
|
// Since this function does not have access to the API
|
|
// instance, we construct a GitHub provider directly as
|
|
// a best-effort fallback.
|
|
// TODO: This uses the default github.com API base URL,
|
|
// so branch URLs for GitHub Enterprise instances will
|
|
// be incorrect. To fix this, this function would need
|
|
// access to the external auth configs.
|
|
gp, _ := gitprovider.New("github", "", nil)
|
|
if gp != nil {
|
|
if owner, repo, _, ok := gp.ParseRepositoryOrigin(status.GitRemoteOrigin); ok {
|
|
branchURL := gp.BuildBranchURL(owner, repo, status.GitBranch)
|
|
if branchURL != "" {
|
|
result.URL = &branchURL
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if status.PullRequestState.Valid {
|
|
pullRequestState := strings.TrimSpace(status.PullRequestState.String)
|
|
if pullRequestState != "" {
|
|
result.PullRequestState = &pullRequestState
|
|
}
|
|
}
|
|
result.PullRequestTitle = status.PullRequestTitle
|
|
result.PullRequestDraft = status.PullRequestDraft
|
|
result.ChangesRequested = status.ChangesRequested
|
|
result.Additions = status.Additions
|
|
result.Deletions = status.Deletions
|
|
result.ChangedFiles = status.ChangedFiles
|
|
if status.AuthorLogin.Valid {
|
|
result.AuthorLogin = &status.AuthorLogin.String
|
|
}
|
|
if status.AuthorAvatarUrl.Valid {
|
|
result.AuthorAvatarURL = &status.AuthorAvatarUrl.String
|
|
}
|
|
if status.BaseBranch.Valid {
|
|
result.BaseBranch = &status.BaseBranch.String
|
|
}
|
|
if status.HeadBranch.Valid {
|
|
result.HeadBranch = &status.HeadBranch.String
|
|
}
|
|
if status.PrNumber.Valid {
|
|
result.PRNumber = &status.PrNumber.Int32
|
|
}
|
|
if status.Commits.Valid {
|
|
result.Commits = &status.Commits.Int32
|
|
}
|
|
if status.Approved.Valid {
|
|
result.Approved = &status.Approved.Bool
|
|
}
|
|
if status.ReviewerCount.Valid {
|
|
result.ReviewerCount = &status.ReviewerCount.Int32
|
|
}
|
|
if status.RefreshedAt.Valid {
|
|
refreshedAt := status.RefreshedAt.Time
|
|
result.RefreshedAt = &refreshedAt
|
|
}
|
|
staleAt := status.StaleAt
|
|
result.StaleAt = &staleAt
|
|
|
|
return result
|
|
}
|
|
|
|
// UserSecret converts a database ListUserSecretsRow (metadata only,
|
|
// no value) to an SDK UserSecret.
|
|
func UserSecret(secret database.ListUserSecretsRow) codersdk.UserSecret {
|
|
return codersdk.UserSecret{
|
|
ID: secret.ID,
|
|
Name: secret.Name,
|
|
Description: secret.Description,
|
|
EnvName: secret.EnvName,
|
|
FilePath: secret.FilePath,
|
|
CreatedAt: secret.CreatedAt,
|
|
UpdatedAt: secret.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// UserSecretFromFull converts a full database UserSecret row to an
|
|
// SDK UserSecret, omitting the value and encryption key ID.
|
|
func UserSecretFromFull(secret database.UserSecret) codersdk.UserSecret {
|
|
return codersdk.UserSecret{
|
|
ID: secret.ID,
|
|
Name: secret.Name,
|
|
Description: secret.Description,
|
|
EnvName: secret.EnvName,
|
|
FilePath: secret.FilePath,
|
|
CreatedAt: secret.CreatedAt,
|
|
UpdatedAt: secret.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// UserSecrets converts a slice of database ListUserSecretsRow to
|
|
// SDK UserSecret values.
|
|
func UserSecrets(secrets []database.ListUserSecretsRow) []codersdk.UserSecret {
|
|
result := make([]codersdk.UserSecret, 0, len(secrets))
|
|
for _, s := range secrets {
|
|
result = append(result, UserSecret(s))
|
|
}
|
|
return result
|
|
}
|
|
|
|
// UserSkill converts a database UserSkill to an SDK UserSkill.
|
|
func UserSkill(skill database.UserSkill) codersdk.UserSkill {
|
|
return codersdk.UserSkill{
|
|
UserSkillMetadata: codersdk.UserSkillMetadata{
|
|
ID: skill.ID,
|
|
Name: skill.Name,
|
|
Description: skill.Description,
|
|
CreatedAt: skill.CreatedAt,
|
|
UpdatedAt: skill.UpdatedAt,
|
|
},
|
|
Content: skill.Content,
|
|
}
|
|
}
|
|
|
|
// UserSkillMetadata converts database user skill metadata to an SDK UserSkillMetadata.
|
|
func UserSkillMetadata(skill database.ListUserSkillMetadataByUserIDRow) codersdk.UserSkillMetadata {
|
|
return codersdk.UserSkillMetadata{
|
|
ID: skill.ID,
|
|
Name: skill.Name,
|
|
Description: skill.Description,
|
|
CreatedAt: skill.CreatedAt,
|
|
UpdatedAt: skill.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// UserSkillMetadataList converts database user skill metadata rows to SDK values.
|
|
func UserSkillMetadataList(rows []database.ListUserSkillMetadataByUserIDRow) []codersdk.UserSkillMetadata {
|
|
metadata := make([]codersdk.UserSkillMetadata, 0, len(rows))
|
|
for _, row := range rows {
|
|
metadata = append(metadata, UserSkillMetadata(row))
|
|
}
|
|
return metadata
|
|
}
|