From 3e79cf4724b22e09261e0d54ea374a4f1ebc7491 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 19 May 2026 18:49:27 -0500 Subject: [PATCH] Update artifact resolution so configured artifact IDs are resolved through the runtime catalog --- internal/artifacts/artifact_resolver.go | 36 ++++++ internal/artifacts/artifact_resolver_test.go | 128 +++++++++++++++++++ internal/stage/analyze.go | 62 ++++++++- internal/stage/analyze_test.go | 83 ++++++++++++ 4 files changed, 307 insertions(+), 2 deletions(-) diff --git a/internal/artifacts/artifact_resolver.go b/internal/artifacts/artifact_resolver.go index 8d72394..91227a3 100644 --- a/internal/artifacts/artifact_resolver.go +++ b/internal/artifacts/artifact_resolver.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "gitea.maximumdirect.net/eric/narratio/internal/manifest" @@ -22,6 +23,7 @@ const ( // ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID. var ErrSessionArtifactNotFound = errors.New("session artifact not found") +var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.[a-z][a-z0-9_]*$`) type artifactContentKind string @@ -119,6 +121,11 @@ func NormalizeSessionArtifactSource(source string) (string, error) { return normalized, nil } +// IsConfiguredArtifactSource returns true when source is narratio.artifact.. +func IsConfiguredArtifactSource(source string) bool { + return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source)) +} + // ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path. // Resolution order is manifest producer outputs first, then canonical session path fallback. func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) { @@ -167,6 +174,35 @@ func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source str return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID} } +// ResolveSessionArtifactWithCatalog resolves built-in sources using existing rules and resolves +// configured narratio.artifact. sources through runtime catalog availability. +func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest, source string, catalog *ArtifactCatalog) (ResolvedSessionArtifact, error) { + normalized := strings.TrimSpace(source) + if !IsConfiguredArtifactSource(normalized) { + return ResolveSessionArtifact(paths, m, normalized) + } + if catalog == nil { + return ResolvedSessionArtifact{}, fmt.Errorf("configured artifact source %q requires runtime artifact catalog", source) + } + entry, ok := catalog.Lookup(normalized) + if !ok { + return ResolvedSessionArtifact{}, fmt.Errorf("unsupported artifact source %q", source) + } + if !entry.Available { + return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: normalized} + } + if err := validateResolvedContent(entry.Path, contentText); err != nil { + return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", normalized, err) + } + return ResolvedSessionArtifact{ + ID: normalized, + Path: filepath.Clean(entry.Path), + ProducerStage: entry.ProducerStage, + OutputKind: entry.OutputKind, + Provenance: entry.Provenance, + }, nil +} + func manifestArtifactCandidates(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) []ResolvedSessionArtifact { if m == nil || len(m.Stages) == 0 || spec.ProducerStage == "" || spec.OutputKind == "" { return nil diff --git a/internal/artifacts/artifact_resolver_test.go b/internal/artifacts/artifact_resolver_test.go index fbe87cb..6daf320 100644 --- a/internal/artifacts/artifact_resolver_test.go +++ b/internal/artifacts/artifact_resolver_test.go @@ -137,3 +137,131 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) { t.Fatalf("error = %q, want segments validation error", err.Error()) } } + +func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "campaign", "session") + canonicalPath := filepath.Join(paths.TranscriptsDir, "trimmed.json") + if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(canonicalPath, []byte(`{"segments":[]}`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, ArtifactTranscriptTrimmed, NewArtifactCatalog()) + if err != nil { + t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err) + } + if resolved.Path != canonicalPath { + t.Fatalf("resolved.Path = %q, want %q", resolved.Path, canonicalPath) + } + if resolved.Provenance != "fallback.canonical_path" { + t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance) + } +} + +func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "campaign", "session") + outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md") + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(outputPath, []byte("recap\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + catalog := NewArtifactCatalog() + if err := catalog.RegisterConfiguredArtifacts( + map[string]ConfiguredArtifactDefinition{ + "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, + }, + nil, + ); err != nil { + t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) + } + sourceID := ConfiguredArtifactSourceID("session_recap") + if err := catalog.MarkAvailableGenerated(sourceID, outputPath); err != nil { + t.Fatalf("MarkAvailableGenerated() error = %v", err) + } + + resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, sourceID, catalog) + if err != nil { + t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err) + } + if resolved.Path != outputPath { + t.Fatalf("resolved.Path = %q, want %q", resolved.Path, outputPath) + } + if resolved.Provenance != ArtifactProvenanceGeneratedCurrentAnalyzeRun { + t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceGeneratedCurrentAnalyzeRun) + } +} + +func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "campaign", "session") + outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md") + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(outputPath, []byte("handout\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + catalog := NewArtifactCatalog() + if err := catalog.RegisterConfiguredArtifacts( + map[string]ConfiguredArtifactDefinition{ + "player_handout": {Enabled: false, OutputPath: "artifacts/player_handout.md"}, + }, + nil, + ); err != nil { + t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) + } + sourceID := ConfiguredArtifactSourceID("player_handout") + if err := catalog.MarkAvailableFromDisk(sourceID, outputPath); err != nil { + t.Fatalf("MarkAvailableFromDisk() error = %v", err) + } + + resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, sourceID, catalog) + if err != nil { + t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err) + } + if resolved.Provenance != ArtifactProvenanceDisabledFromDisk { + t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceDisabledFromDisk) + } +} + +func TestResolveSessionArtifactWithCatalogConfiguredPlannedButUnavailable(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "campaign", "session") + catalog := NewArtifactCatalog() + if err := catalog.RegisterConfiguredArtifacts( + map[string]ConfiguredArtifactDefinition{ + "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, + }, + nil, + ); err != nil { + t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) + } + _, err := ResolveSessionArtifactWithCatalog(paths, nil, ConfiguredArtifactSourceID("session_recap"), catalog) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, ErrSessionArtifactNotFound) { + t.Fatalf("errors.Is(err, ErrSessionArtifactNotFound)=false; err=%v", err) + } +} + +func TestResolveSessionArtifactWithCatalogUnsupportedConfiguredSourceFails(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "campaign", "session") + catalog := NewArtifactCatalog() + _, err := ResolveSessionArtifactWithCatalog(paths, nil, "narratio.artifact.unknown", catalog) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "unsupported artifact source") { + t.Fatalf("error = %q, want unsupported artifact source", err.Error()) + } +} diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 25378cc..233ee42 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -88,6 +88,11 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S }, nil } + runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog(paths, env.Config.Pipeline.Scriptorium, env.SelectedAnalyzeArtifacts) + if err != nil { + return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err) + } + transcriptRefs := discoverAnalyzeTranscriptRefs(m, paths) inputPaths := map[string]string{} @@ -96,7 +101,7 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs) for _, inputName := range inputNames { inputCfg := artifactCfg.Inputs[inputName] - resolvedPath, resolved, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir) + resolvedPath, resolved, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir, runtimeCatalog) if resolveErr != nil { return nil, fmt.Errorf("analyze: resolve input %q: %w", inputName, resolveErr) } @@ -391,6 +396,7 @@ func resolveScriptoriumInput( m *manifest.Manifest, paths artifacts.SessionPaths, sessionDir string, + runtimeCatalog *artifacts.ArtifactCatalog, ) (string, bool, error) { switch strings.TrimSpace(inputCfg.Source) { case "previous_session_artifact": @@ -403,11 +409,17 @@ func resolveScriptoriumInput( } return resolved, true, nil default: - resolved, err := artifacts.ResolveSessionArtifact(paths, m, inputCfg.Source) + resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, inputCfg.Source, runtimeCatalog) if err == nil { return resolved.Path, true, nil } if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { + if artifacts.IsConfiguredArtifactSource(inputCfg.Source) { + if inputCfg.Required { + return "", false, fmt.Errorf("configured artifact source %q is unavailable", inputCfg.Source) + } + return "", false, nil + } normalized, normalizeErr := artifacts.NormalizeSessionArtifactSource(inputCfg.Source) if normalizeErr != nil { return "", false, normalizeErr @@ -427,6 +439,52 @@ func resolveScriptoriumInput( } } +func buildAnalyzeRuntimeArtifactCatalog( + paths artifacts.SessionPaths, + scriptoriumCfg *config.ScriptoriumConfig, + selectedArtifacts []string, +) (*artifacts.ArtifactCatalog, error) { + catalog := artifacts.NewArtifactCatalog() + if err := catalog.RegisterBuiltIns(); err != nil { + return nil, err + } + if scriptoriumCfg == nil { + return catalog, nil + } + + configured := map[string]artifacts.ConfiguredArtifactDefinition{} + for key, artifactCfg := range scriptoriumCfg.Artifacts { + configured[key] = artifacts.ConfiguredArtifactDefinition{ + Enabled: artifactCfg.Enabled, + OutputPath: artifactCfg.OutputPath, + } + } + if err := catalog.RegisterConfiguredArtifacts(configured, selectedArtifacts); err != nil { + return nil, err + } + + for _, entry := range catalog.ListConfigured() { + if entry.Executable { + continue + } + if strings.TrimSpace(entry.CanonicalRelPath) == "" { + continue + } + resolvedPath, err := resolveScriptoriumOutputPath(paths, entry.CanonicalRelPath) + if err != nil { + continue + } + if err := requireNonEmptyFile(resolvedPath, "configured artifact "+entry.SourceID); err != nil { + continue + } + if err := catalog.MarkAvailableFromDisk(entry.SourceID, resolvedPath); err != nil { + return nil, err + } + } + + return catalog, nil +} + func resolveInputPathForRead(paths artifacts.SessionPaths, sessionDir, pathValue string) string { trimmed := strings.TrimSpace(pathValue) if trimmed == "" { diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index f377c60..0fecbed 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -414,6 +414,89 @@ func TestAnalyzeFailsWhenRequiredPreviousRecapMissing(t *testing.T) { } } +func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) + playerHandoutPath := filepath.Join(paths.ArtifactsDir, "player_handout.md") + writeAnalyzeFile(t, playerHandoutPath, "handout\n") + + sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{ + Source: "narratio.artifact.player_handout", + Required: true, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap + env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ + Enabled: false, + OutputPath: "artifacts/player_handout.md", + } + + _, err := (analyzeStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(fake.RunRequests) != 1 { + t.Fatalf("run requests = %d, want 1", len(fake.RunRequests)) + } + if got := fake.RunRequests[0].InputPaths["recap"]; got != playerHandoutPath { + t.Fatalf("recap input = %q, want %q", got, playerHandoutPath) + } +} + +func TestAnalyzeFailsWhenRequiredConfiguredArtifactMissing(t *testing.T) { + env, m, _ := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) + + sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{ + Source: "narratio.artifact.player_handout", + Required: true, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap + env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ + Enabled: false, + OutputPath: "artifacts/player_handout.md", + } + + _, err := (analyzeStage{}).Run(context.Background(), env, m) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), `configured artifact source "narratio.artifact.player_handout" is unavailable`) { + t.Fatalf("error = %q, want configured artifact unavailable context", err.Error()) + } +} + +func TestAnalyzeOmitsOptionalMissingConfiguredArtifactInput(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) + + sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{ + Source: "narratio.artifact.player_handout", + Required: false, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap + env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ + Enabled: false, + OutputPath: "artifacts/player_handout.md", + } + + _, err := (analyzeStage{}).Run(context.Background(), env, m) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if len(fake.RunRequests) != 1 { + t.Fatalf("run requests = %d, want 1", len(fake.RunRequests)) + } + if _, exists := fake.RunRequests[0].InputPaths["recap"]; exists { + t.Fatalf("optional recap input should be omitted when unavailable, got %q", fake.RunRequests[0].InputPaths["recap"]) + } +} + func TestAnalyzeFailsWhenOutputPathMissing(t *testing.T) { env, m, fake := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID)