Add new configuration fields and CLI flags for the upcoming analyze stage enhancements

This commit is contained in:
2026-05-19 18:36:30 -05:00
parent 8480b74283
commit c63ecbab32
13 changed files with 751 additions and 23 deletions

View File

@@ -324,20 +324,52 @@ func validateScriptorium(cfg *ScriptoriumConfig) error {
return err
}
for artifactName, artifactCfg := range cfg.Artifacts {
trimmedArtifactName := strings.TrimSpace(artifactName)
if trimmedArtifactName == "" {
return fmt.Errorf("pipeline.scriptorium.artifacts keys must be non-empty")
configuredArtifacts := make(map[string]struct{}, len(cfg.Artifacts))
referencedArtifacts := make(map[string]struct{})
for artifactName := range cfg.Artifacts {
if !scriptoriumArtifactKeyRE.MatchString(strings.TrimSpace(artifactName)) {
return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$")
}
configuredArtifacts[artifactName] = struct{}{}
}
for artifactName, artifactCfg := range cfg.Artifacts {
if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.PromptID) == "" {
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.prompt_id is required when enabled", artifactName)
}
if artifactCfg.Enabled && strings.TrimSpace(artifactCfg.OutputPath) == "" {
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is required when enabled", artifactName)
}
if strings.TrimSpace(artifactCfg.OutputPath) != "" {
pathField := "pipeline.scriptorium.artifacts." + artifactName + ".output_path"
if err := validateRelativeSafePath(pathField, artifactCfg.OutputPath); err != nil {
return err
}
if err := validatePathWithinRoot(pathField, artifactCfg.OutputPath, DefaultScriptoriumArtifactOutputRoot); err != nil {
return err
}
}
if err := validateDuration("pipeline.scriptorium.artifacts."+artifactName+".timeout", artifactCfg.Timeout); err != nil {
return err
}
depSet := make(map[string]struct{}, len(artifactCfg.DependsOn))
for i, depName := range artifactCfg.DependsOn {
trimmedDep := strings.TrimSpace(depName)
field := fmt.Sprintf("pipeline.scriptorium.artifacts.%s.depends_on[%d]", artifactName, i)
if trimmedDep == "" {
return fmt.Errorf("%s must be non-empty", field)
}
if _, ok := configuredArtifacts[trimmedDep]; !ok {
return fmt.Errorf("%s %q is not a configured artifact key", field, depName)
}
if trimmedDep == artifactName {
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on must not include itself", artifactName)
}
depSet[trimmedDep] = struct{}{}
referencedArtifacts[trimmedDep] = struct{}{}
}
for inputName, inputCfg := range artifactCfg.Inputs {
trimmedInputName := strings.TrimSpace(inputName)
if trimmedInputName == "" {
@@ -347,8 +379,22 @@ func validateScriptorium(cfg *ScriptoriumConfig) error {
if source == "" {
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source is required", artifactName, inputName)
}
if !isSupportedScriptoriumInputSource(source) {
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported", artifactName, inputName, inputCfg.Source)
referencedArtifact, err := validateScriptoriumInputSource(artifactName, inputName, source, configuredArtifacts)
if err != nil {
return err
}
if referencedArtifact != "" {
if _, ok := depSet[referencedArtifact]; !ok {
return fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q requires depends_on entry %q",
artifactName,
inputName,
source,
referencedArtifact,
)
}
referencedArtifacts[referencedArtifact] = struct{}{}
}
}
for varName, varValue := range artifactCfg.Vars {
@@ -363,6 +409,17 @@ func validateScriptorium(cfg *ScriptoriumConfig) error {
}
}
for artifactName := range referencedArtifacts {
artifactCfg := cfg.Artifacts[artifactName]
if strings.TrimSpace(artifactCfg.OutputPath) == "" {
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is required when artifact is referenced", artifactName)
}
}
if err := validateEnabledArtifactDependencyCycles(cfg.Artifacts); err != nil {
return err
}
return nil
}
@@ -439,7 +496,38 @@ func archiveUploadConfiguredForS3(pipeline *PipelineConfig) bool {
return enabled && upload
}
func isSupportedScriptoriumInputSource(source string) bool {
var windowsAbsPathRE = regexp.MustCompile(`^[A-Za-z]:[\\/].*`)
var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
var narratioArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) {
if isStaticSupportedScriptoriumInputSource(source) {
return "", nil
}
matches := narratioArtifactSourceRE.FindStringSubmatch(source)
if len(matches) != 2 {
return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
artifactName,
inputName,
source,
)
}
referenced := matches[1]
if _, ok := configuredArtifacts[referenced]; !ok {
return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
artifactName,
inputName,
source,
referenced,
)
}
return referenced, nil
}
func isStaticSupportedScriptoriumInputSource(source string) bool {
switch strings.TrimSpace(source) {
case "previous_session_artifact":
return true
@@ -453,16 +541,11 @@ func isSupportedScriptoriumInputSource(source string) bool {
return true
case "narratio.bounds.session":
return true
case "narratio.artifact.session_recap":
return true
default:
return false
}
}
var windowsAbsPathRE = regexp.MustCompile(`^[A-Za-z]:[\\/].*`)
var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
func validateEnvVarNameField(fieldName, value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
@@ -492,6 +575,73 @@ func validateRelativeSafePath(fieldName, value string) error {
return nil
}
func validatePathWithinRoot(fieldName, value, root string) error {
normalizedValue := filepath.ToSlash(filepath.Clean(strings.TrimSpace(value)))
normalizedRoot := filepath.ToSlash(filepath.Clean(strings.TrimSpace(root)))
if normalizedValue == normalizedRoot {
return nil
}
if strings.HasPrefix(normalizedValue, normalizedRoot+"/") {
return nil
}
return fmt.Errorf("%s must be under %s/", fieldName, normalizedRoot)
}
func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArtifactConfig) error {
if len(artifacts) == 0 {
return nil
}
enabled := make(map[string]struct{}, len(artifacts))
graph := make(map[string][]string, len(artifacts))
for name, cfg := range artifacts {
if !cfg.Enabled {
continue
}
enabled[name] = struct{}{}
}
for name, cfg := range artifacts {
if !cfg.Enabled {
continue
}
for _, dep := range cfg.DependsOn {
trimmedDep := strings.TrimSpace(dep)
if _, ok := enabled[trimmedDep]; ok {
graph[name] = append(graph[name], trimmedDep)
}
}
}
visiting := make(map[string]bool, len(enabled))
visited := make(map[string]bool, len(enabled))
var visit func(node string) error
visit = func(node string) error {
if visiting[node] {
return fmt.Errorf("pipeline.scriptorium.artifacts enabled dependencies must not contain cycles")
}
if visited[node] {
return nil
}
visiting[node] = true
for _, dep := range graph[node] {
if err := visit(dep); err != nil {
return err
}
}
visiting[node] = false
visited[node] = true
return nil
}
for node := range enabled {
if err := visit(node); err != nil {
return err
}
}
return nil
}
func validateDuration(fieldName, value string) error {
trimmed := strings.TrimSpace(value)
if trimmed == "" {