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

@@ -0,0 +1,66 @@
package app
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
type artifactSelectionFlag struct {
values []string
}
func (f *artifactSelectionFlag) String() string {
return strings.Join(f.values, ",")
}
func (f *artifactSelectionFlag) Set(value string) error {
f.values = append(f.values, value)
return nil
}
func (f *artifactSelectionFlag) Normalize() ([]string, error) {
if len(f.values) == 0 {
return nil, nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(f.values))
for _, raw := range f.values {
for _, part := range strings.Split(raw, ",") {
name := strings.TrimSpace(part)
if name == "" {
return nil, fmt.Errorf("artifact names must be non-empty")
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
}
sort.Strings(out)
return out, nil
}
func validateSelectedAnalyzeArtifacts(cfg *config.Config, selected []string) error {
if len(selected) == 0 {
return nil
}
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Scriptorium == nil {
return fmt.Errorf("--artifacts requires pipeline.scriptorium.artifacts to be configured")
}
configured := cfg.Pipeline.Scriptorium.Artifacts
if len(configured) == 0 {
return fmt.Errorf("--artifacts requires at least one configured artifact in pipeline.scriptorium.artifacts")
}
for _, name := range selected {
if _, ok := configured[name]; !ok {
return fmt.Errorf("--artifacts includes unknown artifact %q", name)
}
}
return nil
}

View File

@@ -0,0 +1,112 @@
package app
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestExecuteRunStageArtifactsNonAnalyzeFails(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "--artifacts", "session_recap", "polish"},
&stdout,
&stderr,
)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), `run-stage: --artifacts is only supported for stage "analyze"`) {
t.Fatalf("stderr = %q, want stage-gating error", stderr.String())
}
}
func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{"run", "--config", pipelinePath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
&stdout,
&stderr,
)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), `run: --artifacts includes unknown artifact "unknown_artifact"`) {
t.Fatalf("stderr = %q, want unknown-artifact validation error", stderr.String())
}
}
func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{}
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
seed.MarkStageSucceeded("analyze", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
t.Fatalf("save manifest: %v", err)
}
var out bytes.Buffer
err := RunStage(
context.Background(),
[]string{"--config", pipelinePath, "--session", sessionPath, "--artifacts", "session_recap,session_recap", "analyze"},
&out,
)
if err != nil {
t.Fatalf("RunStage() error = %v", err)
}
if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") {
t.Fatalf("output = %q, want analyze skip without force", out.String())
}
}
func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string) {
t.Helper()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
f, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatalf("open pipeline config for append: %v", err)
}
defer f.Close()
extra := `
scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
player_handout:
enabled: true
prompt_id: dnd.player_handout
output_path: artifacts/player_handout.md
depends_on:
- session_recap
inputs:
recap:
source: narratio.artifact.session_recap
required: true
`
if _, err := f.WriteString(extra); err != nil {
t.Fatalf("append scriptorium config: %v", err)
}
return pipelinePath, sessionPath
}

View File

@@ -0,0 +1,132 @@
package app
import (
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestArtifactSelectionFlagNormalize(t *testing.T) {
tests := []struct {
name string
inputs []string
want []string
wantErr string
}{
{
name: "single value",
inputs: []string{"session_recap"},
want: []string{"session_recap"},
},
{
name: "repeatable and comma separated values are deduped and sorted",
inputs: []string{"session_recap,player_handout", "session_recap"},
want: []string{"player_handout", "session_recap"},
},
{
name: "empty token fails",
inputs: []string{"session_recap,"},
wantErr: "artifact names must be non-empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var flag artifactSelectionFlag
for _, in := range tt.inputs {
if err := flag.Set(in); err != nil {
t.Fatalf("Set(%q) error = %v", in, err)
}
}
got, err := flag.Normalize()
if tt.wantErr != "" {
if err == nil {
t.Fatalf("Normalize() error = nil, want %q", tt.wantErr)
}
if err.Error() != tt.wantErr {
t.Fatalf("Normalize() error = %q, want %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("Normalize() len = %d, want %d; got=%v", len(got), len(tt.want), got)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("Normalize()[%d] = %q, want %q", i, got[i], tt.want[i])
}
}
})
}
}
func TestValidateSelectedAnalyzeArtifacts(t *testing.T) {
tests := []struct {
name string
cfg *config.Config
selected []string
wantErr string
}{
{
name: "empty selection is accepted",
cfg: &config.Config{},
selected: nil,
},
{
name: "scriptorium required when selected artifacts present",
cfg: &config.Config{Pipeline: &config.PipelineConfig{}},
selected: []string{"session_recap"},
wantErr: "--artifacts requires pipeline.scriptorium.artifacts to be configured",
},
{
name: "unknown selected artifact fails",
cfg: &config.Config{
Pipeline: &config.PipelineConfig{
Scriptorium: &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {Enabled: true, PromptID: "dnd.session_recap", OutputPath: "artifacts/session_recap.md"},
},
},
},
},
selected: []string{"player_handout"},
wantErr: `--artifacts includes unknown artifact "player_handout"`,
},
{
name: "known selected artifacts are accepted",
cfg: &config.Config{
Pipeline: &config.PipelineConfig{
Scriptorium: &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {Enabled: true, PromptID: "dnd.session_recap", OutputPath: "artifacts/session_recap.md"},
"player_handout": {Enabled: true, PromptID: "dnd.player_handout", OutputPath: "artifacts/player_handout.md"},
},
},
},
},
selected: []string{"player_handout", "session_recap"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateSelectedAnalyzeArtifacts(tt.cfg, tt.selected)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("error = nil, want %q", tt.wantErr)
}
if err.Error() != tt.wantErr {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("error = %v, want nil", err)
}
})
}
}

View File

@@ -20,10 +20,12 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
var sessionPath string
var sessionID string
var force bool
var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("resume: invalid flags: %w", err)
@@ -49,6 +51,13 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("resume: %w", err)
}
normalizedArtifacts, err := selectedArtifacts.Normalize()
if err != nil {
return fmt.Errorf("resume: invalid --artifacts: %w", err)
}
if err := validateSelectedAnalyzeArtifacts(cfg, normalizedArtifacts); err != nil {
return fmt.Errorf("resume: %w", err)
}
full := BuildFullPlan()
selected := full
@@ -67,7 +76,10 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
}
}
summary, err := executeStages(ctx, cfg, selected, RunOptions{Force: force})
summary, err := executeStages(ctx, cfg, selected, RunOptions{
Force: force,
SelectedArtifacts: normalizedArtifacts,
})
if err != nil {
return fmt.Errorf("resume: %w", err)
}

View File

@@ -18,10 +18,12 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
var sessionPath string
var sessionID string
var force bool
var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("run: invalid flags: %w", err)
@@ -47,9 +49,19 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("run: %w", err)
}
normalizedArtifacts, err := selectedArtifacts.Normalize()
if err != nil {
return fmt.Errorf("run: invalid --artifacts: %w", err)
}
if err := validateSelectedAnalyzeArtifacts(cfg, normalizedArtifacts); err != nil {
return fmt.Errorf("run: %w", err)
}
stages := BuildFullPlan()
summary, err := executeStages(ctx, cfg, stages, RunOptions{Force: force})
summary, err := executeStages(ctx, cfg, stages, RunOptions{
Force: force,
SelectedArtifacts: normalizedArtifacts,
})
if err != nil {
return fmt.Errorf("run: %w", err)
}

View File

@@ -18,10 +18,12 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
var sessionPath string
var sessionID string
var force bool
var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("run-stage: invalid flags: %w", err)
@@ -30,6 +32,13 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
return fmt.Errorf("run-stage: expected exactly one stage name")
}
stageName := fs.Arg(0)
normalizedArtifacts, err := selectedArtifacts.Normalize()
if err != nil {
return fmt.Errorf("run-stage: invalid --artifacts: %w", err)
}
if len(normalizedArtifacts) > 0 && stageName != "analyze" {
return fmt.Errorf("run-stage: --artifacts is only supported for stage \"analyze\"")
}
stages, err := BuildSingleStagePlan(stageName)
if err != nil {
return fmt.Errorf("run-stage: %w", err)
@@ -53,8 +62,14 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("run-stage: %w", err)
}
if err := validateSelectedAnalyzeArtifacts(cfg, normalizedArtifacts); err != nil {
return fmt.Errorf("run-stage: %w", err)
}
summary, err := executeStages(ctx, cfg, stages, RunOptions{Force: force})
summary, err := executeStages(ctx, cfg, stages, RunOptions{
Force: force,
SelectedArtifacts: normalizedArtifacts,
})
if err != nil {
return fmt.Errorf("run-stage: %w", err)
}

View File

@@ -21,8 +21,9 @@ import (
)
type RunOptions struct {
Force bool
Env *Env
Force bool
SelectedArtifacts []string
Env *Env
}
type RunSummary struct {
@@ -43,6 +44,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
if env.Config == nil {
env.Config = cfg
}
env.SelectedAnalyzeArtifacts = append([]string(nil), opts.SelectedArtifacts...)
if env.ArtifactStore == nil {
env.ArtifactStore = artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
}

View File

@@ -44,6 +44,40 @@ func (s countingStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest
return &stage.StageResult{Metadata: map[string]any{"counting": true}}, nil
}
type captureSelectedArtifactsStage struct {
name string
captured *[]string
}
func (s captureSelectedArtifactsStage) Name() string { return s.name }
func (s captureSelectedArtifactsStage) Declares() stage.IODecl { return stage.IODecl{} }
func (s captureSelectedArtifactsStage) Run(_ context.Context, env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
if s.captured != nil {
*s.captured = append((*s.captured)[:0], env.SelectedAnalyzeArtifacts...)
}
return &stage.StageResult{Metadata: map[string]any{"captured": true}}, nil
}
func TestExecuteStagesPropagatesSelectedArtifactsToEnv(t *testing.T) {
cfg := testConfig(t)
captured := []string{}
stageToRun := captureSelectedArtifactsStage{name: "analyze", captured: &captured}
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{
SelectedArtifacts: []string{"player_handout", "session_recap"},
})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if len(captured) != 2 {
t.Fatalf("captured len = %d, want 2 (%v)", len(captured), captured)
}
if captured[0] != "player_handout" || captured[1] != "session_recap" {
t.Fatalf("captured = %v, want [player_handout session_recap]", captured)
}
}
func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
cfg := testConfig(t)