480 lines
16 KiB
Go
480 lines
16 KiB
Go
package artifacts
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
)
|
|
|
|
const (
|
|
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
|
|
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
|
|
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
|
|
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
|
|
ArtifactTranscriptFinalMarkdown = artifactmodel.SourceTranscriptFinalMarkdown
|
|
ArtifactTranscriptFinalTrimmedMarkdown = artifactmodel.SourceTranscriptFinalTrimmedMarkdown
|
|
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
|
|
TranscriptPathFinalMarkdown = artifactmodel.TranscriptPathFinalMarkdown
|
|
TranscriptPathFinalTrimmedMarkdown = artifactmodel.TranscriptPathFinalTrimmedMarkdown
|
|
)
|
|
|
|
const (
|
|
TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase
|
|
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
|
|
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
|
|
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
|
|
TranscriptOutputKindFinalMarkdown = artifactmodel.TranscriptOutputKindFinalMarkdown
|
|
TranscriptOutputKindFinalTrimmedMarkdown = artifactmodel.TranscriptOutputKindFinalTrimmedMarkdown
|
|
)
|
|
|
|
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
|
var ErrSessionArtifactNotFound = errors.New("session artifact not found")
|
|
|
|
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: transcriptContentKind(transcript),
|
|
}
|
|
}
|
|
registry[ArtifactBoundsSession] = artifactSpec{
|
|
ID: ArtifactBoundsSession,
|
|
CanonicalRelPath: "artifacts/session_bounds.json",
|
|
ProducerStage: "trim",
|
|
OutputKind: "session_bounds",
|
|
ContentKind: contentJSON,
|
|
}
|
|
return registry
|
|
}
|
|
|
|
func transcriptContentKind(transcript TranscriptArtifactSpec) artifactContentKind {
|
|
switch transcript.SourceID {
|
|
case ArtifactTranscriptFinalMarkdown, ArtifactTranscriptFinalTrimmedMarkdown:
|
|
return contentText
|
|
default:
|
|
return contentTranscriptJSON
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
classified, err := artifactpolicy.ClassifySource(source)
|
|
if err != nil {
|
|
return "", fmt.Errorf("unsupported artifact source %q", source)
|
|
}
|
|
if classified.Kind != artifactpolicy.SourceKindBuiltIn {
|
|
return "", fmt.Errorf("unsupported artifact source %q", source)
|
|
}
|
|
if _, ok := artifactRegistry[classified.ID]; !ok {
|
|
return "", fmt.Errorf("unsupported artifact source %q", source)
|
|
}
|
|
return classified.ID, nil
|
|
}
|
|
|
|
// IsConfiguredArtifactSource returns true when source is narratio.artifact.<name>.
|
|
func IsConfiguredArtifactSource(source string) bool {
|
|
_, ok := artifactpolicy.ParseConfiguredSource(source)
|
|
return ok
|
|
}
|
|
|
|
// ConfiguredArtifactName extracts <name> from narratio.artifact.<name>.
|
|
func ConfiguredArtifactName(source string) (string, bool) {
|
|
return artifactpolicy.ParseConfiguredSource(source)
|
|
}
|
|
|
|
// ExtractionArtifactSourceID returns narratio.extraction.<name> for a configured key.
|
|
func ExtractionArtifactSourceID(key string) string {
|
|
return artifactpolicy.ExtractionSourceID(key)
|
|
}
|
|
|
|
// IsExtractionArtifactSource reports whether source is narratio.extraction.<name>.
|
|
func IsExtractionArtifactSource(source string) bool {
|
|
_, ok := artifactpolicy.ParseExtractionSource(source)
|
|
return ok
|
|
}
|
|
|
|
// ExtractionArtifactName extracts <name> from narratio.extraction.<name>.
|
|
func ExtractionArtifactName(source string) (string, bool) {
|
|
return artifactpolicy.ParseExtractionSource(source)
|
|
}
|
|
|
|
// IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.<name>.
|
|
func IsPreviousSessionArtifactSource(source string) bool {
|
|
_, ok := artifactpolicy.ParsePreviousSessionSource(source)
|
|
return ok
|
|
}
|
|
|
|
// PreviousSessionArtifactName extracts <name> from narratio.previous_session.artifact.<name>.
|
|
func PreviousSessionArtifactName(source string) (string, bool) {
|
|
return artifactpolicy.ParsePreviousSessionSource(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) {
|
|
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.<name> 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
|
|
}
|