Unify previous source resolution
This commit is contained in:
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -40,9 +39,13 @@ type Record struct {
|
||||
Size int64
|
||||
Generation string
|
||||
S3Bucket string
|
||||
VerifiedContent []byte
|
||||
}
|
||||
|
||||
func BuildPlan(
|
||||
// Resolve evaluates previous-session requirements against the one selected
|
||||
// remote current state. Optional absence is represented in SkippedMissing;
|
||||
// required absence returns an error.
|
||||
func Resolve(
|
||||
ctx context.Context,
|
||||
cfg *config.Config,
|
||||
paths artifacts.SessionPaths,
|
||||
@@ -137,45 +140,44 @@ func BuildPlan(
|
||||
manifestRecord.SHA256 = committedManifest.SHA256
|
||||
manifestRecord.Size = committedManifest.Size
|
||||
manifestRecord.Generation = committedManifest.Generation
|
||||
manifestRecord.VerifiedContent = append([]byte(nil), current.ManifestData...)
|
||||
}
|
||||
result.Records = append(result.Records, manifestRecord)
|
||||
|
||||
for _, requirement := range orderedRequirements {
|
||||
candidates := artifactRelativePathCandidates(requirement.Name, previousManifest, cfg)
|
||||
if len(candidates) == 0 {
|
||||
if requirement.Required {
|
||||
return nil, fmt.Errorf(
|
||||
"required previous-session artifact %q is unavailable in previous-session manifest/published-state",
|
||||
requirement.Name,
|
||||
)
|
||||
}
|
||||
result.SkippedMissing = append(result.SkippedMissing, requirement.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
selectedRel := ""
|
||||
selectedKey := ""
|
||||
var selectedArtifact *artifacts.RemoteArtifact
|
||||
for _, candidate := range candidates {
|
||||
if current.Commit != nil {
|
||||
artifact, ok := committedPublishedArtifact(current.Commit, previousSessionPrefix, candidate)
|
||||
if !ok {
|
||||
continue
|
||||
if current.Commit != nil {
|
||||
sourceID := previousArtifactSourceID(requirement.Name)
|
||||
artifact, ok := committedPublishedArtifactBySource(current.Commit, sourceID)
|
||||
if ok {
|
||||
selectedRel, ok = sourceRelativePath(sourceID, requirement.Name, previousManifest, cfg)
|
||||
if ok {
|
||||
selectedKey = artifact.DestinationKey
|
||||
selectedArtifact = &artifact
|
||||
}
|
||||
selectedRel = candidate
|
||||
selectedKey = artifact.DestinationKey
|
||||
selectedArtifact = &artifact
|
||||
break
|
||||
}
|
||||
remoteKey := artifacts.S3PublishedOutputKey(previousSessionPrefix, candidate)
|
||||
exists, err := store.Exists(ctx, remoteKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check previous-session artifact object %q: %w", remoteKey, err)
|
||||
} else {
|
||||
// Legacy current state predates immutable source-to-destination
|
||||
// mappings. Keep its compatibility candidates isolated here.
|
||||
matchedCandidates := make([]string, 0, 1)
|
||||
for _, candidate := range legacyArtifactRelativePathCandidates(requirement.Name, previousManifest, cfg) {
|
||||
remoteKey := artifacts.S3PublishedOutputKey(previousSessionPrefix, candidate)
|
||||
exists, err := store.Exists(ctx, remoteKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check previous-session artifact object %q: %w", remoteKey, err)
|
||||
}
|
||||
if exists {
|
||||
matchedCandidates = append(matchedCandidates, candidate)
|
||||
if len(matchedCandidates) == 1 {
|
||||
selectedRel = candidate
|
||||
selectedKey = remoteKey
|
||||
}
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
selectedRel = candidate
|
||||
selectedKey = remoteKey
|
||||
break
|
||||
if len(matchedCandidates) > 1 {
|
||||
return nil, fmt.Errorf("legacy previous-session artifact %q matches multiple published candidates: %s", requirement.Name, strings.Join(matchedCandidates, ", "))
|
||||
}
|
||||
}
|
||||
if selectedRel == "" {
|
||||
@@ -224,13 +226,27 @@ func BuildPlan(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func committedPublishedArtifact(commit *artifacts.RemoteCommitManifest, sessionPrefix, relativePath string) (artifacts.RemoteArtifact, bool) {
|
||||
// BuildPlan is retained for callers that need the historical name.
|
||||
func BuildPlan(
|
||||
ctx context.Context,
|
||||
cfg *config.Config,
|
||||
paths artifacts.SessionPaths,
|
||||
requirements []artifacts.PreviousArtifactRequirement,
|
||||
store storage.ObjectStore,
|
||||
) (*Plan, error) {
|
||||
return Resolve(ctx, cfg, paths, requirements, store)
|
||||
}
|
||||
|
||||
func committedPublishedArtifactBySource(commit *artifacts.RemoteCommitManifest, source string) (artifacts.RemoteArtifact, bool) {
|
||||
if commit == nil {
|
||||
return artifacts.RemoteArtifact{}, false
|
||||
}
|
||||
want := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(sessionPrefix, commit.RunID), relativePath)
|
||||
want := strings.TrimSpace(source)
|
||||
if want == "" {
|
||||
return artifacts.RemoteArtifact{}, false
|
||||
}
|
||||
for _, artifact := range commit.Artifacts {
|
||||
if artifact.Type == artifacts.RemoteArtifactTypePublishedOutput && artifact.DestinationKey == want {
|
||||
if artifact.Type == artifacts.RemoteArtifactTypePublishedOutput && artifact.Source == want {
|
||||
return artifact, true
|
||||
}
|
||||
}
|
||||
@@ -260,7 +276,30 @@ func optionalPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequ
|
||||
return names
|
||||
}
|
||||
|
||||
func artifactRelativePathCandidates(
|
||||
func previousArtifactSourceID(artifactName string) string {
|
||||
sourceID := artifactpolicy.ConfiguredSourceID(artifactName)
|
||||
if sourceDescriptor, err := artifactpolicy.PreviousSessionSourceDescriptorForConfiguredKey(artifactName); err == nil {
|
||||
sourceID = sourceDescriptor.ConfiguredSourceID
|
||||
}
|
||||
return sourceID
|
||||
}
|
||||
|
||||
func sourceRelativePath(sourceID, artifactName string, previousManifest *manifest.Manifest, cfg *config.Config) (string, bool) {
|
||||
if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok {
|
||||
return rel, true
|
||||
}
|
||||
if cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
|
||||
if artifactCfg, ok := cfg.Pipeline.Scriptorium.Artifacts[artifactName]; ok {
|
||||
normalized, err := pathsafe.NormalizeRelativeDestination(artifactCfg.OutputPath)
|
||||
if err == nil {
|
||||
return normalized, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func legacyArtifactRelativePathCandidates(
|
||||
artifactName string,
|
||||
previousManifest *manifest.Manifest,
|
||||
cfg *config.Config,
|
||||
@@ -274,16 +313,12 @@ func artifactRelativePathCandidates(
|
||||
candidates = append(candidates, normalized)
|
||||
}
|
||||
|
||||
sourceDescriptor, err := artifactpolicy.PreviousSessionSourceDescriptorForConfiguredKey(artifactName)
|
||||
sourceID := artifactpolicy.ConfiguredSourceID(artifactName)
|
||||
if err == nil {
|
||||
sourceID = sourceDescriptor.ConfiguredSourceID
|
||||
}
|
||||
sourceID := previousArtifactSourceID(artifactName)
|
||||
if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok {
|
||||
appendCandidate(rel)
|
||||
base := path.Base(rel)
|
||||
base := filepath.Base(rel)
|
||||
for _, published := range manifestPublishedPaths(previousManifest) {
|
||||
if path.Base(published) == base {
|
||||
if filepath.Base(published) == base {
|
||||
appendCandidate(published)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package previouscache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -58,6 +60,76 @@ func TestBuildPlanFallsBackToConfiguredOutputPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveUsesCommittedSourceMappingForCustomDestination(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
store := &storage.FakeBackend{}
|
||||
previous := previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", nil)
|
||||
customKey := seedCommittedPreviousState(t, store, cfg, previous, []artifacts.RemoteArtifact{
|
||||
committedPublishedArtifactForTest("narratio.artifact.session_recap", "history/recap-v2.txt", []byte("# recap\n"), "recap-generation"),
|
||||
committedPublishedArtifactForTest("narratio.artifact.player_handout", "other/recap-v2.txt", []byte("handout"), "handout-generation"),
|
||||
})
|
||||
|
||||
plan, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{{Name: "session_recap", Required: true}}, store)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if len(plan.Records) != 2 {
|
||||
t.Fatalf("records = %#v, want manifest and artifact", plan.Records)
|
||||
}
|
||||
if manifestRecord := findRecord(plan.Records, InputKindManifest); manifestRecord == nil || len(manifestRecord.VerifiedContent) == 0 {
|
||||
t.Fatalf("manifest record = %#v, want retained verified content", manifestRecord)
|
||||
}
|
||||
artifact := findRecord(plan.Records, InputKindArtifact)
|
||||
if artifact == nil {
|
||||
t.Fatal("artifact record is missing")
|
||||
}
|
||||
if artifact.RemoteKey != customKey {
|
||||
t.Fatalf("remote key = %q, want exact configured source destination %q", artifact.RemoteKey, customKey)
|
||||
}
|
||||
if artifact.LocalRelativePath != "previous/artifacts/session_recap.md" {
|
||||
t.Fatalf("local relative path = %q, want source-local cache path", artifact.LocalRelativePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDoesNotUseCommittedBasenameFallback(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
store := &storage.FakeBackend{}
|
||||
previous := previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", nil)
|
||||
seedCommittedPreviousState(t, store, cfg, previous, []artifacts.RemoteArtifact{
|
||||
committedPublishedArtifactForTest("narratio.artifact.player_handout", "history/session_recap.md", []byte("wrong source"), "handout-generation"),
|
||||
})
|
||||
|
||||
_, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{{Name: "session_recap", Required: true}}, store)
|
||||
if err == nil || !strings.Contains(err.Error(), `required previous-session artifact "session_recap" object missing`) {
|
||||
t.Fatalf("Resolve() error = %v, want source-mapping absence", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRejectsAmbiguousLegacyCandidates(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
store := &storage.FakeBackend{}
|
||||
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"archive/session_recap.md"}))
|
||||
prefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
||||
store.SeedObject(storage.FakeObject{Key: prefix + "artifacts/session_recap.md", Data: []byte("source candidate")})
|
||||
store.SeedObject(storage.FakeObject{Key: prefix + "archive/session_recap.md", Data: []byte("published candidate")})
|
||||
|
||||
_, err := Resolve(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{{Name: "session_recap", Required: true}}, store)
|
||||
if err == nil || !strings.Contains(err.Error(), "matches multiple published candidates") {
|
||||
t.Fatalf("Resolve() error = %v, want legacy ambiguity error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHonorsCancellation(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := Resolve(ctx, cfg, paths, []artifacts.PreviousArtifactRequirement{{Name: "session_recap", Required: true}}, &storage.FakeBackend{})
|
||||
if err == nil || !strings.Contains(err.Error(), "context canceled") {
|
||||
t.Fatalf("Resolve() error = %v, want cancellation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSkipsMissingOptionalPreviousArtifact(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
store := &storage.FakeBackend{}
|
||||
@@ -238,3 +310,91 @@ func recordRelPaths(records []Record) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findRecord(records []Record, kind string) *Record {
|
||||
for index := range records {
|
||||
if records[index].Kind == kind {
|
||||
return &records[index]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func seedCommittedPreviousState(t *testing.T, store *storage.FakeBackend, cfg *config.Config, m *manifest.Manifest, published []artifacts.RemoteArtifact) string {
|
||||
t.Helper()
|
||||
prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
||||
runID := "previous-run"
|
||||
manifestKey := artifacts.S3RunSessionManifestKey(prefix, runID)
|
||||
manifestData, err := marshalManifestForPreviousCacheTest(m)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal previous manifest: %v", err)
|
||||
}
|
||||
manifestGeneration := "manifest-generation"
|
||||
commit := artifacts.RemoteCommitManifest{
|
||||
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||
Campaign: cfg.Session.Campaign,
|
||||
SessionID: cfg.Session.PreviousSessionID,
|
||||
RunID: runID,
|
||||
Artifacts: append([]artifacts.RemoteArtifact{{
|
||||
Type: artifacts.RemoteArtifactTypeSessionManifest,
|
||||
Source: "session.manifest",
|
||||
DestinationKey: manifestKey,
|
||||
SHA256: sha256ForPreviousCacheTest(manifestData),
|
||||
Size: int64(len(manifestData)),
|
||||
Generation: manifestGeneration,
|
||||
}}, published...),
|
||||
}
|
||||
commitData, err := artifacts.EncodeRemoteCommitManifest(commit)
|
||||
if err != nil {
|
||||
t.Fatalf("encode committed previous state: %v", err)
|
||||
}
|
||||
commitKey := artifacts.S3RunCommitKey(prefix, runID)
|
||||
commitGeneration := "commit-generation"
|
||||
pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{
|
||||
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||
Campaign: cfg.Session.Campaign,
|
||||
SessionID: cfg.Session.PreviousSessionID,
|
||||
RunID: runID,
|
||||
CommitKey: commitKey,
|
||||
CommitSHA256: sha256ForPreviousCacheTest(commitData),
|
||||
CommitSize: int64(len(commitData)),
|
||||
CommitGeneration: commitGeneration,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encode committed previous pointer: %v", err)
|
||||
}
|
||||
|
||||
store.SeedObject(storage.FakeObject{Key: artifacts.S3CurrentCommitPointerKey(prefix), Data: pointerData, ETag: "pointer-generation"})
|
||||
store.SeedObject(storage.FakeObject{Key: commitKey, Data: commitData, ETag: commitGeneration})
|
||||
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: manifestData, ETag: manifestGeneration})
|
||||
for _, artifact := range published {
|
||||
store.SeedObject(storage.FakeObject{Key: artifact.DestinationKey, Data: publishedDataForTest(artifact.Source), ETag: artifact.Generation})
|
||||
}
|
||||
return published[0].DestinationKey
|
||||
}
|
||||
|
||||
func committedPublishedArtifactForTest(source, destination string, data []byte, generation string) artifacts.RemoteArtifact {
|
||||
return artifacts.RemoteArtifact{
|
||||
Type: artifacts.RemoteArtifactTypePublishedOutput,
|
||||
Source: source,
|
||||
DestinationKey: "dnd/campaigns/sample-campaign/sessions/2026-04-26/runs/previous-run/published/" + destination,
|
||||
SHA256: sha256ForPreviousCacheTest(data),
|
||||
Size: int64(len(data)),
|
||||
Generation: generation,
|
||||
}
|
||||
}
|
||||
|
||||
func publishedDataForTest(source string) []byte {
|
||||
if source == "narratio.artifact.session_recap" {
|
||||
return []byte("# recap\n")
|
||||
}
|
||||
if source == "narratio.artifact.player_handout" {
|
||||
return []byte("handout")
|
||||
}
|
||||
return []byte("published")
|
||||
}
|
||||
|
||||
func sha256ForPreviousCacheTest(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user