package artifacts import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) const ( ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed ArtifactBoundsSession = "narratio.bounds.session" ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache" ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache" ) const ( TranscriptPathBase = artifactmodel.TranscriptPathBase TranscriptPathPolished = artifactmodel.TranscriptPathPolished TranscriptPathFinal = artifactmodel.TranscriptPathFinal TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed ) const ( TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed ) // 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_]*)$`) var previousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`) type artifactContentKind string const ( contentTranscriptJSON artifactContentKind = "transcript_json" contentJSON artifactContentKind = "json" contentText artifactContentKind = "text" ) type artifactSpec struct { ID string CanonicalRelPath string ProducerStage string OutputKind string ContentKind artifactContentKind } var artifactRegistry = buildArtifactRegistry() func buildArtifactRegistry() map[string]artifactSpec { registry := map[string]artifactSpec{} for _, transcript := range RuntimeTranscriptArtifacts() { registry[transcript.SourceID] = artifactSpec{ ID: transcript.SourceID, CanonicalRelPath: transcript.CanonicalRelPath, ProducerStage: transcript.ProducerStage, OutputKind: transcript.OutputKind, ContentKind: contentTranscriptJSON, } } registry[ArtifactBoundsSession] = artifactSpec{ ID: ArtifactBoundsSession, CanonicalRelPath: "artifacts/session_bounds.json", ProducerStage: "trim", OutputKind: "session_bounds", ContentKind: contentJSON, } return registry } // ResolvedSessionArtifact describes one session-level artifact lookup result. type ResolvedSessionArtifact struct { ID string Path string ProducerStage string OutputKind string ProducerRunID string Provenance string } // SessionArtifactNotFoundError includes context when a known artifact cannot be read. type SessionArtifactNotFoundError struct { ArtifactID string } func (e *SessionArtifactNotFoundError) Error() string { return fmt.Sprintf("%s: %q", ErrSessionArtifactNotFound, e.ArtifactID) } func (e *SessionArtifactNotFoundError) Unwrap() error { return ErrSessionArtifactNotFound } // NormalizeSessionArtifactSource validates canonical artifact IDs. func NormalizeSessionArtifactSource(source string) (string, error) { normalized := strings.TrimSpace(source) if normalized == "" { return "", fmt.Errorf("artifact source is required") } if _, ok := artifactRegistry[normalized]; !ok { return "", fmt.Errorf("unsupported artifact source %q", source) } return normalized, nil } // IsConfiguredArtifactSource returns true when source is narratio.artifact.. func IsConfiguredArtifactSource(source string) bool { return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source)) } // ConfiguredArtifactName extracts from narratio.artifact.. func ConfiguredArtifactName(source string) (string, bool) { matches := configuredArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source)) if len(matches) != 2 { return "", false } return matches[1], true } // IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.. func IsPreviousSessionArtifactSource(source string) bool { _, ok := PreviousSessionArtifactName(source) return ok } // PreviousSessionArtifactName extracts from narratio.previous_session.artifact.. func PreviousSessionArtifactName(source string) (string, bool) { matches := previousSessionArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source)) if len(matches) != 2 { return "", false } return matches[1], true } // 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) { id, err := NormalizeSessionArtifactSource(source) if err != nil { return ResolvedSessionArtifact{}, err } spec := artifactRegistry[id] for _, candidate := range manifestArtifactCandidates(paths, m, spec) { exists, isDir, statErr := pathExists(candidate.Path) if statErr != nil { return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", candidate.Path, statErr) } if !exists || isDir { continue } resolved := candidate resolved.ID = spec.ID resolved.ProducerStage = spec.ProducerStage resolved.OutputKind = spec.OutputKind if err := validateResolvedContent(resolved.Path, spec.ContentKind); err != nil { return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", resolved.ID, err) } return resolved, nil } fallbackPath := filepath.Join(paths.Root, filepath.FromSlash(spec.CanonicalRelPath)) exists, isDir, statErr := pathExists(fallbackPath) if statErr != nil { return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", fallbackPath, statErr) } if exists && !isDir { if err := validateResolvedContent(fallbackPath, spec.ContentKind); err != nil { return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err) } return ResolvedSessionArtifact{ ID: spec.ID, Path: filepath.Clean(fallbackPath), ProducerStage: spec.ProducerStage, OutputKind: spec.OutputKind, Provenance: "fallback.canonical_path", }, nil } 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 IsPreviousSessionArtifactSource(normalized) { return ResolvePreviousSessionArtifactWithCatalog(paths, m, normalized, catalog) } 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 } // ResolvePreviousSessionArtifactWithCatalog resolves one canonical previous-session source id // to the prepared current-session previous-cache path. func ResolvePreviousSessionArtifactWithCatalog( paths SessionPaths, m *manifest.Manifest, source string, catalog *ArtifactCatalog, ) (ResolvedSessionArtifact, error) { artifactName, ok := PreviousSessionArtifactName(source) if !ok { return ResolvedSessionArtifact{}, fmt.Errorf("unsupported previous-session artifact source %q", source) } if catalog == nil { return ResolvedSessionArtifact{}, fmt.Errorf("previous-session artifact source %q requires runtime artifact catalog", source) } configuredSourceID := ConfiguredArtifactSourceID(artifactName) entry, ok := catalog.Lookup(configuredSourceID) if !ok { return ResolvedSessionArtifact{}, fmt.Errorf("unsupported previous-session artifact source %q", source) } candidates := previousSessionCacheCandidatePaths(paths, entry.CanonicalRelPath) if len(candidates) == 0 { return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source} } manifestInputPaths := manifestInputPathSet(paths, m) fallback := "" for _, candidate := range candidates { exists, isDir, statErr := pathExists(candidate) if statErr != nil { return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", candidate, statErr) } if !exists || isDir { continue } if err := validateResolvedContent(candidate, contentText); err != nil { return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", source, err) } if _, ok := manifestInputPaths[candidate]; ok { return ResolvedSessionArtifact{ ID: source, Path: candidate, ProducerStage: "prepare", OutputKind: "previous_session_cache", Provenance: ArtifactProvenancePreviousCacheManifestInput, }, nil } if fallback == "" { fallback = candidate } } if fallback != "" { return ResolvedSessionArtifact{ ID: source, Path: fallback, ProducerStage: "prepare", OutputKind: "previous_session_cache", Provenance: ArtifactProvenancePreviousCacheFilesystem, }, nil } return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source} } func manifestArtifactCandidates(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) []ResolvedSessionArtifact { if m == nil || len(m.Stages) == 0 || spec.ProducerStage == "" || spec.OutputKind == "" { return nil } sr := m.Stages[spec.ProducerStage] if sr == nil { return nil } candidates := make([]ResolvedSessionArtifact, 0, len(sr.Outputs)) for _, out := range sr.Outputs { if strings.TrimSpace(out.Kind) != spec.OutputKind { continue } if strings.TrimSpace(out.LocalPath) == "" { continue } resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, out.LocalPath)) if resolved == "" { continue } candidates = append(candidates, ResolvedSessionArtifact{ Path: resolved, ProducerRunID: strings.TrimSpace(out.ProducerRunID), Provenance: "manifest." + spec.ProducerStage + ".outputs", }) } return dedupeResolvedArtifacts(candidates) } func dedupeResolvedArtifacts(values []ResolvedSessionArtifact) []ResolvedSessionArtifact { seen := map[string]struct{}{} out := make([]ResolvedSessionArtifact, 0, len(values)) for _, value := range values { key := filepath.Clean(strings.TrimSpace(value.Path)) if key == "" { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} value.Path = key out = append(out, value) } return out } func previousSessionCacheCandidatePaths(paths SessionPaths, canonicalRelPath string) []string { trimmed := strings.TrimSpace(canonicalRelPath) if trimmed == "" { return nil } normalized := filepath.ToSlash(filepath.Clean(filepath.FromSlash(trimmed))) if normalized == "." || normalized == "" || normalized == ".." || strings.HasPrefix(normalized, "../") || strings.HasPrefix(normalized, "/") { return nil } relCandidates := []string{normalized} const artifactsPrefix = "artifacts/" if strings.HasPrefix(normalized, artifactsPrefix) && len(normalized) > len(artifactsPrefix) { relCandidates = append(relCandidates, strings.TrimPrefix(normalized, artifactsPrefix)) } out := make([]string, 0, len(relCandidates)) seen := map[string]struct{}{} for _, rel := range relCandidates { abs := filepath.Clean(SessionPreviousArtifactPath(paths, rel)) if _, ok := seen[abs]; ok { continue } seen[abs] = struct{}{} out = append(out, abs) } return out } func manifestInputPathSet(paths SessionPaths, m *manifest.Manifest) map[string]struct{} { if m == nil || len(m.Inputs) == 0 { return nil } out := make(map[string]struct{}, len(m.Inputs)) for _, in := range m.Inputs { resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, in.Path)) if strings.TrimSpace(resolved) == "" { continue } out[resolved] = struct{}{} } return out } func pathExists(path string) (exists bool, isDir bool, err error) { info, err := os.Stat(path) if err == nil { return true, info.IsDir(), nil } if errors.Is(err, os.ErrNotExist) { return false, false, nil } return false, false, err } func validateResolvedContent(path string, kind artifactContentKind) error { switch kind { case contentTranscriptJSON: return validateTranscriptSegmentsJSON(path) case contentJSON: return validateJSONContent(path) case contentText: return validateNonEmptyContent(path) default: return fmt.Errorf("unsupported content kind %q", kind) } } func validateTranscriptSegmentsJSON(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read file: %w", err) } var payload map[string]any if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("decode json: %w", err) } segments, ok := payload["segments"] if !ok { return fmt.Errorf("top-level segments is required") } if _, ok := segments.([]any); !ok { return fmt.Errorf("top-level segments must be an array") } return nil } func validateJSONContent(path string) error { data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read file: %w", err) } var payload any if err := json.Unmarshal(data, &payload); err != nil { return fmt.Errorf("decode json: %w", err) } return nil } func validateNonEmptyContent(path string) error { info, err := os.Stat(path) if err != nil { return fmt.Errorf("stat file: %w", err) } if info.IsDir() { return fmt.Errorf("path is a directory") } if info.Size() <= 0 { return fmt.Errorf("file is empty") } return nil }