diff --git a/internal/artifactpolicy/policy.go b/internal/artifactpolicy/policy.go index ab18760..0fad146 100644 --- a/internal/artifactpolicy/policy.go +++ b/internal/artifactpolicy/policy.go @@ -1,6 +1,7 @@ package artifactpolicy import ( + "errors" "fmt" "regexp" "strings" @@ -19,6 +20,11 @@ const ( var configuredSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`) var previousSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`) +var ( + ErrUnsupportedScriptoriumInputSource = errors.New("unsupported scriptorium input source") + ErrInvalidPreviousSessionSource = errors.New("invalid previous-session source format") +) + type SourceKind string const ( @@ -34,6 +40,28 @@ type Source struct { ConfiguredKey string } +// ScriptoriumInputSourceDescriptor describes one validated Scriptorium input source. +type ScriptoriumInputSourceDescriptor struct { + Source Source + PreviousSession *PreviousSessionSourceDescriptor +} + +// PreviousSessionSourceDescriptor describes one canonical previous-session input source. +type PreviousSessionSourceDescriptor struct { + SourceID string + ConfiguredKey string + ConfiguredSourceID string +} + +// UnknownConfiguredArtifactError reports a source that references an undefined configured artifact key. +type UnknownConfiguredArtifactError struct { + ConfiguredKey string +} + +func (e *UnknownConfiguredArtifactError) Error() string { + return fmt.Sprintf("references unknown artifact %q", e.ConfiguredKey) +} + // ConfiguredSourceID converts a configured artifact key into source id form. func ConfiguredSourceID(key string) string { return configuredSourcePrefix + strings.TrimSpace(key) @@ -83,6 +111,70 @@ func ClassifySource(source string) (Source, error) { return Source{}, fmt.Errorf("unsupported artifact source %q", source) } +// DescribeScriptoriumInputSource classifies one input source and returns descriptor +// metadata used by config validation, analyze input resolution, and previous-cache planning. +func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescriptor, error) { + trimmed := strings.TrimSpace(source) + if trimmed == "" { + return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource + } + if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") { + descriptor, err := DescribePreviousSessionSource(trimmed) + if err != nil { + return ScriptoriumInputSourceDescriptor{}, err + } + return ScriptoriumInputSourceDescriptor{ + Source: Source{ + ID: descriptor.SourceID, + Kind: SourceKindPreviousArtifact, + ConfiguredKey: descriptor.ConfiguredKey, + }, + PreviousSession: &descriptor, + }, nil + } + + classified, err := ClassifySource(trimmed) + if err != nil { + return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource + } + return ScriptoriumInputSourceDescriptor{Source: classified}, nil +} + +// DescribePreviousSessionSource validates a canonical previous-session source id +// and returns both previous and configured-source vocabulary descriptors. +func DescribePreviousSessionSource(source string) (PreviousSessionSourceDescriptor, error) { + configuredKey, ok := ParsePreviousSessionSource(source) + if !ok { + return PreviousSessionSourceDescriptor{}, ErrInvalidPreviousSessionSource + } + return PreviousSessionSourceDescriptor{ + SourceID: PreviousSessionSourceID(configuredKey), + ConfiguredKey: configuredKey, + ConfiguredSourceID: ConfiguredSourceID(configuredKey), + }, nil +} + +// PreviousSessionSourceDescriptorForConfiguredKey derives a previous-session source descriptor +// from a configured artifact key. +func PreviousSessionSourceDescriptorForConfiguredKey(configuredKey string) (PreviousSessionSourceDescriptor, error) { + return DescribePreviousSessionSource(PreviousSessionSourceID(configuredKey)) +} + +// ValidateInputConfiguredReference checks that configured/previous-session sources +// reference configured artifacts known to the current Scriptorium config. +func ValidateInputConfiguredReference( + descriptor ScriptoriumInputSourceDescriptor, + configured map[string]struct{}, +) error { + switch descriptor.Source.Kind { + case SourceKindConfiguredArtifact, SourceKindPreviousArtifact: + if _, ok := configured[descriptor.Source.ConfiguredKey]; !ok { + return &UnknownConfiguredArtifactError{ConfiguredKey: descriptor.Source.ConfiguredKey} + } + } + return nil +} + // ValidatePublishSource validates that a source is publish-compatible and references a known configured artifact. func ValidatePublishSource(source string, configured map[string]string) (Source, error) { classified, err := ClassifySource(source) diff --git a/internal/artifactpolicy/policy_test.go b/internal/artifactpolicy/policy_test.go index bd909fe..a669b95 100644 --- a/internal/artifactpolicy/policy_test.go +++ b/internal/artifactpolicy/policy_test.go @@ -1,6 +1,7 @@ package artifactpolicy import ( + "errors" "strings" "testing" ) @@ -90,3 +91,101 @@ func TestResolvePublishedDestinationRejectsTraversal(t *testing.T) { t.Fatal("ResolvePublishedDestination() error = nil, want traversal rejection") } } + +func TestDescribeScriptoriumInputSource(t *testing.T) { + tests := []struct { + name string + source string + wantKind SourceKind + wantKey string + wantPrev bool + wantErr error + wantErrLike string + }{ + {name: "built in", source: "narratio.transcript.final_trimmed", wantKind: SourceKindBuiltIn}, + {name: "configured", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"}, + {name: "previous", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap", wantPrev: true}, + {name: "invalid previous", source: "narratio.previous_session.artifact.", wantErr: ErrInvalidPreviousSessionSource}, + {name: "unsupported", source: "narratio.unknown", wantErr: ErrUnsupportedScriptoriumInputSource}, + {name: "empty", source: " ", wantErr: ErrUnsupportedScriptoriumInputSource}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := DescribeScriptoriumInputSource(tt.source) + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("DescribeScriptoriumInputSource() error = %v, want %v", err, tt.wantErr) + } + return + } + if tt.wantErrLike != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErrLike) { + t.Fatalf("DescribeScriptoriumInputSource() error = %v, want like %q", err, tt.wantErrLike) + } + return + } + if err != nil { + t.Fatalf("DescribeScriptoriumInputSource() error = %v", err) + } + if got.Source.Kind != tt.wantKind { + t.Fatalf("DescribeScriptoriumInputSource().Source.Kind = %q, want %q", got.Source.Kind, tt.wantKind) + } + if got.Source.ConfiguredKey != tt.wantKey { + t.Fatalf("DescribeScriptoriumInputSource().Source.ConfiguredKey = %q, want %q", got.Source.ConfiguredKey, tt.wantKey) + } + if tt.wantPrev && got.PreviousSession == nil { + t.Fatal("DescribeScriptoriumInputSource().PreviousSession = nil, want descriptor") + } + if !tt.wantPrev && got.PreviousSession != nil { + t.Fatalf("DescribeScriptoriumInputSource().PreviousSession = %#v, want nil", got.PreviousSession) + } + }) + } +} + +func TestValidateInputConfiguredReference(t *testing.T) { + configured := map[string]struct{}{"session_recap": {}} + + desc, err := DescribeScriptoriumInputSource("narratio.artifact.session_recap") + if err != nil { + t.Fatalf("DescribeScriptoriumInputSource(configured) error = %v", err) + } + if err := ValidateInputConfiguredReference(desc, configured); err != nil { + t.Fatalf("ValidateInputConfiguredReference(configured) error = %v", err) + } + + prevDesc, err := DescribeScriptoriumInputSource("narratio.previous_session.artifact.session_recap") + if err != nil { + t.Fatalf("DescribeScriptoriumInputSource(previous) error = %v", err) + } + if err := ValidateInputConfiguredReference(prevDesc, configured); err != nil { + t.Fatalf("ValidateInputConfiguredReference(previous) error = %v", err) + } + + missingDesc, err := DescribeScriptoriumInputSource("narratio.artifact.quest_log") + if err != nil { + t.Fatalf("DescribeScriptoriumInputSource(missing configured) error = %v", err) + } + err = ValidateInputConfiguredReference(missingDesc, configured) + var unknown *UnknownConfiguredArtifactError + if !errors.As(err, &unknown) || unknown.ConfiguredKey != "quest_log" { + t.Fatalf("ValidateInputConfiguredReference(missing configured) error = %v, want UnknownConfiguredArtifactError(quest_log)", err) + } +} + +func TestPreviousSessionSourceDescriptorForConfiguredKey(t *testing.T) { + got, err := PreviousSessionSourceDescriptorForConfiguredKey("session_recap") + if err != nil { + t.Fatalf("PreviousSessionSourceDescriptorForConfiguredKey() error = %v", err) + } + if got.SourceID != "narratio.previous_session.artifact.session_recap" { + t.Fatalf("SourceID = %q, want narratio.previous_session.artifact.session_recap", got.SourceID) + } + if got.ConfiguredSourceID != "narratio.artifact.session_recap" { + t.Fatalf("ConfiguredSourceID = %q, want narratio.artifact.session_recap", got.ConfiguredSourceID) + } + if got.ConfiguredKey != "session_recap" { + t.Fatalf("ConfiguredKey = %q, want session_recap", got.ConfiguredKey) + } +} diff --git a/internal/artifacts/previous_requirements.go b/internal/artifacts/previous_requirements.go index 9db6496..1c7b9ea 100644 --- a/internal/artifacts/previous_requirements.go +++ b/internal/artifacts/previous_requirements.go @@ -5,6 +5,7 @@ import ( "sort" "strings" + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/config" ) @@ -36,10 +37,11 @@ func CollectPreviousArtifactRequirements( inputNames := sortedScriptoriumInputKeys(artifactCfg.Inputs) for _, inputName := range inputNames { inputCfg := artifactCfg.Inputs[inputName] - previousName, ok := PreviousSessionArtifactName(inputCfg.Source) - if !ok { + descriptor, err := artifactpolicy.DescribeScriptoriumInputSource(inputCfg.Source) + if err != nil || descriptor.PreviousSession == nil { continue } + previousName := descriptor.PreviousSession.ConfiguredKey location := fmt.Sprintf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source", diff --git a/internal/config/validate.go b/internal/config/validate.go index 6a3cbd2..9627dd7 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) @@ -639,13 +638,9 @@ var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) { trimmedSource := strings.TrimSpace(source) - if isStaticSupportedScriptoriumInputSource(trimmedSource) { - return "", nil - } - - if strings.HasPrefix(trimmedSource, "narratio.previous_session.artifact") { - referenced, ok := artifactpolicy.ParsePreviousSessionSource(trimmedSource) - if !ok { + descriptor, err := artifactpolicy.DescribeScriptoriumInputSource(trimmedSource) + if err != nil { + if errors.Is(err, artifactpolicy.ErrInvalidPreviousSessionSource) { return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$", artifactName, @@ -653,20 +648,6 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf source, ) } - 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 "", nil - } - - referenced, ok := artifactpolicy.ParseConfiguredSource(trimmedSource) - if !ok { return "", fmt.Errorf( "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported", artifactName, @@ -674,28 +655,28 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf source, ) } - if _, ok := configuredArtifacts[referenced]; !ok { + if err := artifactpolicy.ValidateInputConfiguredReference(descriptor, configuredArtifacts); err != nil { + var unknownConfigured *artifactpolicy.UnknownConfiguredArtifactError + if errors.As(err, &unknownConfigured) { + return "", fmt.Errorf( + "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q", + artifactName, + inputName, + source, + unknownConfigured.ConfiguredKey, + ) + } return "", fmt.Errorf( - "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q", + "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported", artifactName, inputName, source, - referenced, ) } - return referenced, nil -} - -func isStaticSupportedScriptoriumInputSource(source string) bool { - if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(source); ok { - return true - } - switch source { - case "narratio.bounds.session": - return true - default: - return false + if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact { + return descriptor.Source.ConfiguredKey, nil } + return "", nil } func validateEnvVarNameField(fieldName, value string) error { diff --git a/internal/previouscache/previouscache.go b/internal/previouscache/previouscache.go index 3c1f3c5..06836ca 100644 --- a/internal/previouscache/previouscache.go +++ b/internal/previouscache/previouscache.go @@ -229,7 +229,11 @@ func artifactRelativePathCandidates( candidates = append(candidates, normalized) } + sourceDescriptor, err := artifactpolicy.PreviousSessionSourceDescriptorForConfiguredKey(artifactName) sourceID := artifactpolicy.ConfiguredSourceID(artifactName) + if err == nil { + sourceID = sourceDescriptor.ConfiguredSourceID + } if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok { appendCandidate(rel) base := path.Base(rel) diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 984fb7d..6f40658 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -628,11 +628,11 @@ func resolveScriptoriumInput( runtimeCatalog *artifacts.ArtifactCatalog, ) (string, bool, *artifacts.ResolvedSessionArtifact, error) { source := strings.TrimSpace(inputCfg.Source) - classified, classifyErr := artifactpolicy.ClassifySource(source) - if classifyErr != nil { - return "", false, nil, classifyErr + descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source) + if describeErr != nil { + return "", false, nil, describeErr } - if classified.Kind == artifactpolicy.SourceKindPreviousArtifact { + if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact { resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) if err == nil { copy := resolved @@ -657,13 +657,13 @@ func resolveScriptoriumInput( return resolved.Path, true, ©, nil } if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { - if classified.Kind == artifactpolicy.SourceKindConfiguredArtifact { + if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact { if inputCfg.Required { return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source) } return "", false, nil, nil } - switch classified.ID { + switch descriptor.Source.ID { case artifacts.ArtifactTranscriptPolished: return "", false, nil, nil case artifacts.ArtifactTranscriptFinal: