package runtimeconfig import ( "context" "encoding/json" "fmt" "sort" "inbox/internal/base/timeutil" "inbox/internal/domain/role" "inbox/internal/domain/skill" ) type Repository interface { GetRole(ctx context.Context, name string) (role.Definition, error) GetRoleConfig(ctx context.Context, roleName string) (role.Config, error) ListRolePrompts(ctx context.Context, roleName string) ([]role.Prompt, error) ListRoleSkillBindings(ctx context.Context, roleName string) ([]role.SkillBinding, error) } type SkillRepository interface { ListSkillsByIDs(ctx context.Context, ids []string) (map[string]skill.Definition, error) } type ResolvedSkill struct { Binding role.SkillBinding `json:"binding"` Skill skill.Definition `json:"skill"` } type ResolvedRole struct { Role role.Definition `json:"role"` WorkspaceID string `json:"workspace_id,omitempty"` Prompts map[role.PromptKind]role.Prompt `json:"prompts"` Config role.Config `json:"config"` Skills []ResolvedSkill `json:"skills"` ResolvedAt string `json:"resolved_at"` } type Service struct { repo Repository skills SkillRepository clock timeutil.Clock } func NewService(repo Repository, skills SkillRepository, clock timeutil.Clock) *Service { if clock == nil { clock = timeutil.SystemClock{} } return &Service{ repo: repo, skills: skills, clock: clock, } } func (s *Service) ResolveRole(ctx context.Context, workspaceID, roleName string) (ResolvedRole, error) { definition, err := s.repo.GetRole(ctx, roleName) if err != nil { return ResolvedRole{}, fmt.Errorf("get role %q: %w", roleName, err) } prompts, err := s.repo.ListRolePrompts(ctx, roleName) if err != nil { return ResolvedRole{}, fmt.Errorf("list role prompts for %q: %w", roleName, err) } bindings, err := s.repo.ListRoleSkillBindings(ctx, roleName) if err != nil { return ResolvedRole{}, fmt.Errorf("list role skill bindings for %q: %w", roleName, err) } resolved := ResolvedRole{ Role: definition, WorkspaceID: workspaceID, Prompts: pickPrompts(prompts, workspaceID), ResolvedAt: timeutil.FormatRFC3339(s.clock.Now()), } config, err := s.repo.GetRoleConfig(ctx, roleName) if err != nil { return ResolvedRole{}, fmt.Errorf("get role config for %q: %w", roleName, err) } resolved.Config = config resolved.Config.RoleName = roleName resolvedBindings := pickBindings(bindings, workspaceID) if len(resolvedBindings) == 0 { return resolved, nil } skillIDs := make([]string, 0, len(resolvedBindings)) for _, binding := range resolvedBindings { if binding.IsEnabled { skillIDs = append(skillIDs, binding.SkillID) } } skillsByID, err := s.skills.ListSkillsByIDs(ctx, skillIDs) if err != nil { return ResolvedRole{}, fmt.Errorf("list skills for %q: %w", roleName, err) } resolved.Skills = make([]ResolvedSkill, 0, len(skillIDs)) for _, binding := range resolvedBindings { if !binding.IsEnabled { continue } skillDef, ok := skillsByID[binding.SkillID] if !ok { return ResolvedRole{}, fmt.Errorf("missing skill %q for role %q", binding.SkillID, roleName) } resolved.Skills = append(resolved.Skills, ResolvedSkill{ Binding: binding, Skill: skillDef, }) } sort.Slice(resolved.Skills, func(i, j int) bool { if resolved.Skills[i].Binding.SortOrder == resolved.Skills[j].Binding.SortOrder { if resolved.Skills[i].Skill.Name == resolved.Skills[j].Skill.Name { return resolved.Skills[i].Skill.ID < resolved.Skills[j].Skill.ID } return resolved.Skills[i].Skill.Name < resolved.Skills[j].Skill.Name } return resolved.Skills[i].Binding.SortOrder < resolved.Skills[j].Binding.SortOrder }) return resolved, nil } func (r ResolvedRole) SnapshotJSON() (string, error) { data, err := json.Marshal(r) if err != nil { return "", fmt.Errorf("marshal runtime config snapshot: %w", err) } return string(data), nil } func pickPrompts(items []role.Prompt, workspaceID string) map[role.PromptKind]role.Prompt { out := make(map[role.PromptKind]role.Prompt) for _, item := range items { if item.WorkspaceID != "" && item.WorkspaceID != workspaceID { continue } current, exists := out[item.PromptKind] if !exists { out[item.PromptKind] = item continue } if current.WorkspaceID == "" && item.WorkspaceID == workspaceID { out[item.PromptKind] = item } } return out } func pickBindings(items []role.SkillBinding, workspaceID string) []role.SkillBinding { out := make(map[string]role.SkillBinding, len(items)) for _, item := range items { if item.WorkspaceID != "" && item.WorkspaceID != workspaceID { continue } current, exists := out[item.SkillID] if !exists { out[item.SkillID] = item continue } if current.WorkspaceID == "" && item.WorkspaceID == workspaceID { out[item.SkillID] = item } } result := make([]role.SkillBinding, 0, len(out)) for _, item := range out { result = append(result, item) } return result }