Require manifest-backed local artifacts
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
// MaxResolvedArtifactBytes bounds externally generated artifact content before validation.
|
||||
@@ -178,8 +180,8 @@ 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.
|
||||
// ResolveSessionArtifact resolves a symbolic source from its unique current
|
||||
// producer output declaration in the session manifest.
|
||||
func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) {
|
||||
id, err := NormalizeSessionArtifactSource(source)
|
||||
if err != nil {
|
||||
@@ -187,43 +189,21 @@ func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source str
|
||||
}
|
||||
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
|
||||
candidate, err := manifestArtifactCandidate(paths, m, spec)
|
||||
if err != nil {
|
||||
return ResolvedSessionArtifact{}, err
|
||||
}
|
||||
|
||||
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 err := validateResolvedContent(candidate.Path, spec.ContentKind); err != nil {
|
||||
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", spec.ID, err)
|
||||
}
|
||||
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
|
||||
actualChecksum, err := SHA256File(candidate.Path)
|
||||
if err != nil {
|
||||
return ResolvedSessionArtifact{}, fmt.Errorf("checksum %q: %w", spec.ID, err)
|
||||
}
|
||||
|
||||
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
|
||||
if actualChecksum != candidate.Checksum {
|
||||
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: checksum mismatch", spec.ID)
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
// ResolveSessionArtifactWithCatalog resolves built-in sources using existing rules and resolves
|
||||
@@ -334,52 +314,74 @@ func ResolvePreviousSessionArtifactWithCatalog(
|
||||
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
|
||||
func manifestArtifactCandidate(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) (ResolvedSessionArtifact, error) {
|
||||
if m == nil || spec.ProducerStage == "" || spec.OutputKind == "" {
|
||||
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
|
||||
}
|
||||
sr := m.Stages[spec.ProducerStage]
|
||||
if sr == nil {
|
||||
return nil
|
||||
if sr == nil || sr.Name != spec.ProducerStage || sr.Status != manifest.StatusSucceeded {
|
||||
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
|
||||
}
|
||||
|
||||
candidates := make([]ResolvedSessionArtifact, 0, len(sr.Outputs))
|
||||
var selected manifest.ArtifactRecord
|
||||
count := 0
|
||||
for _, out := range sr.Outputs {
|
||||
if strings.TrimSpace(out.Kind) != spec.OutputKind {
|
||||
continue
|
||||
if strings.TrimSpace(out.Kind) == spec.OutputKind && strings.TrimSpace(out.SourceID) == spec.ID {
|
||||
selected, count = out, count+1
|
||||
}
|
||||
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)
|
||||
if count == 0 {
|
||||
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: spec.ID}
|
||||
}
|
||||
if count != 1 {
|
||||
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q is ambiguous", spec.ProducerStage, spec.ID)
|
||||
}
|
||||
if err := ValidateRunIdentity(strings.TrimSpace(selected.ProducerRunID)); err != nil {
|
||||
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid producer run ID: %w", spec.ProducerStage, spec.ID, err)
|
||||
}
|
||||
checksum := strings.TrimSpace(selected.Checksum)
|
||||
if len(checksum) != 64 || strings.ToLower(checksum) != checksum {
|
||||
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid checksum", spec.ProducerStage, spec.ID)
|
||||
}
|
||||
if _, err := hex.DecodeString(checksum); err != nil {
|
||||
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has invalid checksum", spec.ProducerStage, spec.ID)
|
||||
}
|
||||
resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, selected.LocalPath))
|
||||
if resolved == "" {
|
||||
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has an empty path", spec.ProducerStage, spec.ID)
|
||||
}
|
||||
if _, err := pathsafe.SlashRelativeFromRoot(paths.Root, resolved); err != nil {
|
||||
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has unsafe path: %w", spec.ProducerStage, spec.ID, err)
|
||||
}
|
||||
if err := requireSafeSessionRegularFile(paths.Root, resolved); err != nil {
|
||||
return ResolvedSessionArtifact{}, fmt.Errorf("manifest %s output evidence for %q has unsafe path: %w", spec.ProducerStage, spec.ID, err)
|
||||
}
|
||||
return ResolvedSessionArtifact{ID: spec.ID, Path: resolved, ProducerStage: spec.ProducerStage, OutputKind: spec.OutputKind, ProducerRunID: selected.ProducerRunID, Provenance: "manifest." + spec.ProducerStage + ".outputs", Checksum: checksum}, nil
|
||||
}
|
||||
|
||||
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)
|
||||
func requireSafeSessionRegularFile(root, path string) error {
|
||||
relative, err := pathsafe.SlashRelativeFromRoot(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return out
|
||||
current := filepath.Clean(root)
|
||||
for _, component := range strings.Split(relative, "/") {
|
||||
current = filepath.Join(current, filepath.FromSlash(component))
|
||||
info, err := os.Lstat(current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("path contains symlink %q", current)
|
||||
}
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("path is not a regular file")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func previousSessionCacheCandidatePaths(paths SessionPaths, canonicalRelPath string) []string {
|
||||
|
||||
@@ -187,7 +187,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
|
||||
|
||||
m := manifest.New("session", time.Now().UTC())
|
||||
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{
|
||||
{Kind: "transcript_final", LocalPath: manifestPath, ProducerRunID: "run-123"},
|
||||
{Kind: "transcript_final", SourceID: ArtifactTranscriptFinal, LocalPath: manifestPath, ProducerRunID: "run-123", Checksum: resolverTestChecksum(t, manifestPath)},
|
||||
})
|
||||
|
||||
resolved, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFinal)
|
||||
@@ -205,7 +205,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
|
||||
func TestResolveSessionArtifactRejectsIncidentalCanonicalPath(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
|
||||
@@ -216,19 +216,13 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSessionArtifact() error = %v", err)
|
||||
}
|
||||
if resolved.Path != canonicalPath {
|
||||
t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath)
|
||||
}
|
||||
if resolved.Provenance != "fallback.canonical_path" {
|
||||
t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance)
|
||||
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed)
|
||||
if !errors.Is(err, ErrSessionArtifactNotFound) {
|
||||
t.Fatalf("ResolveSessionArtifact() error = %v, want typed unavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) {
|
||||
func TestResolveSessionArtifactRejectsIncidentalCanonicalMarkdownPath(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md")
|
||||
@@ -239,15 +233,9 @@ func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSessionArtifact() error = %v", err)
|
||||
}
|
||||
if resolved.Path != canonicalPath {
|
||||
t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath)
|
||||
}
|
||||
if resolved.Provenance != "fallback.canonical_path" {
|
||||
t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance)
|
||||
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown)
|
||||
if !errors.Is(err, ErrSessionArtifactNotFound) {
|
||||
t.Fatalf("ResolveSessionArtifact() error = %v, want typed unavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +263,8 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptPolished)
|
||||
m := resolverTestManifest(t, paths, ArtifactTranscriptPolished, canonicalPath)
|
||||
_, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptPolished)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -295,7 +284,8 @@ func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmedMarkdown)
|
||||
m := resolverTestManifest(t, paths, ArtifactTranscriptFinalTrimmedMarkdown, canonicalPath)
|
||||
_, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFinalTrimmedMarkdown)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -304,7 +294,7 @@ func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) {
|
||||
func TestResolveSessionArtifactWithCatalogBuiltInUsesManifestEvidence(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
|
||||
@@ -315,18 +305,39 @@ func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T)
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, ArtifactTranscriptFinalTrimmed, NewArtifactCatalog())
|
||||
m := resolverTestManifest(t, paths, ArtifactTranscriptFinalTrimmed, canonicalPath)
|
||||
resolved, err := ResolveSessionArtifactWithCatalog(paths, m, ArtifactTranscriptFinalTrimmed, NewArtifactCatalog())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||
}
|
||||
if resolved.Path != canonicalPath {
|
||||
t.Fatalf("resolved.Path = %q, want %q", resolved.Path, canonicalPath)
|
||||
}
|
||||
if resolved.Provenance != "fallback.canonical_path" {
|
||||
t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance)
|
||||
if resolved.Provenance != "manifest.trim.outputs" {
|
||||
t.Fatalf("provenance = %q, want manifest.trim.outputs", resolved.Provenance)
|
||||
}
|
||||
}
|
||||
|
||||
func resolverTestManifest(t *testing.T, paths SessionPaths, source, localPath string) *manifest.Manifest {
|
||||
t.Helper()
|
||||
spec := artifactRegistry[source]
|
||||
m := manifest.New(paths.SessionID, time.Now().UTC())
|
||||
m.MarkStageSucceeded(spec.ProducerStage, time.Now().UTC(), []manifest.ArtifactRecord{{
|
||||
Kind: spec.OutputKind, SourceID: source, LocalPath: localPath,
|
||||
ProducerRunID: "resolver-run", Checksum: resolverTestChecksum(t, localPath),
|
||||
}})
|
||||
return m
|
||||
}
|
||||
|
||||
func resolverTestChecksum(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
checksum, err := SHA256File(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return checksum
|
||||
}
|
||||
|
||||
func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -93,6 +94,7 @@ func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) {
|
||||
name string
|
||||
state ExtractionEvidenceState
|
||||
recomputeInputIdentity bool
|
||||
wantInputUnavailable bool
|
||||
mutate func(*testing.T, *SessionPaths, *manifest.Manifest)
|
||||
}{
|
||||
{name: "valid", state: ExtractionEvidenceValid, mutate: func(_ *testing.T, _ *SessionPaths, _ *manifest.Manifest) {}},
|
||||
@@ -102,7 +104,7 @@ func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) {
|
||||
{name: "obsolete version", state: ExtractionEvidenceObsolete, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) {
|
||||
m.Stages["extract"].Outputs[0].Contract.SchemaVersion = "99"
|
||||
}},
|
||||
{name: "changed direct transcript", state: ExtractionEvidenceObsolete, recomputeInputIdentity: true, mutate: func(t *testing.T, _ *SessionPaths, m *manifest.Manifest) {
|
||||
{name: "changed direct transcript", wantInputUnavailable: true, recomputeInputIdentity: true, mutate: func(t *testing.T, _ *SessionPaths, m *manifest.Manifest) {
|
||||
writeExtractionFixtureFile(t, m.Stages["trim"].Outputs[0].LocalPath, `{"segments":[{"id":"changed"}]}`)
|
||||
}},
|
||||
{name: "changed direct producer", state: ExtractionEvidenceObsolete, recomputeInputIdentity: true, mutate: func(_ *testing.T, _ *SessionPaths, m *manifest.Manifest) {
|
||||
@@ -140,6 +142,12 @@ func TestInspectExtractionEvidenceClassifiesBundleStates(t *testing.T) {
|
||||
test.mutate(t, &paths, currentManifest)
|
||||
if test.recomputeInputIdentity {
|
||||
input, err = ResolveExtractionInputIdentity(paths, currentManifest)
|
||||
if test.wantInputUnavailable {
|
||||
if !errors.Is(err, ErrSessionArtifactNotFound) && (err == nil || !strings.Contains(err.Error(), "checksum mismatch")) {
|
||||
t.Fatalf("ResolveExtractionInputIdentity() error = %v, want unavailable", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -271,7 +279,7 @@ func validExtractionCatalogFixture(t *testing.T) (SessionPaths, *manifest.Manife
|
||||
m.Stages["trim"] = &manifest.StageRecord{
|
||||
Name: "trim", Status: manifest.StatusSucceeded,
|
||||
Outputs: []manifest.ArtifactRecord{{
|
||||
Kind: TranscriptOutputKindFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1",
|
||||
Kind: TranscriptOutputKindFinalTrimmed, SourceID: ArtifactTranscriptFinalTrimmed, LocalPath: trimmedPath, ProducerRunID: "trim-run-1", Checksum: extractionFixtureChecksum(t, trimmedPath),
|
||||
}},
|
||||
}
|
||||
input, err := ResolveExtractionInputIdentity(paths, m)
|
||||
|
||||
Reference in New Issue
Block a user