diff --git a/internal/app/analyze_artifacts_commands_test.go b/internal/app/analyze_artifacts_commands_test.go index 9fbd17c..fb78803 100644 --- a/internal/app/analyze_artifacts_commands_test.go +++ b/internal/app/analyze_artifacts_commands_test.go @@ -76,6 +76,34 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) { } } +func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(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)) + for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} { + seed.MarkStageSucceeded(stageName, 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 := Resume( + context.Background(), + []string{"--config", pipelinePath, "--session", sessionPath, "--artifacts", "session_recap"}, + &out, + ) + if err != nil { + t.Fatalf("Resume() error = %v", err) + } + if !strings.Contains(out.String(), "has no remaining stages") { + t.Fatalf("output = %q, want no remaining stages", out.String()) + } +} + func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string) { t.Helper() diff --git a/internal/artifacts/catalog.go b/internal/artifacts/catalog.go new file mode 100644 index 0000000..e096afd --- /dev/null +++ b/internal/artifacts/catalog.go @@ -0,0 +1,224 @@ +package artifacts + +import ( + "fmt" + "sort" + "strings" +) + +const ( + ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run" + ArtifactProvenanceDisabledFromDisk = "filesystem.disabled_artifact_output" +) + +// ConfiguredArtifactDefinition describes one configured analyze artifact. +type ConfiguredArtifactDefinition struct { + Enabled bool + OutputPath string +} + +// CatalogEntry is one runtime catalog entry resolved by source ID. +type CatalogEntry struct { + SourceID string + ConfiguredKey string + CanonicalRelPath string + ProducerStage string + OutputKind string + Planned bool + Executable bool + Available bool + Path string + Provenance string +} + +// ArtifactCatalog tracks built-in and configured artifact definitions and runtime state. +type ArtifactCatalog struct { + entries map[string]CatalogEntry + configuredIndex map[string]string +} + +// NewArtifactCatalog returns an empty runtime artifact catalog. +func NewArtifactCatalog() *ArtifactCatalog { + return &ArtifactCatalog{ + entries: map[string]CatalogEntry{}, + configuredIndex: map[string]string{}, + } +} + +// ConfiguredArtifactSourceID converts a configured artifact key into canonical source ID. +func ConfiguredArtifactSourceID(key string) string { + return "narratio.artifact." + strings.TrimSpace(key) +} + +// RegisterBuiltIns registers built-in source definitions used by runtime artifact resolution. +func (c *ArtifactCatalog) RegisterBuiltIns() error { + for _, id := range runtimeBuiltInArtifactIDs() { + spec, ok := artifactRegistry[id] + if !ok { + return fmt.Errorf("register built-ins: source %q not found in artifact registry", id) + } + if err := c.addEntry(CatalogEntry{ + SourceID: spec.ID, + CanonicalRelPath: spec.CanonicalRelPath, + ProducerStage: spec.ProducerStage, + OutputKind: spec.OutputKind, + Planned: true, + }); err != nil { + return fmt.Errorf("register built-ins: %w", err) + } + } + return nil +} + +// RegisterConfiguredArtifacts registers configured artifacts and applies executable selection. +func (c *ArtifactCatalog) RegisterConfiguredArtifacts( + configured map[string]ConfiguredArtifactDefinition, + selected []string, +) error { + keys := make([]string, 0, len(configured)) + for key := range configured { + keys = append(keys, key) + } + sort.Strings(keys) + + selectedSet := map[string]struct{}{} + for _, key := range selected { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return fmt.Errorf("selected artifact keys must be non-empty") + } + selectedSet[trimmed] = struct{}{} + } + + for _, key := range keys { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return fmt.Errorf("configured artifact keys must be non-empty") + } + def := configured[key] + sourceID := ConfiguredArtifactSourceID(trimmed) + if _, exists := c.configuredIndex[trimmed]; exists { + return fmt.Errorf("duplicate configured artifact key %q", trimmed) + } + + executable := def.Enabled + if len(selectedSet) > 0 { + _, executable = selectedSet[trimmed] + } + + if err := c.addEntry(CatalogEntry{ + SourceID: sourceID, + ConfiguredKey: trimmed, + CanonicalRelPath: strings.TrimSpace(def.OutputPath), + ProducerStage: "analyze", + OutputKind: "scriptorium_artifact", + Planned: true, + Executable: executable, + }); err != nil { + return fmt.Errorf("register configured artifact %q: %w", trimmed, err) + } + c.configuredIndex[trimmed] = sourceID + } + + if len(selectedSet) > 0 { + for key := range selectedSet { + if _, ok := c.configuredIndex[key]; !ok { + return fmt.Errorf("selected artifact %q is not configured", key) + } + } + } + + return nil +} + +// Lookup returns one catalog entry by source ID. +func (c *ArtifactCatalog) Lookup(sourceID string) (CatalogEntry, bool) { + if c == nil { + return CatalogEntry{}, false + } + entry, ok := c.entries[strings.TrimSpace(sourceID)] + return entry, ok +} + +// SourceIDForConfiguredKey returns canonical source ID for one configured key. +func (c *ArtifactCatalog) SourceIDForConfiguredKey(key string) (string, bool) { + if c == nil { + return "", false + } + sourceID, ok := c.configuredIndex[strings.TrimSpace(key)] + return sourceID, ok +} + +// ListConfigured returns configured entries sorted by configured key. +func (c *ArtifactCatalog) ListConfigured() []CatalogEntry { + if c == nil || len(c.configuredIndex) == 0 { + return nil + } + keys := make([]string, 0, len(c.configuredIndex)) + for key := range c.configuredIndex { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]CatalogEntry, 0, len(keys)) + for _, key := range keys { + sourceID := c.configuredIndex[key] + out = append(out, c.entries[sourceID]) + } + return out +} + +// MarkAvailableGenerated marks one source as available in current analyze execution. +func (c *ArtifactCatalog) MarkAvailableGenerated(sourceID, path string) error { + return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun) +} + +// MarkAvailableFromDisk marks one source as available from disabled artifact on disk. +func (c *ArtifactCatalog) MarkAvailableFromDisk(sourceID, path string) error { + return c.markAvailable(sourceID, path, ArtifactProvenanceDisabledFromDisk) +} + +func (c *ArtifactCatalog) markAvailable(sourceID, path, provenance string) error { + if c == nil { + return fmt.Errorf("artifact catalog is nil") + } + normalizedID := strings.TrimSpace(sourceID) + entry, ok := c.entries[normalizedID] + if !ok { + return fmt.Errorf("unknown artifact source %q", sourceID) + } + trimmedPath := strings.TrimSpace(path) + if trimmedPath == "" { + return fmt.Errorf("artifact path is required") + } + entry.Available = true + entry.Path = trimmedPath + entry.Provenance = provenance + c.entries[normalizedID] = entry + return nil +} + +func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error { + if c == nil { + return fmt.Errorf("artifact catalog is nil") + } + sourceID := strings.TrimSpace(entry.SourceID) + if sourceID == "" { + return fmt.Errorf("source id is required") + } + if _, exists := c.entries[sourceID]; exists { + return fmt.Errorf("source id %q is already registered", sourceID) + } + entry.SourceID = sourceID + c.entries[sourceID] = entry + return nil +} + +func runtimeBuiltInArtifactIDs() []string { + return []string{ + ArtifactTranscriptMerged, + ArtifactTranscriptPolished, + ArtifactTranscriptFull, + ArtifactTranscriptTrimmed, + ArtifactBoundsSession, + } +} diff --git a/internal/artifacts/catalog_test.go b/internal/artifacts/catalog_test.go new file mode 100644 index 0000000..ed31a83 --- /dev/null +++ b/internal/artifacts/catalog_test.go @@ -0,0 +1,188 @@ +package artifacts + +import "testing" + +func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) { + catalog := NewArtifactCatalog() + if err := catalog.RegisterBuiltIns(); err != nil { + t.Fatalf("RegisterBuiltIns() error = %v", err) + } + + entry, ok := catalog.Lookup(ArtifactTranscriptFull) + if !ok { + t.Fatalf("Lookup(%q) ok = false, want true", ArtifactTranscriptFull) + } + if !entry.Planned { + t.Fatalf("entry.Planned = false, want true") + } + if entry.Executable { + t.Fatalf("entry.Executable = true, want false") + } + if entry.CanonicalRelPath != "transcripts/normalized.json" { + t.Fatalf("entry.CanonicalRelPath = %q, want transcripts/normalized.json", entry.CanonicalRelPath) + } +} + +func TestArtifactCatalogRegisterConfiguredArtifactsDefaultsToEnabled(t *testing.T) { + catalog := NewArtifactCatalog() + if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{ + "player_handout": {Enabled: false, OutputPath: "artifacts/player_handout.md"}, + "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, + }, nil); err != nil { + t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) + } + + recapID, ok := catalog.SourceIDForConfiguredKey("session_recap") + if !ok { + t.Fatal("SourceIDForConfiguredKey(session_recap) ok = false, want true") + } + recap, ok := catalog.Lookup(recapID) + if !ok { + t.Fatalf("Lookup(%q) ok = false, want true", recapID) + } + if !recap.Executable { + t.Fatalf("recap.Executable = false, want true") + } + + handoutID, ok := catalog.SourceIDForConfiguredKey("player_handout") + if !ok { + t.Fatal("SourceIDForConfiguredKey(player_handout) ok = false, want true") + } + handout, ok := catalog.Lookup(handoutID) + if !ok { + t.Fatalf("Lookup(%q) ok = false, want true", handoutID) + } + if handout.Executable { + t.Fatalf("handout.Executable = true, want false") + } +} + +func TestArtifactCatalogRegisterConfiguredArtifactsSelectedSetOverridesEnabled(t *testing.T) { + catalog := NewArtifactCatalog() + if err := catalog.RegisterConfiguredArtifacts( + map[string]ConfiguredArtifactDefinition{ + "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, + "player_handout": {Enabled: false, OutputPath: "artifacts/player_handout.md"}, + }, + []string{"player_handout"}, + ); err != nil { + t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) + } + + entries := catalog.ListConfigured() + if len(entries) != 2 { + t.Fatalf("ListConfigured() len = %d, want 2", len(entries)) + } + if entries[0].ConfiguredKey != "player_handout" || entries[0].Executable != true { + t.Fatalf("entries[0] = %+v, want player_handout executable", entries[0]) + } + if entries[1].ConfiguredKey != "session_recap" || entries[1].Executable != false { + t.Fatalf("entries[1] = %+v, want session_recap disabled by selection", entries[1]) + } +} + +func TestArtifactCatalogRejectsSelectedUnknownArtifact(t *testing.T) { + catalog := NewArtifactCatalog() + err := catalog.RegisterConfiguredArtifacts( + map[string]ConfiguredArtifactDefinition{ + "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, + }, + []string{"unknown"}, + ) + if err == nil { + t.Fatal("RegisterConfiguredArtifacts() error = nil, want non-nil") + } +} + +func TestArtifactCatalogRejectsConfiguredSourceConflictAcrossRegistrations(t *testing.T) { + catalog := NewArtifactCatalog() + if err := catalog.RegisterConfiguredArtifacts( + map[string]ConfiguredArtifactDefinition{ + "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, + }, + nil, + ); err != nil { + t.Fatalf("RegisterConfiguredArtifacts() first call error = %v", err) + } + err := catalog.RegisterConfiguredArtifacts( + map[string]ConfiguredArtifactDefinition{ + "session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"}, + }, + nil, + ) + if err == nil { + t.Fatal("RegisterConfiguredArtifacts() error = nil, want non-nil") + } +} + +func TestArtifactCatalogMarkAvailableGenerated(t *testing.T) { + 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, _ := catalog.SourceIDForConfiguredKey("session_recap") + if err := catalog.MarkAvailableGenerated(sourceID, "/tmp/session_recap.md"); err != nil { + t.Fatalf("MarkAvailableGenerated() error = %v", err) + } + entry, _ := catalog.Lookup(sourceID) + if !entry.Available { + t.Fatalf("entry.Available = false, want true") + } + if entry.Provenance != ArtifactProvenanceGeneratedCurrentAnalyzeRun { + t.Fatalf("entry.Provenance = %q, want %q", entry.Provenance, ArtifactProvenanceGeneratedCurrentAnalyzeRun) + } +} + +func TestArtifactCatalogMarkAvailableFromDisk(t *testing.T) { + catalog := NewArtifactCatalog() + if err := catalog.RegisterConfiguredArtifacts( + map[string]ConfiguredArtifactDefinition{ + "session_recap": {Enabled: false, OutputPath: "artifacts/session_recap.md"}, + }, + nil, + ); err != nil { + t.Fatalf("RegisterConfiguredArtifacts() error = %v", err) + } + + sourceID, _ := catalog.SourceIDForConfiguredKey("session_recap") + if err := catalog.MarkAvailableFromDisk(sourceID, "/tmp/session_recap.md"); err != nil { + t.Fatalf("MarkAvailableFromDisk() error = %v", err) + } + entry, _ := catalog.Lookup(sourceID) + if !entry.Available { + t.Fatalf("entry.Available = false, want true") + } + if entry.Provenance != ArtifactProvenanceDisabledFromDisk { + t.Fatalf("entry.Provenance = %q, want %q", entry.Provenance, ArtifactProvenanceDisabledFromDisk) + } +} + +func TestArtifactCatalogLookupPlannedButUnavailable(t *testing.T) { + 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, _ := catalog.SourceIDForConfiguredKey("session_recap") + entry, ok := catalog.Lookup(sourceID) + if !ok { + t.Fatalf("Lookup(%q) ok = false, want true", sourceID) + } + if !entry.Planned { + t.Fatalf("entry.Planned = false, want true") + } + if entry.Available { + t.Fatalf("entry.Available = true, want false") + } +}