Files
narratio/internal/config/artifact_families.go

255 lines
9.3 KiB
Go

package config
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
)
const (
partyCharactersFamilySource = "party.characters"
characterIDToken = "{character_id}"
)
var memberVariableSelectors = map[string]func(PartyCharacter) string{
"character_id": func(character PartyCharacter) string { return character.ID },
"player.name": func(character PartyCharacter) string { return character.Player.Name },
"character.name": func(character PartyCharacter) string { return character.Character.Name },
"character.class_summary": func(character PartyCharacter) string { return character.ClassSummary() },
"character.alias_summary": func(character PartyCharacter) string { return character.AliasSummary() },
}
func retainArtifactFamilyDeclarations(cfg *PipelineConfig) {
if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil {
return
}
cfg.resolution.artifactFamilies = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies)
}
func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) error {
if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil || cfg.resolution.artifactFamiliesExpanded {
return nil
}
families := cfg.resolution.artifactFamilies
if len(families) == 0 {
families = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies)
}
if len(families) == 0 {
return nil
}
if party.Mode != PartyModeCanonical || party.Canonical == nil {
return fmt.Errorf("pipeline.scriptorium.artifact_families requires a canonical campaign party")
}
expanded := cloneArtifactDefinitions(cfg.Scriptorium.Artifacts)
familyKeys := sortedFamilyKeys(families)
for _, familyKey := range familyKeys {
if !artifactpolicy.IsConfiguredKey(familyKey) {
return fmt.Errorf("pipeline.scriptorium.artifact_families keys must match ^[a-z][a-z0-9_]*$")
}
if _, exists := expanded[familyKey]; exists {
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s collides with configured artifact key %q", familyKey, familyKey)
}
}
characters := append([]PartyCharacter(nil), party.Canonical.Characters...)
sort.Slice(characters, func(left, right int) bool { return characters[left].ID < characters[right].ID })
catalog := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(familyKeys)), Members: map[string]ArtifactFamilyMemberOrigin{}}
outputOwners := make(map[string]string, len(expanded))
for key, artifact := range expanded {
if output := strings.TrimSpace(artifact.OutputPath); output != "" {
outputOwners[output] = key
}
}
for _, familyKey := range familyKeys {
family := families[familyKey]
prefix := "pipeline.scriptorium.artifact_families." + familyKey
if strings.TrimSpace(family.ForEach) != partyCharactersFamilySource {
return fmt.Errorf("%s.for_each must be %q", prefix, partyCharactersFamilySource)
}
if err := validateFamilyOutputPattern(prefix+".output_path_pattern", family.OutputPathPattern); err != nil {
return err
}
familyOrigin := ArtifactFamilyOrigin{
Members: make([]string, 0, len(characters)),
MemberDependencies: append([]string(nil), family.MemberDependencies...),
Publish: cloneArtifactFamilyPublish(family.Publish),
Source: artifactFamilySource(cfg, familyKey),
}
for _, character := range characters {
key := familyKey + "_" + character.ID
if !artifactpolicy.IsConfiguredKey(key) {
return fmt.Errorf("%s generates invalid artifact key %q", prefix, key)
}
if _, exists := expanded[key]; exists {
return fmt.Errorf("%s generates artifact key %q that collides with another artifact", prefix, key)
}
outputPath := strings.ReplaceAll(family.OutputPathPattern, characterIDToken, character.ID)
if prior, exists := outputOwners[outputPath]; exists {
return fmt.Errorf("%s.output_path_pattern generates output path %q already used by artifact %q", prefix, outputPath, prior)
}
vars, err := resolveFamilyVars(prefix, family, character)
if err != nil {
return err
}
expanded[key] = ScriptoriumArtifactConfig{
Enabled: family.Enabled, DependsOn: append([]string(nil), family.DependsOn...), RenderDebug: family.RenderDebug,
PromptID: family.PromptID, ProfileID: family.ProfileID, OutputPath: outputPath, Timeout: family.Timeout,
Inputs: cloneArtifactInputs(family.Inputs), Vars: vars,
}
outputOwners[outputPath] = key
familyOrigin.Members = append(familyOrigin.Members, key)
catalog.Members[key] = ArtifactFamilyMemberOrigin{Family: familyKey, CharacterID: character.ID, Source: familyOrigin.Source}
}
catalog.Families[familyKey] = familyOrigin
}
cfg.Scriptorium.Artifacts = expanded
cfg.Scriptorium.ArtifactFamilies = nil
cfg.familyCatalog = catalog
cfg.resolution.artifactFamiliesExpanded = true
if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil {
return err
}
if err := recomputePipelineEffectiveDigest(cfg); err != nil {
return fmt.Errorf("refresh expanded pipeline digest: %w", err)
}
return nil
}
func validateFamilyOutputPattern(field, pattern string) error {
if strings.Count(pattern, characterIDToken) != 1 {
return fmt.Errorf("%s must contain exactly one %s token", field, characterIDToken)
}
remainder := strings.ReplaceAll(pattern, characterIDToken, "")
if strings.ContainsAny(remainder, "{}") {
return fmt.Errorf("%s contains unsupported brace syntax", field)
}
return nil
}
func resolveFamilyVars(prefix string, family ScriptoriumArtifactFamilyConfig, character PartyCharacter) (map[string]any, error) {
vars := make(map[string]any, len(family.Vars)+len(family.MemberVars))
for name, value := range family.Vars {
vars[name] = value
}
for name, selector := range family.MemberVars {
if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("%s.member_vars keys must be non-empty", prefix)
}
if _, exists := vars[name]; exists {
return nil, fmt.Errorf("%s.member_vars.%s conflicts with static vars", prefix, name)
}
resolve, ok := memberVariableSelectors[strings.TrimSpace(selector)]
if !ok {
return nil, fmt.Errorf("%s.member_vars.%s selector %q is unsupported", prefix, name, selector)
}
vars[name] = resolve(character)
}
return vars, nil
}
func sortedFamilyKeys(families map[string]ScriptoriumArtifactFamilyConfig) []string {
keys := make([]string, 0, len(families))
for key := range families {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func artifactFamilySource(cfg *PipelineConfig, family string) string {
if cfg == nil || cfg.resolution == nil {
return ""
}
prefix := "scriptorium.artifact_families." + family + "."
for _, ownership := range cfg.resolution.ownership {
if strings.HasPrefix(ownership.path, prefix) && len(ownership.sources) > 0 {
return ownership.sources[0]
}
}
return pipelineDefaultOwnershipSource
}
func cloneArtifactDefinitions(in map[string]ScriptoriumArtifactConfig) map[string]ScriptoriumArtifactConfig {
if len(in) == 0 {
return map[string]ScriptoriumArtifactConfig{}
}
out := make(map[string]ScriptoriumArtifactConfig, len(in))
for key, artifact := range in {
artifact.DependsOn = append([]string(nil), artifact.DependsOn...)
artifact.Inputs = cloneArtifactInputs(artifact.Inputs)
artifact.Vars = cloneArtifactVars(artifact.Vars)
out[key] = artifact
}
return out
}
func cloneArtifactInputs(in map[string]ScriptoriumInputConfig) map[string]ScriptoriumInputConfig {
if len(in) == 0 {
return nil
}
out := make(map[string]ScriptoriumInputConfig, len(in))
for key, input := range in {
out[key] = input
}
return out
}
func cloneArtifactVars(in map[string]any) map[string]any {
if len(in) == 0 {
return nil
}
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func cloneArtifactFamilyDefinitions(in map[string]ScriptoriumArtifactFamilyConfig) map[string]ScriptoriumArtifactFamilyConfig {
if len(in) == 0 {
return nil
}
out := make(map[string]ScriptoriumArtifactFamilyConfig, len(in))
for key, family := range in {
family.DependsOn = append([]string(nil), family.DependsOn...)
family.MemberDependencies = append([]string(nil), family.MemberDependencies...)
family.Inputs = cloneArtifactInputs(family.Inputs)
family.Vars = cloneArtifactVars(family.Vars)
if len(family.MemberVars) > 0 {
memberVars := make(map[string]string, len(family.MemberVars))
for name, selector := range family.MemberVars {
memberVars[name] = selector
}
family.MemberVars = memberVars
}
family.Publish = cloneArtifactFamilyPublish(family.Publish)
out[key] = family
}
return out
}
func cloneArtifactFamilyPublish(in *ScriptoriumArtifactFamilyPublishConfig) *ScriptoriumArtifactFamilyPublishConfig {
if in == nil {
return nil
}
copy := *in
return &copy
}
func cloneArtifactFamilyCatalog(in ArtifactFamilyCatalog) ArtifactFamilyCatalog {
out := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(in.Families)), Members: make(map[string]ArtifactFamilyMemberOrigin, len(in.Members))}
for key, family := range in.Families {
family.Members = append([]string(nil), family.Members...)
family.MemberDependencies = append([]string(nil), family.MemberDependencies...)
family.Publish = cloneArtifactFamilyPublish(family.Publish)
out.Families[key] = family
}
for key, member := range in.Members {
out.Members[key] = member
}
return out
}