Files
coder/coderd/database/db2sdk/db2sdk.go
T
Michael Suchacz 0bfb9f6f13 feat: show agent turn summary in agents sidebar (#24942)
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.
2026-05-06 16:43:35 +02:00

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 = &param.ValidationMin.Int32
}
var validationMax *int32
if param.ValidationMax.Valid {
validationMax = &param.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
}