package config import ( "fmt" "sort" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" ) const ( partyCharactersFamilySource = "party.characters" characterIDToken = "{character_id}" memberArtifactSourcePrefix = "narratio.member_artifact." ) 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) if err := rejectConcreteMemberArtifactSources(expanded); err != nil { return err } 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) } } if err := validateFamilyMemberDependencies(families, familyKeys); err != nil { return err } 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 } dependencies, err := expandedFamilyDependencies(prefix, family, character.ID) if err != nil { return err } inputs, err := expandFamilyInputs(prefix, family, character.ID) if err != nil { return err } expanded[key] = ScriptoriumArtifactConfig{ Enabled: family.Enabled, DependsOn: dependencies, RenderDebug: family.RenderDebug, PromptID: family.PromptID, ProfileID: family.ProfileID, OutputPath: outputPath, Timeout: family.Timeout, Inputs: inputs, Vars: vars, } outputOwners[outputPath] = key familyOrigin.Members = append(familyOrigin.Members, key) catalog.Members[key] = ArtifactFamilyMemberOrigin{Family: familyKey, CharacterID: character.ID, Source: familyOrigin.Source, Dependencies: append([]string(nil), dependencies...), Inputs: inputSourceMap(inputs)} } catalog.Families[familyKey] = familyOrigin } cfg.Scriptorium.Artifacts = expanded if err := expandFamilyPublishRules(cfg, catalog); err != nil { return err } cfg.Scriptorium.ArtifactFamilies = nil cfg.familyCatalog = catalog cfg.resolution.artifactFamiliesExpanded = true if err := rejectConcreteMemberArtifactSources(expanded); err != nil { return err } if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil { return err } if err := validatePublish(cfg.Publish, 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 expandFamilyPublishRules(cfg *PipelineConfig, catalog ArtifactFamilyCatalog) error { if cfg == nil || cfg.Scriptorium == nil { return nil } if cfg.Publish == nil { for familyKey, family := range catalog.Families { if family.Publish != nil && family.Publish.Enabled { return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish requires pipeline.publish", familyKey) } } return nil } generated := make([]PublishOutputRule, 0) explicitSources := make(map[string]struct{}, len(cfg.Publish.Outputs)) for _, rule := range cfg.Publish.Outputs { explicitSources[strings.TrimSpace(rule.Source)] = struct{}{} } for familyKey, family := range catalog.Families { if family.Publish == nil || !family.Publish.Enabled { continue } if cfg.resolution == nil || !cfg.resolution.publishDeclared { return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish requires pipeline.publish", familyKey) } pattern := strings.TrimSpace(family.Publish.DestPattern) if pattern != "" { if err := validateFamilyOutputPattern("pipeline.scriptorium.artifact_families."+familyKey+".publish.dest_pattern", pattern); err != nil { return err } } for _, key := range family.Members { if _, conflict := explicitSources[artifactpolicy.ConfiguredSourceID(key)]; conflict { return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish conflicts with explicit publish source %q", familyKey, artifactpolicy.ConfiguredSourceID(key)) } origin := catalog.Members[key] dest := cfg.Scriptorium.Artifacts[key].OutputPath if pattern != "" { dest = strings.ReplaceAll(pattern, characterIDToken, origin.CharacterID) } required := family.Publish.Required generated = append(generated, PublishOutputRule{Source: artifactpolicy.ConfiguredSourceID(key), Dest: dest, Required: &required}) } } if len(generated) == 0 { return nil } cfg.Publish.Outputs = append(cfg.Publish.Outputs, generated...) sort.Slice(cfg.Publish.Outputs, func(i, j int) bool { if cfg.Publish.Outputs[i].Dest == cfg.Publish.Outputs[j].Dest { return cfg.Publish.Outputs[i].Source < cfg.Publish.Outputs[j].Source } return cfg.Publish.Outputs[i].Dest < cfg.Publish.Outputs[j].Dest }) return nil } func validateFamilyMemberDependencies(families map[string]ScriptoriumArtifactFamilyConfig, familyKeys []string) error { for _, familyKey := range familyKeys { family := families[familyKey] seen := make(map[string]struct{}, len(family.MemberDependencies)) for index, raw := range family.MemberDependencies { dependency := strings.TrimSpace(raw) prefix := fmt.Sprintf("pipeline.scriptorium.artifact_families.%s.member_dependencies[%d]", familyKey, index) if !artifactpolicy.IsConfiguredKey(dependency) { return fmt.Errorf("%s must be a valid family key", prefix) } if dependency == familyKey { return fmt.Errorf("%s must not reference its own family", prefix) } if _, duplicate := seen[dependency]; duplicate { return fmt.Errorf("%s duplicates family %q", prefix, dependency) } seen[dependency] = struct{}{} dependencyFamily, exists := families[dependency] if !exists { return fmt.Errorf("%s references unknown family %q", prefix, dependency) } if strings.TrimSpace(dependencyFamily.ForEach) != partyCharactersFamilySource { return fmt.Errorf("%s family %q must use %q", prefix, dependency, partyCharactersFamilySource) } } } return nil } func expandedFamilyDependencies(prefix string, family ScriptoriumArtifactFamilyConfig, characterID string) ([]string, error) { dependencies := append([]string(nil), family.DependsOn...) memberDependencies := append([]string(nil), family.MemberDependencies...) sort.Strings(memberDependencies) for _, familyKey := range memberDependencies { dependencies = append(dependencies, familyKey+"_"+characterID) } return normalizedFamilyDependencies(prefix, dependencies), nil } func normalizedFamilyDependencies(_ string, dependencies []string) []string { seen := make(map[string]struct{}, len(dependencies)) normalized := make([]string, 0, len(dependencies)) for _, dependency := range dependencies { dependency = strings.TrimSpace(dependency) if _, exists := seen[dependency]; exists { continue } seen[dependency] = struct{}{} normalized = append(normalized, dependency) } return normalized } func expandFamilyInputs(prefix string, family ScriptoriumArtifactFamilyConfig, characterID string) (map[string]ScriptoriumInputConfig, error) { inputs := cloneArtifactInputs(family.Inputs) for name, input := range inputs { familyKey, matched, err := parseMemberArtifactSource(input.Source) if err != nil { return nil, fmt.Errorf("%s.inputs.%s.source: %w", prefix, name, err) } if !matched { continue } if !familyDependencyContains(family.MemberDependencies, familyKey) { return nil, fmt.Errorf("%s.inputs.%s.source %q requires member_dependencies entry %q", prefix, name, input.Source, familyKey) } input.Source = artifactpolicy.ConfiguredSourceID(familyKey + "_" + characterID) inputs[name] = input } return inputs, nil } func parseMemberArtifactSource(source string) (string, bool, error) { trimmed := strings.TrimSpace(source) if !strings.HasPrefix(trimmed, "narratio.member_artifact") { return "", false, nil } if !strings.HasPrefix(trimmed, memberArtifactSourcePrefix) { return "", true, fmt.Errorf("malformed member artifact source %q", source) } family := strings.TrimPrefix(trimmed, memberArtifactSourcePrefix) if !artifactpolicy.IsConfiguredKey(family) { return "", true, fmt.Errorf("malformed member artifact source %q", source) } return family, true, nil } func rejectConcreteMemberArtifactSources(artifacts map[string]ScriptoriumArtifactConfig) error { for artifactKey, artifact := range artifacts { for inputName, input := range artifact.Inputs { if _, matched, err := parseMemberArtifactSource(input.Source); matched { if err != nil { return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source: %w", artifactKey, inputName, err) } return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source uses member artifact syntax outside an artifact family", artifactKey, inputName) } } } return nil } func familyDependencyContains(values []string, want string) bool { for _, value := range values { if strings.TrimSpace(value) == want { return true } } return false } func inputSourceMap(inputs map[string]ScriptoriumInputConfig) map[string]string { if len(inputs) == 0 { return nil } out := make(map[string]string, len(inputs)) for name, input := range inputs { out[name] = input.Source } return out } 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 © } 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 { member.Dependencies = append([]string(nil), member.Dependencies...) if len(member.Inputs) > 0 { inputs := make(map[string]string, len(member.Inputs)) for name, source := range member.Inputs { inputs[name] = source } member.Inputs = inputs } out.Members[key] = member } return out }