diff --git a/internal/artifacts/artifact_resolver.go b/internal/artifacts/artifact_resolver.go index 25ef12e..4e67165 100644 --- a/internal/artifacts/artifact_resolver.go +++ b/internal/artifacts/artifact_resolver.go @@ -18,6 +18,9 @@ const ( ArtifactTranscriptFull = "narratio.transcript.full" ArtifactTranscriptTrimmed = "narratio.transcript.trimmed" ArtifactBoundsSession = "narratio.bounds.session" + + ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache" + ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache" ) // ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID. @@ -186,6 +189,9 @@ func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source str // 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) } @@ -211,6 +217,72 @@ func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest, }, 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_artifact", + Provenance: ArtifactProvenancePreviousCacheManifestInput, + }, nil + } + if fallback == "" { + fallback = candidate + } + } + + if fallback != "" { + return ResolvedSessionArtifact{ + ID: source, + Path: fallback, + ProducerStage: "prepare", + OutputKind: "previous_session_artifact", + 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 @@ -259,6 +331,50 @@ func dedupeResolvedArtifacts(values []ResolvedSessionArtifact) []ResolvedSession 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 { diff --git a/internal/artifacts/artifact_resolver_test.go b/internal/artifacts/artifact_resolver_test.go index 1c46c41..8f7c021 100644 --- a/internal/artifacts/artifact_resolver_test.go +++ b/internal/artifacts/artifact_resolver_test.go @@ -318,3 +318,120 @@ func TestResolveSessionArtifactWithCatalogUnsupportedConfiguredSourceFails(t *te t.Fatalf("error = %q, want unsupported artifact source", err.Error()) } } + +func TestResolvePreviousSessionArtifactWithCatalogPrefersManifestInputRecord(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "campaign", "session") + + manifestBackedPath := SessionPreviousArtifactPath(paths, "artifacts/session_recap.md") + fallbackPath := SessionPreviousArtifactPath(paths, "session_recap.md") + if err := os.MkdirAll(filepath.Dir(manifestBackedPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(fallbackPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(manifestBackedPath, []byte("recap from manifest input\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if err := os.WriteFile(fallbackPath, []byte("recap fallback\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) + } + + m := manifest.New("session", time.Now().UTC()) + m.Inputs = []manifest.InputRecord{ + {Kind: "previous_artifact", Path: manifestBackedPath}, + } + + resolved, err := ResolvePreviousSessionArtifactWithCatalog( + paths, + m, + "narratio.previous_session.artifact.session_recap", + catalog, + ) + if err != nil { + t.Fatalf("ResolvePreviousSessionArtifactWithCatalog() error = %v", err) + } + if resolved.Path != manifestBackedPath { + t.Fatalf("resolved path = %q, want %q", resolved.Path, manifestBackedPath) + } + if resolved.Provenance != ArtifactProvenancePreviousCacheManifestInput { + t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenancePreviousCacheManifestInput) + } +} + +func TestResolvePreviousSessionArtifactWithCatalogFallsBackToPreparedCachePath(t *testing.T) { + workspace := t.TempDir() + paths := buildSessionPaths(workspace, "campaign", "session") + + fallbackPath := SessionPreviousArtifactPath(paths, "session_recap.md") + if err := os.MkdirAll(filepath.Dir(fallbackPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(fallbackPath, []byte("recap fallback\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) + } + + resolved, err := ResolvePreviousSessionArtifactWithCatalog( + paths, + nil, + "narratio.previous_session.artifact.session_recap", + catalog, + ) + if err != nil { + t.Fatalf("ResolvePreviousSessionArtifactWithCatalog() error = %v", err) + } + if resolved.Path != fallbackPath { + t.Fatalf("resolved path = %q, want %q", resolved.Path, fallbackPath) + } + if resolved.Provenance != ArtifactProvenancePreviousCacheFilesystem { + t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenancePreviousCacheFilesystem) + } +} + +func TestResolvePreviousSessionArtifactWithCatalogMissingReturnsTypedError(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 := ResolvePreviousSessionArtifactWithCatalog( + paths, + nil, + "narratio.previous_session.artifact.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) + } +} diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index d5d5fb3..e0322dd 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -632,13 +632,21 @@ func resolveScriptoriumInput( ) (string, bool, *artifacts.ResolvedSessionArtifact, error) { source := strings.TrimSpace(inputCfg.Source) if artifacts.IsPreviousSessionArtifactSource(source) { - if inputCfg.Required { - return "", false, nil, fmt.Errorf( - "required previous-session input source %q is unavailable; run narratio run-stage --force prepare", - source, - ) + resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) + if err == nil { + copy := resolved + return resolved.Path, true, ©, nil } - return "", false, nil, nil + if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { + if inputCfg.Required { + return "", false, nil, fmt.Errorf( + "required previous-session input source %q is unavailable; run narratio run-stage --force prepare", + source, + ) + } + return "", false, nil, nil + } + return "", false, nil, err } switch source { case "previous_session_artifact": diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index 88c0391..9ee5044 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -736,6 +736,61 @@ func TestAnalyzeRequiredPreviousSessionArtifactInputGuidesPrepareForce(t *testin } } +func TestAnalyzeResolvesCanonicalPreviousSessionArtifactFromManifestInput(t *testing.T) { + env, m, fake := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) + + previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md") + writeAnalyzeFile(t, previousPath, "previous recap\n") + m.Inputs = append(m.Inputs, manifest.InputRecord{ + Kind: "previous_artifact", + Path: previousPath, + }) + + sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + sessionRecap.Inputs["previous_recap"] = config.ScriptoriumInputConfig{ + Source: "narratio.previous_session.artifact.session_recap", + Required: true, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap + + _, 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["previous_recap"]; got != previousPath { + t.Fatalf("previous_recap input = %q, want %q", got, previousPath) + } +} + +func TestAnalyzeOmitsOptionalMissingCanonicalPreviousSessionArtifact(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["previous_recap"] = config.ScriptoriumInputConfig{ + Source: "narratio.previous_session.artifact.session_recap", + Required: false, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap + + _, 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["previous_recap"]; exists { + t.Fatalf("optional canonical previous_recap should be omitted when unavailable") + } +} + func TestAnalyzeFailsWhenOutputPathMissing(t *testing.T) { env, m, fake := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID)