mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
1060 lines
28 KiB
Go
1060 lines
28 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/fantasy"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/pubsub"
|
|
"github.com/coder/coder/v2/coderd/util/namesgenerator"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/provisionersdk"
|
|
)
|
|
|
|
var (
|
|
promptTokenPattern = regexp.MustCompile(`[a-z0-9]+`)
|
|
templateSelectionUUIDPattern = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
|
|
)
|
|
|
|
const (
|
|
chatWorkspaceBuildLogStreamTimeout = 2 * time.Minute
|
|
chatWorkspaceBuildLogPollInterval = 2 * time.Second
|
|
)
|
|
|
|
type workspaceCreator struct {
|
|
prepareWorkspaceCreateFn func(
|
|
ctx context.Context,
|
|
chat database.Chat,
|
|
) (context.Context, *http.Request, string, error)
|
|
authorizedTemplatesFn func(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
) ([]database.Template, error)
|
|
createWorkspaceFn func(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
ownerID uuid.UUID,
|
|
req codersdk.CreateWorkspaceRequest,
|
|
) (codersdk.Workspace, error)
|
|
db database.Store
|
|
pubsub pubsub.Pubsub
|
|
logger slog.Logger
|
|
}
|
|
|
|
// NewWorkspaceCreator returns the default create-workspace implementation used
|
|
// by the chat processor.
|
|
func NewWorkspaceCreator(
|
|
prepareWorkspaceCreate func(
|
|
ctx context.Context,
|
|
chat database.Chat,
|
|
) (context.Context, *http.Request, string, error),
|
|
authorizedTemplates func(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
) ([]database.Template, error),
|
|
createWorkspace func(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
ownerID uuid.UUID,
|
|
req codersdk.CreateWorkspaceRequest,
|
|
) (codersdk.Workspace, error),
|
|
db database.Store,
|
|
ps pubsub.Pubsub,
|
|
logger slog.Logger,
|
|
) CreateWorkspaceFunc {
|
|
creator := &workspaceCreator{
|
|
prepareWorkspaceCreateFn: prepareWorkspaceCreate,
|
|
authorizedTemplatesFn: authorizedTemplates,
|
|
createWorkspaceFn: createWorkspace,
|
|
db: db,
|
|
pubsub: ps,
|
|
logger: logger,
|
|
}
|
|
return creator.CreateWorkspace
|
|
}
|
|
|
|
type templateSelectionReasonError struct {
|
|
reason string
|
|
}
|
|
|
|
func (e *templateSelectionReasonError) Error() string {
|
|
return e.reason
|
|
}
|
|
|
|
func newTemplateSelectionReasonError(reason string) error {
|
|
return &templateSelectionReasonError{reason: strings.TrimSpace(reason)}
|
|
}
|
|
|
|
func templateSelectionReason(err error) (string, bool) {
|
|
var reasonErr *templateSelectionReasonError
|
|
if !errors.As(err, &reasonErr) || reasonErr == nil || reasonErr.reason == "" {
|
|
return "", false
|
|
}
|
|
return reasonErr.reason, true
|
|
}
|
|
|
|
func (c *workspaceCreator) CreateWorkspace(
|
|
ctx context.Context,
|
|
req CreateWorkspaceToolRequest,
|
|
) (CreateWorkspaceToolResult, error) {
|
|
if c == nil {
|
|
return CreateWorkspaceToolResult{}, xerrors.New("chat workspace creator is not configured")
|
|
}
|
|
if c.db == nil {
|
|
return CreateWorkspaceToolResult{}, xerrors.New("chat workspace creator database is not configured")
|
|
}
|
|
if c.prepareWorkspaceCreateFn == nil {
|
|
return CreateWorkspaceToolResult{}, xerrors.New("chat workspace creator prepare callback is not configured")
|
|
}
|
|
|
|
ctx, httpReq, accessURL, err := c.prepareWorkspaceCreateFn(ctx, req.Chat)
|
|
if err != nil {
|
|
return CreateWorkspaceToolResult{}, err
|
|
}
|
|
accessURL = strings.TrimRight(accessURL, "/")
|
|
|
|
spec, err := ParseWorkspaceSpec(req.Spec)
|
|
if err != nil {
|
|
return CreateWorkspaceToolResult{
|
|
Created: false,
|
|
Reason: fmt.Sprintf("invalid workspace request format: %s", err),
|
|
}, nil
|
|
}
|
|
|
|
createReq := spec.CreateRequest
|
|
if strings.TrimSpace(createReq.Name) == "" && strings.TrimSpace(spec.Name) != "" {
|
|
createReq.Name = strings.TrimSpace(spec.Name)
|
|
}
|
|
|
|
template, err := c.resolveTemplateSelection(ctx, httpReq, req, spec, &createReq)
|
|
if err != nil {
|
|
if reason, ok := templateSelectionReason(err); ok {
|
|
return CreateWorkspaceToolResult{
|
|
Created: false,
|
|
Reason: reason,
|
|
}, nil
|
|
}
|
|
return CreateWorkspaceToolResult{}, err
|
|
}
|
|
|
|
if err := FinalizeWorkspaceName(&createReq, spec, req.Prompt, template.Name); err != nil {
|
|
return CreateWorkspaceToolResult{
|
|
Created: false,
|
|
Reason: err.Error(),
|
|
}, nil
|
|
}
|
|
|
|
if c.createWorkspaceFn == nil {
|
|
return CreateWorkspaceToolResult{}, xerrors.New("chat workspace creator create callback is not configured")
|
|
}
|
|
workspace, err := c.createWorkspaceFn(ctx, httpReq, req.Chat.OwnerID, createReq)
|
|
if err != nil {
|
|
return CreateWorkspaceToolResult{}, err
|
|
}
|
|
|
|
if req.BuildLogHandler != nil {
|
|
c.streamWorkspaceBuildLogs(ctx, workspace.LatestBuild.Job.ID, req.BuildLogHandler)
|
|
}
|
|
|
|
workspaceAgentID := uuid.Nil
|
|
agents, err := c.db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, workspace.ID)
|
|
if err == nil && len(agents) > 0 {
|
|
workspaceAgentID = agents[0].ID
|
|
}
|
|
|
|
workspaceURL := ""
|
|
if accessURL != "" {
|
|
workspaceURL = fmt.Sprintf("%s/@%s/%s", accessURL, workspace.OwnerName, workspace.Name)
|
|
}
|
|
|
|
return CreateWorkspaceToolResult{
|
|
Created: true,
|
|
WorkspaceID: workspace.ID,
|
|
WorkspaceAgentID: workspaceAgentID,
|
|
WorkspaceName: workspace.FullName(),
|
|
WorkspaceURL: workspaceURL,
|
|
}, nil
|
|
}
|
|
|
|
// WorkspaceSpec is a normalized create-workspace tool payload.
|
|
type WorkspaceSpec struct {
|
|
CreateRequest codersdk.CreateWorkspaceRequest
|
|
|
|
Name string
|
|
NameExplicit bool
|
|
|
|
TemplateID uuid.UUID
|
|
TemplateRef string
|
|
TemplateExplicit bool
|
|
|
|
TemplateVersionID uuid.UUID
|
|
TemplateVersionRef string
|
|
TemplateVersionExplicit bool
|
|
}
|
|
|
|
// ParseWorkspaceSpec decodes a create-workspace tool payload into a normalized
|
|
// shape that supports common alias keys.
|
|
func ParseWorkspaceSpec(raw json.RawMessage) (WorkspaceSpec, error) {
|
|
spec := WorkspaceSpec{}
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return spec, nil
|
|
}
|
|
|
|
if err := json.Unmarshal(raw, &spec.CreateRequest); err != nil {
|
|
return WorkspaceSpec{}, xerrors.Errorf("decode create workspace request: %w", err)
|
|
}
|
|
|
|
if spec.CreateRequest.TemplateID != uuid.Nil {
|
|
spec.TemplateID = spec.CreateRequest.TemplateID
|
|
spec.TemplateExplicit = true
|
|
}
|
|
if spec.CreateRequest.TemplateVersionID != uuid.Nil {
|
|
spec.TemplateVersionID = spec.CreateRequest.TemplateVersionID
|
|
spec.TemplateVersionExplicit = true
|
|
}
|
|
if strings.TrimSpace(spec.CreateRequest.Name) != "" {
|
|
spec.Name = strings.TrimSpace(spec.CreateRequest.Name)
|
|
spec.NameExplicit = true
|
|
}
|
|
|
|
payload := map[string]any{}
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
// If we could decode into CreateWorkspaceRequest but not into map,
|
|
// keep going with the structured parse.
|
|
return spec, nil
|
|
}
|
|
|
|
if value, ok := findValue(payload, "name", "workspace_name", "workspaceName"); ok {
|
|
if name := strings.TrimSpace(toString(value)); name != "" {
|
|
spec.Name = name
|
|
spec.NameExplicit = true
|
|
}
|
|
}
|
|
|
|
if value, ok := findValue(payload, "template_id", "templateId", "templateID"); ok {
|
|
if parsedID := parseUUIDValue(value); parsedID != uuid.Nil {
|
|
spec.TemplateID = parsedID
|
|
spec.TemplateExplicit = true
|
|
}
|
|
}
|
|
if value, ok := findValue(payload, "template"); ok {
|
|
templateID, templateRef, explicit := parseTemplateReference(value)
|
|
if explicit {
|
|
spec.TemplateExplicit = true
|
|
}
|
|
if templateID != uuid.Nil {
|
|
spec.TemplateID = templateID
|
|
}
|
|
if templateRef != "" {
|
|
spec.TemplateRef = templateRef
|
|
}
|
|
}
|
|
if value, ok := findValue(payload, "template_name", "templateName"); ok {
|
|
if templateRef := strings.TrimSpace(toString(value)); templateRef != "" {
|
|
spec.TemplateRef = templateRef
|
|
spec.TemplateExplicit = true
|
|
}
|
|
}
|
|
|
|
if value, ok := findValue(payload, "template_version_id", "templateVersionID", "templateVersionId"); ok {
|
|
if parsedID := parseUUIDValue(value); parsedID != uuid.Nil {
|
|
spec.TemplateVersionID = parsedID
|
|
spec.TemplateVersionExplicit = true
|
|
}
|
|
}
|
|
if value, ok := findValue(payload, "template_version"); ok {
|
|
templateVersionID, templateVersionRef, explicit := parseTemplateReference(value)
|
|
if explicit {
|
|
spec.TemplateVersionExplicit = true
|
|
}
|
|
if templateVersionID != uuid.Nil {
|
|
spec.TemplateVersionID = templateVersionID
|
|
}
|
|
if templateVersionRef != "" {
|
|
spec.TemplateVersionRef = templateVersionRef
|
|
}
|
|
}
|
|
if value, ok := findValue(payload, "template_version_name", "templateVersionName"); ok {
|
|
if templateVersionRef := strings.TrimSpace(toString(value)); templateVersionRef != "" {
|
|
spec.TemplateVersionRef = templateVersionRef
|
|
spec.TemplateVersionExplicit = true
|
|
}
|
|
}
|
|
|
|
return spec, nil
|
|
}
|
|
|
|
// FinalizeWorkspaceName resolves and validates the workspace name from the
|
|
// explicit request or prompt/template fallback.
|
|
func FinalizeWorkspaceName(
|
|
createReq *codersdk.CreateWorkspaceRequest,
|
|
spec WorkspaceSpec,
|
|
prompt string,
|
|
templateName string,
|
|
) error {
|
|
if createReq == nil {
|
|
return xerrors.New("create workspace request is nil")
|
|
}
|
|
|
|
name := strings.TrimSpace(createReq.Name)
|
|
explicit := spec.NameExplicit || name != ""
|
|
|
|
if name == "" {
|
|
seed := strings.TrimSpace(prompt)
|
|
if seed == "" {
|
|
seed = templateName
|
|
}
|
|
createReq.Name = generatedWorkspaceName(seed)
|
|
return nil
|
|
}
|
|
|
|
if err := codersdk.NameValid(name); err != nil {
|
|
if explicit {
|
|
return xerrors.Errorf("workspace name %q is invalid: %w", name, err)
|
|
}
|
|
createReq.Name = generatedWorkspaceName(name)
|
|
return nil
|
|
}
|
|
|
|
createReq.Name = name
|
|
return nil
|
|
}
|
|
|
|
// TemplateMatchesReference returns whether a template name/display name matches
|
|
// the provided reference.
|
|
func TemplateMatchesReference(template database.Template, reference string) bool {
|
|
reference = strings.TrimSpace(strings.ToLower(reference))
|
|
if reference == "" {
|
|
return false
|
|
}
|
|
return strings.EqualFold(template.Name, reference) ||
|
|
strings.EqualFold(strings.TrimSpace(template.DisplayName), reference)
|
|
}
|
|
|
|
// SearchPromptTemplates returns the highest-scored templates for prompt-based
|
|
// matching. For empty prompts it returns the input templates as-is.
|
|
func SearchPromptTemplates(
|
|
prompt string,
|
|
templates []database.Template,
|
|
limit int,
|
|
) []database.Template {
|
|
if len(templates) == 0 {
|
|
return []database.Template{}
|
|
}
|
|
|
|
prompt = strings.TrimSpace(strings.ToLower(prompt))
|
|
if prompt == "" {
|
|
return templates
|
|
}
|
|
|
|
tokens := promptTokenPattern.FindAllString(prompt, -1)
|
|
type scoredTemplate struct {
|
|
template database.Template
|
|
score int
|
|
}
|
|
scored := make([]scoredTemplate, 0, len(templates))
|
|
for _, template := range templates {
|
|
score := scoreTemplateAgainstPrompt(prompt, tokens, template)
|
|
if score == 0 {
|
|
continue
|
|
}
|
|
scored = append(scored, scoredTemplate{
|
|
template: template,
|
|
score: score,
|
|
})
|
|
}
|
|
if len(scored) == 0 {
|
|
return []database.Template{}
|
|
}
|
|
|
|
sort.Slice(scored, func(i, j int) bool {
|
|
if scored[i].score != scored[j].score {
|
|
return scored[i].score > scored[j].score
|
|
}
|
|
if scored[i].template.Name != scored[j].template.Name {
|
|
return scored[i].template.Name < scored[j].template.Name
|
|
}
|
|
return scored[i].template.ID.String() < scored[j].template.ID.String()
|
|
})
|
|
|
|
if limit <= 0 || limit > len(scored) {
|
|
limit = len(scored)
|
|
}
|
|
|
|
candidates := make([]database.Template, 0, limit)
|
|
for i := 0; i < limit; i++ {
|
|
candidates = append(candidates, scored[i].template)
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
func selectTemplateWithModel(
|
|
ctx context.Context,
|
|
model fantasy.LanguageModel,
|
|
prompt string,
|
|
candidates []database.Template,
|
|
) (database.Template, error) {
|
|
candidateLines := make([]string, 0, len(candidates))
|
|
for i := range candidates {
|
|
template := candidates[i]
|
|
candidateLines = append(candidateLines, fmt.Sprintf(
|
|
"%d) id=%s name=%q display_name=%q description=%q",
|
|
i+1,
|
|
template.ID,
|
|
template.Name,
|
|
template.DisplayName,
|
|
template.Description,
|
|
))
|
|
}
|
|
|
|
modelPrompt := strings.TrimSpace(fmt.Sprintf(
|
|
"User prompt:\n%s\n\nTemplates:\n%s",
|
|
strings.TrimSpace(prompt),
|
|
strings.Join(candidateLines, "\n"),
|
|
))
|
|
|
|
toolChoice := fantasy.ToolChoiceNone
|
|
response, err := model.Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleSystem,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{
|
|
Text: "Select exactly one template for workspace creation. " +
|
|
"Respond only with JSON: {\"template_id\":\"<uuid>\",\"reason\":\"<short reason>\"}. " +
|
|
"Use only candidate IDs listed in the prompt.",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: modelPrompt},
|
|
},
|
|
},
|
|
},
|
|
ToolChoice: &toolChoice,
|
|
})
|
|
if err != nil {
|
|
return database.Template{}, newTemplateSelectionReasonError("multiple templates matched and model disambiguation failed")
|
|
}
|
|
|
|
selectionID, ok := parseTemplateSelection(response.Content, candidates)
|
|
if !ok {
|
|
return database.Template{}, newTemplateSelectionReasonError("multiple templates matched and model response was ambiguous")
|
|
}
|
|
|
|
for i := range candidates {
|
|
if candidates[i].ID == selectionID {
|
|
return candidates[i], nil
|
|
}
|
|
}
|
|
|
|
return database.Template{}, newTemplateSelectionReasonError("model selected an unknown template")
|
|
}
|
|
|
|
func parseTemplateSelection(content []fantasy.Content, candidates []database.Template) (uuid.UUID, bool) {
|
|
text := extractResponseText(content)
|
|
if text == "" {
|
|
return uuid.Nil, false
|
|
}
|
|
|
|
candidateByID := make(map[uuid.UUID]struct{}, len(candidates))
|
|
for i := range candidates {
|
|
candidateByID[candidates[i].ID] = struct{}{}
|
|
}
|
|
|
|
tryIDs := make([]string, 0, 2)
|
|
if id := parseSelectionIDFromJSON(text); id != "" {
|
|
tryIDs = append(tryIDs, id)
|
|
}
|
|
|
|
uuidMatches := templateSelectionUUIDPattern.FindAllString(strings.ToLower(text), -1)
|
|
tryIDs = append(tryIDs, uuidMatches...)
|
|
|
|
for _, rawID := range tryIDs {
|
|
parsedID, err := uuid.Parse(strings.TrimSpace(rawID))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, ok := candidateByID[parsedID]; ok {
|
|
return parsedID, true
|
|
}
|
|
}
|
|
|
|
// Last resort: if the model only referenced one candidate by name, accept it.
|
|
lower := strings.ToLower(text)
|
|
nameMatches := make([]uuid.UUID, 0, len(candidates))
|
|
for i := range candidates {
|
|
if strings.Contains(lower, strings.ToLower(candidates[i].Name)) {
|
|
nameMatches = append(nameMatches, candidates[i].ID)
|
|
continue
|
|
}
|
|
display := strings.TrimSpace(candidates[i].DisplayName)
|
|
if display != "" && strings.Contains(lower, strings.ToLower(display)) {
|
|
nameMatches = append(nameMatches, candidates[i].ID)
|
|
}
|
|
}
|
|
if len(nameMatches) == 1 {
|
|
return nameMatches[0], true
|
|
}
|
|
|
|
return uuid.Nil, false
|
|
}
|
|
|
|
func parseSelectionIDFromJSON(text string) string {
|
|
parse := func(raw string) string {
|
|
var payload struct {
|
|
TemplateID string `json:"template_id"`
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
|
return ""
|
|
}
|
|
if strings.TrimSpace(payload.TemplateID) != "" {
|
|
return strings.TrimSpace(payload.TemplateID)
|
|
}
|
|
return strings.TrimSpace(payload.ID)
|
|
}
|
|
|
|
if id := parse(text); id != "" {
|
|
return id
|
|
}
|
|
|
|
start := strings.Index(text, "{")
|
|
end := strings.LastIndex(text, "}")
|
|
if start >= 0 && end > start {
|
|
return parse(text[start : end+1])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func extractResponseText(content []fantasy.Content) string {
|
|
var builder strings.Builder
|
|
for _, block := range content {
|
|
if payload, ok := fantasy.AsContentType[fantasy.TextContent](block); ok {
|
|
if strings.TrimSpace(payload.Text) == "" {
|
|
continue
|
|
}
|
|
if builder.Len() > 0 {
|
|
builder.WriteString("\n")
|
|
}
|
|
builder.WriteString(payload.Text)
|
|
continue
|
|
}
|
|
if payload, ok := fantasy.AsContentType[fantasy.ReasoningContent](block); ok {
|
|
if strings.TrimSpace(payload.Text) == "" {
|
|
continue
|
|
}
|
|
if builder.Len() > 0 {
|
|
builder.WriteString("\n")
|
|
}
|
|
builder.WriteString(payload.Text)
|
|
}
|
|
}
|
|
return strings.TrimSpace(builder.String())
|
|
}
|
|
|
|
func (c *workspaceCreator) streamWorkspaceBuildLogs(
|
|
ctx context.Context,
|
|
jobID uuid.UUID,
|
|
emit CreateWorkspaceBuildLogHandler,
|
|
) {
|
|
if c == nil || emit == nil || jobID == uuid.Nil || c.pubsub == nil || c.db == nil {
|
|
return
|
|
}
|
|
|
|
streamCtx, cancel := context.WithTimeout(ctx, chatWorkspaceBuildLogStreamTimeout)
|
|
defer cancel()
|
|
|
|
after := int64(0)
|
|
streamLogs := func() error {
|
|
logs, err := c.db.GetProvisionerLogsAfterID(streamCtx, database.GetProvisionerLogsAfterIDParams{
|
|
JobID: jobID,
|
|
CreatedAfter: after,
|
|
})
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return xerrors.Errorf("get provisioner logs: %w", err)
|
|
}
|
|
for _, log := range logs {
|
|
emit(CreateWorkspaceBuildLog{
|
|
Source: string(log.Source),
|
|
Level: string(log.Level),
|
|
Stage: log.Stage,
|
|
Output: log.Output,
|
|
})
|
|
after = log.ID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := streamLogs(); err != nil {
|
|
c.logger.Debug(ctx, "failed to stream initial workspace build logs",
|
|
slog.F("job_id", jobID),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
|
|
complete, err := c.isProvisionerJobComplete(streamCtx, jobID)
|
|
if err != nil {
|
|
c.logger.Debug(ctx, "failed to check workspace build status",
|
|
slog.F("job_id", jobID),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
if complete {
|
|
return
|
|
}
|
|
|
|
notifications := make(chan provisionersdk.ProvisionerJobLogsNotifyMessage, 64)
|
|
notifyErrors := make(chan error, 1)
|
|
subCancel, err := c.pubsub.SubscribeWithErr(
|
|
provisionersdk.ProvisionerJobLogsNotifyChannel(jobID),
|
|
func(_ context.Context, message []byte, subErr error) {
|
|
if subErr != nil {
|
|
select {
|
|
case <-streamCtx.Done():
|
|
case notifyErrors <- subErr:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
|
|
var notification provisionersdk.ProvisionerJobLogsNotifyMessage
|
|
if err := json.Unmarshal(message, ¬ification); err != nil {
|
|
select {
|
|
case <-streamCtx.Done():
|
|
case notifyErrors <- err:
|
|
default:
|
|
}
|
|
return
|
|
}
|
|
|
|
select {
|
|
case <-streamCtx.Done():
|
|
case notifications <- notification:
|
|
default:
|
|
}
|
|
},
|
|
)
|
|
if err != nil {
|
|
c.logger.Debug(ctx, "failed to subscribe to workspace build logs",
|
|
slog.F("job_id", jobID),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
defer subCancel()
|
|
|
|
// Re-query logs after subscribing to avoid dropping events between the
|
|
// initial query and pubsub registration.
|
|
if err := streamLogs(); err != nil {
|
|
c.logger.Debug(ctx, "failed to stream workspace build logs after subscribe",
|
|
slog.F("job_id", jobID),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
|
|
complete, err = c.isProvisionerJobComplete(streamCtx, jobID)
|
|
if err != nil {
|
|
c.logger.Debug(ctx, "failed to check workspace build status after subscribe",
|
|
slog.F("job_id", jobID),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
if complete {
|
|
return
|
|
}
|
|
|
|
pollTicker := time.NewTicker(chatWorkspaceBuildLogPollInterval)
|
|
defer pollTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-streamCtx.Done():
|
|
return
|
|
case subErr := <-notifyErrors:
|
|
c.logger.Debug(ctx, "workspace build log subscription ended with error",
|
|
slog.F("job_id", jobID),
|
|
slog.Error(subErr),
|
|
)
|
|
return
|
|
case notification := <-notifications:
|
|
if err := streamLogs(); err != nil {
|
|
c.logger.Debug(ctx, "failed to stream workspace build logs from notification",
|
|
slog.F("job_id", jobID),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
if notification.EndOfLogs {
|
|
return
|
|
}
|
|
case <-pollTicker.C:
|
|
complete, err := c.isProvisionerJobComplete(streamCtx, jobID)
|
|
if err != nil {
|
|
c.logger.Debug(ctx, "failed to poll workspace build status",
|
|
slog.F("job_id", jobID),
|
|
slog.Error(err),
|
|
)
|
|
return
|
|
}
|
|
if !complete {
|
|
continue
|
|
}
|
|
if err := streamLogs(); err != nil {
|
|
c.logger.Debug(ctx, "failed to stream final workspace build logs",
|
|
slog.F("job_id", jobID),
|
|
slog.Error(err),
|
|
)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *workspaceCreator) isProvisionerJobComplete(ctx context.Context, jobID uuid.UUID) (bool, error) {
|
|
job, err := c.db.GetProvisionerJobByID(ctx, jobID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return true, nil
|
|
}
|
|
return false, xerrors.Errorf("get provisioner job: %w", err)
|
|
}
|
|
|
|
switch job.JobStatus {
|
|
case database.ProvisionerJobStatusCanceled,
|
|
database.ProvisionerJobStatusFailed,
|
|
database.ProvisionerJobStatusSucceeded:
|
|
return true, nil
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
func (c *workspaceCreator) resolveTemplateSelection(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
req CreateWorkspaceToolRequest,
|
|
spec WorkspaceSpec,
|
|
createReq *codersdk.CreateWorkspaceRequest,
|
|
) (database.Template, error) {
|
|
var selectedTemplate database.Template
|
|
|
|
templateID := createReq.TemplateID
|
|
if templateID == uuid.Nil {
|
|
templateID = spec.TemplateID
|
|
}
|
|
|
|
templateVersionID := createReq.TemplateVersionID
|
|
if templateVersionID == uuid.Nil {
|
|
templateVersionID = spec.TemplateVersionID
|
|
}
|
|
|
|
templateRef := strings.TrimSpace(spec.TemplateRef)
|
|
templateVersionRef := strings.TrimSpace(spec.TemplateVersionRef)
|
|
|
|
if templateVersionID == uuid.Nil && templateVersionRef != "" {
|
|
if parsedID, err := uuid.Parse(templateVersionRef); err == nil {
|
|
templateVersionID = parsedID
|
|
templateVersionRef = ""
|
|
}
|
|
}
|
|
|
|
if templateVersionID != uuid.Nil {
|
|
templateVersion, err := c.db.GetTemplateVersionByID(ctx, templateVersionID)
|
|
if err != nil {
|
|
return database.Template{}, newTemplateSelectionReasonError("template version was not found or is not accessible")
|
|
}
|
|
selectedTemplate, err = c.db.GetTemplateByID(ctx, templateVersion.TemplateID.UUID)
|
|
if err != nil {
|
|
return database.Template{}, xerrors.Errorf("get template for template version: %w", err)
|
|
}
|
|
if templateID != uuid.Nil && templateID != selectedTemplate.ID {
|
|
return database.Template{}, newTemplateSelectionReasonError("template_id does not match template_version_id")
|
|
}
|
|
if templateRef != "" && !TemplateMatchesReference(selectedTemplate, templateRef) {
|
|
return database.Template{}, newTemplateSelectionReasonError("template does not match template_version")
|
|
}
|
|
|
|
createReq.TemplateVersionID = templateVersionID
|
|
createReq.TemplateID = uuid.Nil
|
|
return selectedTemplate, nil
|
|
}
|
|
|
|
switch {
|
|
case templateID != uuid.Nil:
|
|
template, err := c.db.GetTemplateByID(ctx, templateID)
|
|
if err != nil {
|
|
return database.Template{}, newTemplateSelectionReasonError("template was not found or is not accessible")
|
|
}
|
|
selectedTemplate = template
|
|
case templateRef != "":
|
|
template, err := c.resolveTemplateReference(ctx, r, templateRef)
|
|
if err != nil {
|
|
return database.Template{}, err
|
|
}
|
|
selectedTemplate = template
|
|
default:
|
|
candidates, err := c.searchPromptTemplates(ctx, r, req.Prompt)
|
|
if err != nil {
|
|
return database.Template{}, xerrors.Errorf("search templates for prompt: %w", err)
|
|
}
|
|
switch len(candidates) {
|
|
case 0:
|
|
return database.Template{}, newTemplateSelectionReasonError("no authorized templates matched the prompt")
|
|
case 1:
|
|
selectedTemplate = candidates[0]
|
|
default:
|
|
if req.Model == nil {
|
|
return database.Template{}, newTemplateSelectionReasonError("multiple templates matched and no model is available to disambiguate")
|
|
}
|
|
template, err := selectTemplateWithModel(ctx, req.Model, req.Prompt, candidates)
|
|
if err != nil {
|
|
return database.Template{}, err
|
|
}
|
|
selectedTemplate = template
|
|
}
|
|
}
|
|
|
|
if templateVersionRef != "" {
|
|
templateVersion, err := c.db.GetTemplateVersionByTemplateIDAndName(ctx, database.GetTemplateVersionByTemplateIDAndNameParams{
|
|
TemplateID: uuid.NullUUID{
|
|
UUID: selectedTemplate.ID,
|
|
Valid: true,
|
|
},
|
|
Name: templateVersionRef,
|
|
})
|
|
if err != nil {
|
|
return database.Template{}, newTemplateSelectionReasonError("template version was not found for the selected template")
|
|
}
|
|
createReq.TemplateVersionID = templateVersion.ID
|
|
createReq.TemplateID = uuid.Nil
|
|
return selectedTemplate, nil
|
|
}
|
|
|
|
createReq.TemplateID = selectedTemplate.ID
|
|
createReq.TemplateVersionID = uuid.Nil
|
|
return selectedTemplate, nil
|
|
}
|
|
|
|
func (c *workspaceCreator) resolveTemplateReference(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
templateRef string,
|
|
) (database.Template, error) {
|
|
templateRef = strings.TrimSpace(templateRef)
|
|
if templateRef == "" {
|
|
return database.Template{}, newTemplateSelectionReasonError("template reference is empty")
|
|
}
|
|
|
|
if parsedID, err := uuid.Parse(templateRef); err == nil {
|
|
template, err := c.db.GetTemplateByID(ctx, parsedID)
|
|
if err != nil {
|
|
return database.Template{}, newTemplateSelectionReasonError("template was not found or is not accessible")
|
|
}
|
|
return template, nil
|
|
}
|
|
|
|
templates, err := c.authorizedTemplates(ctx, r)
|
|
if err != nil {
|
|
return database.Template{}, err
|
|
}
|
|
|
|
matches := make([]database.Template, 0, len(templates))
|
|
for _, template := range templates {
|
|
if TemplateMatchesReference(template, templateRef) {
|
|
matches = append(matches, template)
|
|
}
|
|
}
|
|
|
|
switch len(matches) {
|
|
case 0:
|
|
return database.Template{}, newTemplateSelectionReasonError(fmt.Sprintf("no authorized template matched %q", templateRef))
|
|
case 1:
|
|
return matches[0], nil
|
|
default:
|
|
return database.Template{}, newTemplateSelectionReasonError(fmt.Sprintf("multiple templates matched %q; provide template_id", templateRef))
|
|
}
|
|
}
|
|
|
|
func (c *workspaceCreator) searchPromptTemplates(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
prompt string,
|
|
) ([]database.Template, error) {
|
|
templates, err := c.authorizedTemplates(ctx, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return SearchPromptTemplates(prompt, templates, 20), nil
|
|
}
|
|
|
|
func (c *workspaceCreator) authorizedTemplates(
|
|
ctx context.Context,
|
|
r *http.Request,
|
|
) ([]database.Template, error) {
|
|
if c.authorizedTemplatesFn == nil {
|
|
return nil, xerrors.New("chat workspace creator templates callback is not configured")
|
|
}
|
|
|
|
templates, err := c.authorizedTemplatesFn(ctx, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(templates) == 0 {
|
|
return []database.Template{}, nil
|
|
}
|
|
return templates, nil
|
|
}
|
|
|
|
func generatedWorkspaceName(seed string) string {
|
|
base := codersdk.UsernameFrom(strings.TrimSpace(strings.ToLower(seed)))
|
|
if strings.TrimSpace(base) == "" {
|
|
base = "workspace"
|
|
}
|
|
|
|
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:4]
|
|
if len(base) > 27 {
|
|
base = strings.Trim(base[:27], "-")
|
|
}
|
|
if base == "" {
|
|
base = "workspace"
|
|
}
|
|
|
|
name := fmt.Sprintf("%s-%s", base, suffix)
|
|
if err := codersdk.NameValid(name); err == nil {
|
|
return name
|
|
}
|
|
return namesgenerator.NameDigitWith("-")
|
|
}
|
|
|
|
func scoreTemplateAgainstPrompt(prompt string, tokens []string, template database.Template) int {
|
|
name := strings.ToLower(template.Name)
|
|
display := strings.ToLower(strings.TrimSpace(template.DisplayName))
|
|
description := strings.ToLower(strings.TrimSpace(template.Description))
|
|
|
|
score := 0
|
|
if strings.Contains(prompt, name) {
|
|
score += 6
|
|
}
|
|
if display != "" && strings.Contains(prompt, display) {
|
|
score += 5
|
|
}
|
|
|
|
for _, token := range tokens {
|
|
if len(token) < 3 {
|
|
continue
|
|
}
|
|
switch {
|
|
case strings.Contains(name, token):
|
|
score += 3
|
|
case display != "" && strings.Contains(display, token):
|
|
score += 2
|
|
case description != "" && strings.Contains(description, token):
|
|
score++
|
|
}
|
|
}
|
|
|
|
return score
|
|
}
|
|
|
|
func parseTemplateReference(value any) (uuid.UUID, string, bool) {
|
|
switch raw := value.(type) {
|
|
case nil:
|
|
return uuid.Nil, "", false
|
|
case string:
|
|
text := strings.TrimSpace(raw)
|
|
if text == "" {
|
|
return uuid.Nil, "", false
|
|
}
|
|
if parsedID, err := uuid.Parse(text); err == nil {
|
|
return parsedID, "", true
|
|
}
|
|
return uuid.Nil, text, true
|
|
case map[string]any:
|
|
idValue, _ := findValue(raw, "id", "template_id", "template_version_id")
|
|
nameValue, _ := findValue(raw, "name", "display_name", "template_name", "template_version_name")
|
|
|
|
parsedID := parseUUIDValue(idValue)
|
|
name := strings.TrimSpace(toString(nameValue))
|
|
return parsedID, name, parsedID != uuid.Nil || name != ""
|
|
default:
|
|
text := strings.TrimSpace(toString(value))
|
|
if text == "" {
|
|
return uuid.Nil, "", false
|
|
}
|
|
if parsedID, err := uuid.Parse(text); err == nil {
|
|
return parsedID, "", true
|
|
}
|
|
return uuid.Nil, text, true
|
|
}
|
|
}
|
|
|
|
func parseUUIDValue(value any) uuid.UUID {
|
|
text := strings.TrimSpace(toString(value))
|
|
if text == "" {
|
|
return uuid.Nil
|
|
}
|
|
parsedID, err := uuid.Parse(text)
|
|
if err != nil {
|
|
return uuid.Nil
|
|
}
|
|
return parsedID
|
|
}
|
|
|
|
func findValue(values map[string]any, keys ...string) (any, bool) {
|
|
if len(values) == 0 {
|
|
return nil, false
|
|
}
|
|
|
|
targets := make(map[string]struct{}, len(keys))
|
|
for _, key := range keys {
|
|
targets[normalizeKey(key)] = struct{}{}
|
|
}
|
|
|
|
for key, value := range values {
|
|
if _, ok := targets[normalizeKey(key)]; ok {
|
|
return value, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func normalizeKey(key string) string {
|
|
key = strings.TrimSpace(strings.ToLower(key))
|
|
key = strings.ReplaceAll(key, "_", "")
|
|
key = strings.ReplaceAll(key, "-", "")
|
|
return key
|
|
}
|
|
|
|
func toString(value any) string {
|
|
switch raw := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case string:
|
|
return raw
|
|
case json.Number:
|
|
return raw.String()
|
|
default:
|
|
return fmt.Sprintf("%v", value)
|
|
}
|
|
}
|