mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
0bfb9f6f13
Persists the agent-generated turn-end summary on `chats` and shows it as the Agents sidebar subtitle when present, falling back to the model name. Errors still take precedence. > Mux is acting on Mike's behalf. ## What changes **Storage.** New nullable `last_turn_summary` column on `chats` (migration `000486`). New `UpdateChatLastTurnSummary` query normalizes blank/whitespace input to `NULL`, preserves `updated_at` (so the chat does not jump to the top of the sidebar on summary writes), and uses an `expected_updated_at` stale-write guard so an older async summary cannot overwrite a newer turn. **Backend.** `coderd/x/chatd/chatd.go` decouples summary generation from webpush. Generated summaries persist for completed parent turns even when webpush is unconfigured or has no subscriptions. The same generated text is reused as the webpush body when webpush is configured, so the summary model is not called twice. Generic fallback push text is no longer persisted; it clears any stale summary instead. Error/interrupt/pending-action terminal paths clear `last_turn_summary` for the latest turn. **Frontend.** `AgentsSidebar.tsx` subtitle priority is now `errorReason || lastTurnSummary || modelName`, normalized via the existing `asNonEmptyString` helper from `blockUtils.ts`. ## Tests - `TestUpdateChatLastTurnSummary` (database): success, whitespace-to-NULL, stale guard rejects, `updated_at` preserved. - `TestUpdateLastTurnSummaryRejectsStaleWrites` (chatd internal): direct stale-`expected_updated_at` test. - `TestSuccessfulChatPersistsTurnSummaryWithoutWebPush`: persistence works without webpush subscriptions. - `TestSuccessfulChatSendsWebPushWithSummary`: same generated text drives both DB and push body. - `TestSuccessfulChatSendsWebPushFallbackWithoutSummaryForEmptyAssistantText`: fallback text is not persisted. - `TestErroredChatClearsLastTurnSummaryAndSendsWebPush`: error path clears the field. - `TestInterruptChatDoesNotSendWebPushNotification`: interrupt path clears the field, no push fires. - `AgentsSidebar.test.tsx`: subtitle priority for summary-present, error-wins, no-summary fallback, whitespace fallback. - `AgentsSidebar.stories.tsx`: `ChatWithTurnSummary` and `ChatWithTurnSummaryAndError`. ## Notes - No backfill. Existing chats keep showing the model name until their next turn completes. - Parent chats only in this iteration; the field is rendered on any `Chat` if a future change extends generation to children. - Decoupling generation from webpush adds quickgen model calls for completed parent turns that previously skipped generation when no subscriptions existed. Existing parent-only, assistant-text-present, `PushSummaryModel` configured, and bounded-timeout gates keep this behavior bounded.
2040 lines
66 KiB
Go
2040 lines
66 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 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 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 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,
|
|
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
|
|
}
|