Add new configuration fields and CLI flags for the upcoming analyze stage enhancements
This commit is contained in:
@@ -176,6 +176,7 @@ type ScriptoriumConfig struct {
|
||||
// ScriptoriumArtifactConfig configures one named output artifact workflow.
|
||||
type ScriptoriumArtifactConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
DependsOn []string `yaml:"depends_on"`
|
||||
RenderDebug *bool `yaml:"render_debug"`
|
||||
PromptID string `yaml:"prompt_id"`
|
||||
ProfileID string `yaml:"profile_id"`
|
||||
|
||||
@@ -30,8 +30,9 @@ const (
|
||||
DefaultAuditaTimeout = "3h"
|
||||
DefaultAuditaReport = true
|
||||
|
||||
DefaultScriptoriumBinary = "scriptorium"
|
||||
DefaultScriptoriumTimeout = "10m"
|
||||
DefaultScriptoriumBinary = "scriptorium"
|
||||
DefaultScriptoriumTimeout = "10m"
|
||||
DefaultScriptoriumArtifactOutputRoot = "artifacts"
|
||||
|
||||
DefaultTrimBoundsTimeout = "10m"
|
||||
DefaultTrimSeriatimReport = false
|
||||
|
||||
@@ -205,6 +205,195 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid artifact dependency is accepted",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
inputs:
|
||||
transcript:
|
||||
source: narratio.transcript.trimmed
|
||||
required: true
|
||||
player_handout:
|
||||
enabled: true
|
||||
depends_on:
|
||||
- session_recap
|
||||
prompt_id: dnd.player_handout
|
||||
output_path: artifacts/player_handout.md
|
||||
inputs:
|
||||
recap:
|
||||
source: narratio.artifact.session_recap
|
||||
required: true
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "valid dependency on disabled artifact with output path is accepted",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: false
|
||||
output_path: artifacts/session_recap.md
|
||||
player_handout:
|
||||
enabled: true
|
||||
depends_on:
|
||||
- session_recap
|
||||
prompt_id: dnd.player_handout
|
||||
output_path: artifacts/player_handout.md
|
||||
inputs:
|
||||
recap:
|
||||
source: narratio.artifact.session_recap
|
||||
required: true
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "invalid artifact name fails validation",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
SessionRecap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
`,
|
||||
wantValidateErr: "pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$",
|
||||
},
|
||||
{
|
||||
name: "artifact output path outside artifacts root fails validation",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: transcripts/session_recap.md
|
||||
`,
|
||||
wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.output_path must be under artifacts/",
|
||||
},
|
||||
{
|
||||
name: "missing depends_on for artifact source fails validation",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
inputs:
|
||||
transcript:
|
||||
source: narratio.transcript.polished
|
||||
required: true
|
||||
player_handout:
|
||||
enabled: true
|
||||
prompt_id: dnd.player_handout
|
||||
output_path: artifacts/player_handout.md
|
||||
inputs:
|
||||
recap:
|
||||
source: narratio.artifact.session_recap
|
||||
required: true
|
||||
`,
|
||||
wantValidateErr: `pipeline.scriptorium.artifacts.player_handout.inputs.recap.source "narratio.artifact.session_recap" requires depends_on entry "session_recap"`,
|
||||
},
|
||||
{
|
||||
name: "dependency on unknown artifact fails validation",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
player_handout:
|
||||
enabled: true
|
||||
depends_on:
|
||||
- session_recap
|
||||
prompt_id: dnd.player_handout
|
||||
output_path: artifacts/player_handout.md
|
||||
inputs:
|
||||
recap:
|
||||
source: narratio.artifact.session_recap
|
||||
required: true
|
||||
`,
|
||||
wantValidateErr: `pipeline.scriptorium.artifacts.player_handout.depends_on[0] "session_recap" is not a configured artifact key`,
|
||||
},
|
||||
{
|
||||
name: "self dependency fails validation",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: true
|
||||
depends_on:
|
||||
- session_recap
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
`,
|
||||
wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.depends_on must not include itself",
|
||||
},
|
||||
{
|
||||
name: "enabled dependency cycle fails validation",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
artifact_a:
|
||||
enabled: true
|
||||
depends_on:
|
||||
- artifact_b
|
||||
prompt_id: dnd.a
|
||||
output_path: artifacts/a.md
|
||||
inputs:
|
||||
b:
|
||||
source: narratio.artifact.artifact_b
|
||||
required: true
|
||||
artifact_b:
|
||||
enabled: true
|
||||
depends_on:
|
||||
- artifact_a
|
||||
prompt_id: dnd.b
|
||||
output_path: artifacts/b.md
|
||||
inputs:
|
||||
a:
|
||||
source: narratio.artifact.artifact_a
|
||||
required: true
|
||||
`,
|
||||
wantValidateErr: "pipeline.scriptorium.artifacts enabled dependencies must not contain cycles",
|
||||
},
|
||||
{
|
||||
name: "artifact source typo fails validation",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
player_handout:
|
||||
enabled: true
|
||||
prompt_id: dnd.player_handout
|
||||
output_path: artifacts/player_handout.md
|
||||
inputs:
|
||||
recap:
|
||||
source: narratio.artifact.session-recap
|
||||
required: true
|
||||
`,
|
||||
wantValidateErr: `pipeline.scriptorium.artifacts.player_handout.inputs.recap.source "narratio.artifact.session-recap" is unsupported`,
|
||||
},
|
||||
{
|
||||
name: "referenced disabled artifact missing output path fails validation",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: false
|
||||
player_handout:
|
||||
enabled: true
|
||||
depends_on:
|
||||
- session_recap
|
||||
prompt_id: dnd.player_handout
|
||||
output_path: artifacts/player_handout.md
|
||||
inputs:
|
||||
recap:
|
||||
source: narratio.artifact.session_recap
|
||||
required: true
|
||||
`,
|
||||
wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.output_path is required when artifact is referenced",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -258,6 +447,7 @@ audita:
|
||||
`
|
||||
|
||||
const testSessionBaseYAML = `session_id: 2026-05-03
|
||||
campaign: test-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user