mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
730edba87a
We noticed during higher active workspace counts that the agent connection metric, generated via a query to the database, would report a relatively high amount of agents as disconnected. Somewhere between 5 and 20%. However, other metrics such as # of websocket connections would suggest that all agent connections are healthy. Looking at the `Agents` function in prometheus metrics, plus the query execution time (not accounting for actual database RT time) revealed that this reporting of agents as disconnected was almost certainly false positives due to clock drift in the way we're generating the metric values. At 10k metrics, with a p50 of 2ms and p99 of 5ms, the entire `agents` function could take upwards of 50s to execute. Because we were doing a query/database RT to query th apps for each agent individually, and grabbing a `time.Now` value on each iteration of that loop, it's likely the portion of agents that were reported as disconnected were those that had last heartbeat the furthest in the past. The fix here is to set a consistent `now` before fetching agent data to avoid clock drift inflating the inactive timeout comparison, and replace the per-agent app query N+1 with a single batched lookup to prevent loop execution time from pushing agents over the disconnected threshold. Signed-off-by: Callum Styan <callumstyan@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1897 lines
61 KiB
Go
1897 lines
61 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"
|
|
"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,
|
|
}
|
|
}
|
|
|
|
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 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 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,
|
|
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 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,
|
|
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 nullInt64Ptr(v sql.NullInt64) *int64 {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
value := v.Int64
|
|
return &value
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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{}
|
|
}
|
|
chat := codersdk.Chat{
|
|
ID: c.ID,
|
|
OrganizationID: c.OrganizationID,
|
|
OwnerID: c.OwnerID,
|
|
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,
|
|
}
|
|
if c.LastError.Valid {
|
|
chat.LastError = &c.LastError.String
|
|
}
|
|
if c.ParentChatID.Valid {
|
|
parentChatID := c.ParentChatID.UUID
|
|
chat.ParentChatID = &parentChatID
|
|
}
|
|
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),
|
|
}
|
|
}
|
|
|
|
// ChatRows converts a slice of database.GetChatsRow (which embeds
|
|
// Chat plus HasUnread) to codersdk.Chat, looking up diff statuses
|
|
// from the provided map. When diffStatusesByChatID is non-nil,
|
|
// chats without an entry receive an empty DiffStatus.
|
|
func ChatRows(rows []database.GetChatsRow, diffStatusesByChatID map[uuid.UUID]database.ChatDiffStatus) []codersdk.Chat {
|
|
result := make([]codersdk.Chat, len(rows))
|
|
for i, row := range rows {
|
|
diffStatus, ok := diffStatusesByChatID[row.Chat.ID]
|
|
if ok {
|
|
result[i] = Chat(row.Chat, &diffStatus, nil)
|
|
} else {
|
|
result[i] = Chat(row.Chat, nil, nil)
|
|
if diffStatusesByChatID != nil {
|
|
emptyDiffStatus := ChatDiffStatus(row.Chat.ID, nil)
|
|
result[i].DiffStatus = &emptyDiffStatus
|
|
}
|
|
}
|
|
result[i].HasUnread = row.HasUnread
|
|
}
|
|
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
|
|
}
|