Upgraded the restore command to download previous session artifcats when configured as inputs for the current session analyze stage
This commit is contained in:
495
internal/previouscache/previouscache.go
Normal file
495
internal/previouscache/previouscache.go
Normal file
@@ -0,0 +1,495 @@
|
||||
package previouscache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
const (
|
||||
InputKindManifest = "previous_manifest"
|
||||
InputKindArtifact = "previous_artifact"
|
||||
InputSource = "previous_session_archive.current"
|
||||
)
|
||||
|
||||
type Plan struct {
|
||||
Records []Record
|
||||
SkippedMissing []string
|
||||
PreviousRunID string
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
Kind string
|
||||
RequirementName string
|
||||
Required bool
|
||||
LocalRelativePath string
|
||||
LocalPath string
|
||||
RemoteKey string
|
||||
S3Bucket string
|
||||
}
|
||||
|
||||
func BuildPlan(
|
||||
ctx context.Context,
|
||||
cfg *config.Config,
|
||||
paths artifacts.SessionPaths,
|
||||
requirements []artifacts.PreviousArtifactRequirement,
|
||||
store storage.ObjectStore,
|
||||
) (*Plan, error) {
|
||||
if len(requirements) == 0 {
|
||||
return &Plan{}, nil
|
||||
}
|
||||
if cfg == nil || cfg.Session == nil || cfg.Pipeline == nil {
|
||||
return nil, fmt.Errorf("resolved config with session/pipeline is required")
|
||||
}
|
||||
|
||||
orderedRequirements := append([]artifacts.PreviousArtifactRequirement(nil), requirements...)
|
||||
sort.Slice(orderedRequirements, func(i, j int) bool {
|
||||
return orderedRequirements[i].Name < orderedRequirements[j].Name
|
||||
})
|
||||
|
||||
requiredNames := requiredPreviousArtifactNames(orderedRequirements)
|
||||
optionalNames := optionalPreviousArtifactNames(orderedRequirements)
|
||||
previousSessionID := strings.TrimSpace(cfg.Session.PreviousSessionID)
|
||||
if previousSessionID == "" {
|
||||
if len(requiredNames) > 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"previous_session_id is required for required previous-session artifacts: %s",
|
||||
strings.Join(requiredNames, ", "),
|
||||
)
|
||||
}
|
||||
return &Plan{SkippedMissing: optionalNames}, nil
|
||||
}
|
||||
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("previous-session artifact hydration requires object store backend")
|
||||
}
|
||||
if cfg.Pipeline.Storage.S3 == nil {
|
||||
return nil, fmt.Errorf("pipeline.storage.s3 configuration is required for previous-session artifact hydration")
|
||||
}
|
||||
|
||||
campaign := strings.TrimSpace(cfg.Session.Campaign)
|
||||
if campaign == "" {
|
||||
return nil, fmt.Errorf("session campaign is required for previous-session artifact hydration")
|
||||
}
|
||||
bucket := strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket)
|
||||
if bucket == "" {
|
||||
return nil, fmt.Errorf("pipeline.storage.s3.bucket is required for previous-session artifact hydration")
|
||||
}
|
||||
|
||||
previousSessionPrefix := artifacts.S3SessionPrefix(
|
||||
cfg.Pipeline.Storage.S3.RootPrefix,
|
||||
campaign,
|
||||
previousSessionID,
|
||||
)
|
||||
currentManifestKey, currentRunIDKey := artifacts.ResolveArchiveCurrentStateKeys(previousSessionPrefix)
|
||||
|
||||
result := &Plan{}
|
||||
|
||||
runPointerExists, err := store.Exists(ctx, currentRunIDKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check previous-session current run pointer %q: %w", currentRunIDKey, err)
|
||||
}
|
||||
if !runPointerExists {
|
||||
if len(requiredNames) > 0 {
|
||||
return nil, fmt.Errorf("required previous-session artifacts unavailable: remote current run pointer missing: %q", currentRunIDKey)
|
||||
}
|
||||
result.SkippedMissing = optionalNames
|
||||
return result, nil
|
||||
}
|
||||
|
||||
runIDTemp, err := downloadObjectToTemp(ctx, store, currentRunIDKey, "narratio-previous-run-id-*.txt")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download previous-session current run pointer %q: %w", currentRunIDKey, err)
|
||||
}
|
||||
defer func() { _ = os.Remove(runIDTemp) }()
|
||||
|
||||
runIDBytes, err := os.ReadFile(runIDTemp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read previous-session current run pointer %q: %w", currentRunIDKey, err)
|
||||
}
|
||||
previousRunID := strings.TrimSpace(string(runIDBytes))
|
||||
if previousRunID == "" {
|
||||
return nil, fmt.Errorf("previous-session current run pointer %q is empty", currentRunIDKey)
|
||||
}
|
||||
result.PreviousRunID = previousRunID
|
||||
|
||||
manifestExists, err := store.Exists(ctx, currentManifestKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check previous-session current manifest %q: %w", currentManifestKey, err)
|
||||
}
|
||||
if !manifestExists {
|
||||
if len(requiredNames) > 0 {
|
||||
return nil, fmt.Errorf("required previous-session artifacts unavailable: remote current manifest missing: %q", currentManifestKey)
|
||||
}
|
||||
result.SkippedMissing = optionalNames
|
||||
return result, nil
|
||||
}
|
||||
|
||||
manifestTemp, err := downloadObjectToTemp(ctx, store, currentManifestKey, "narratio-previous-manifest-*.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download previous-session current manifest %q: %w", currentManifestKey, err)
|
||||
}
|
||||
defer func() { _ = os.Remove(manifestTemp) }()
|
||||
|
||||
manifestStore := &manifest.LocalStore{}
|
||||
previousManifest, err := manifestStore.Load(ctx, manifestTemp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode downloaded previous-session manifest %q: %w", currentManifestKey, err)
|
||||
}
|
||||
if strings.TrimSpace(previousManifest.SessionID) != previousSessionID {
|
||||
return nil, fmt.Errorf(
|
||||
"previous-session manifest session_id %q does not match configured previous_session_id %q",
|
||||
strings.TrimSpace(previousManifest.SessionID),
|
||||
previousSessionID,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(previousManifest.Campaign) != campaign {
|
||||
return nil, fmt.Errorf(
|
||||
"previous-session manifest campaign %q does not match current campaign %q",
|
||||
strings.TrimSpace(previousManifest.Campaign),
|
||||
campaign,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(previousManifest.RunID) == "" {
|
||||
return nil, fmt.Errorf("previous-session manifest run_id is required")
|
||||
}
|
||||
if strings.TrimSpace(previousManifest.RunID) != previousRunID {
|
||||
return nil, fmt.Errorf(
|
||||
"previous-session current run pointer %q references run %q but current manifest run_id is %q",
|
||||
currentRunIDKey,
|
||||
previousRunID,
|
||||
strings.TrimSpace(previousManifest.RunID),
|
||||
)
|
||||
}
|
||||
|
||||
manifestRel, err := relativeToSession(paths, paths.PreviousManifestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Records = append(result.Records, Record{
|
||||
Kind: InputKindManifest,
|
||||
LocalRelativePath: manifestRel,
|
||||
LocalPath: paths.PreviousManifestPath,
|
||||
RemoteKey: currentManifestKey,
|
||||
S3Bucket: bucket,
|
||||
})
|
||||
|
||||
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/archive",
|
||||
requirement.Name,
|
||||
)
|
||||
}
|
||||
result.SkippedMissing = append(result.SkippedMissing, requirement.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
selectedRel := ""
|
||||
selectedKey := ""
|
||||
for _, candidate := range candidates {
|
||||
remoteKey := artifacts.S3PromotedArtifactKey(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 {
|
||||
continue
|
||||
}
|
||||
selectedRel = candidate
|
||||
selectedKey = remoteKey
|
||||
break
|
||||
}
|
||||
if selectedRel == "" {
|
||||
if requirement.Required {
|
||||
return nil, fmt.Errorf(
|
||||
"required previous-session artifact %q object missing from archive candidate keys",
|
||||
requirement.Name,
|
||||
)
|
||||
}
|
||||
result.SkippedMissing = append(result.SkippedMissing, requirement.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
localPath := artifacts.SessionPreviousArtifactPath(paths, selectedRel)
|
||||
localRel, err := relativeToSession(paths, localPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Records = append(result.Records, Record{
|
||||
Kind: InputKindArtifact,
|
||||
RequirementName: requirement.Name,
|
||||
Required: requirement.Required,
|
||||
LocalRelativePath: localRel,
|
||||
LocalPath: localPath,
|
||||
RemoteKey: selectedKey,
|
||||
S3Bucket: bucket,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Strings(result.SkippedMissing)
|
||||
sort.Slice(result.Records, func(i, j int) bool {
|
||||
if result.Records[i].LocalRelativePath != result.Records[j].LocalRelativePath {
|
||||
return result.Records[i].LocalRelativePath < result.Records[j].LocalRelativePath
|
||||
}
|
||||
return result.Records[i].RemoteKey < result.Records[j].RemoteKey
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func requiredPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequirement) []string {
|
||||
names := make([]string, 0, len(requirements))
|
||||
for _, requirement := range requirements {
|
||||
if requirement.Required {
|
||||
names = append(names, strings.TrimSpace(requirement.Name))
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func optionalPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequirement) []string {
|
||||
names := make([]string, 0, len(requirements))
|
||||
for _, requirement := range requirements {
|
||||
if requirement.Required {
|
||||
continue
|
||||
}
|
||||
names = append(names, strings.TrimSpace(requirement.Name))
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func artifactRelativePathCandidates(
|
||||
artifactName string,
|
||||
previousManifest *manifest.Manifest,
|
||||
cfg *config.Config,
|
||||
) []string {
|
||||
candidates := []string{}
|
||||
appendCandidate := func(v string) {
|
||||
normalized, err := normalizeArchiveRelativePath(v)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
candidates = append(candidates, normalized)
|
||||
}
|
||||
|
||||
sourceID := artifacts.ConfiguredArtifactSourceID(artifactName)
|
||||
if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok {
|
||||
appendCandidate(rel)
|
||||
base := path.Base(rel)
|
||||
for _, promoted := range manifestPromotedPaths(previousManifest) {
|
||||
if path.Base(promoted) == base {
|
||||
appendCandidate(promoted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
|
||||
if artifactCfg, ok := cfg.Pipeline.Scriptorium.Artifacts[artifactName]; ok {
|
||||
appendCandidate(artifactCfg.OutputPath)
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeOrderedStrings(candidates)
|
||||
}
|
||||
|
||||
func manifestArtifactRelativePathBySourceID(previousManifest *manifest.Manifest, sourceID string) (string, bool) {
|
||||
if previousManifest == nil || len(previousManifest.Stages) == 0 {
|
||||
return "", false
|
||||
}
|
||||
sourceID = strings.TrimSpace(sourceID)
|
||||
if sourceID == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
stageNames := make([]string, 0, len(previousManifest.Stages))
|
||||
if _, ok := previousManifest.Stages["analyze"]; ok {
|
||||
stageNames = append(stageNames, "analyze")
|
||||
}
|
||||
for stageName := range previousManifest.Stages {
|
||||
if stageName == "analyze" {
|
||||
continue
|
||||
}
|
||||
stageNames = append(stageNames, stageName)
|
||||
}
|
||||
start := 0
|
||||
if len(stageNames) > 0 && stageNames[0] == "analyze" {
|
||||
start = 1
|
||||
}
|
||||
sort.Strings(stageNames[start:])
|
||||
|
||||
for _, stageName := range stageNames {
|
||||
sr := previousManifest.Stages[stageName]
|
||||
if sr == nil {
|
||||
continue
|
||||
}
|
||||
for _, out := range sr.Outputs {
|
||||
if strings.TrimSpace(out.SourceID) != sourceID {
|
||||
continue
|
||||
}
|
||||
rel, ok := deriveManifestRelativePath(previousManifest, out.LocalPath)
|
||||
if ok {
|
||||
return rel, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func deriveManifestRelativePath(previousManifest *manifest.Manifest, localPath string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(localPath)
|
||||
if trimmed == "" {
|
||||
return "", false
|
||||
}
|
||||
if !filepath.IsAbs(trimmed) {
|
||||
normalized, err := normalizeArchiveRelativePath(filepath.ToSlash(trimmed))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
sessionRoot, ok := manifestSessionRoot(previousManifest)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rel, err := filepath.Rel(sessionRoot, trimmed)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
normalized, err := normalizeArchiveRelativePath(filepath.ToSlash(rel))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func manifestSessionRoot(previousManifest *manifest.Manifest) (string, bool) {
|
||||
if previousManifest == nil {
|
||||
return "", false
|
||||
}
|
||||
runRoot := filepath.Clean(strings.TrimSpace(previousManifest.LocalWorkDir))
|
||||
runID := strings.TrimSpace(previousManifest.RunID)
|
||||
if runRoot == "" || runID == "" {
|
||||
return "", false
|
||||
}
|
||||
if filepath.Base(runRoot) != runID {
|
||||
return "", false
|
||||
}
|
||||
runsDir := filepath.Dir(runRoot)
|
||||
if filepath.Base(runsDir) != config.PathRunsDirSegment {
|
||||
return "", false
|
||||
}
|
||||
return filepath.Dir(runsDir), true
|
||||
}
|
||||
|
||||
func manifestPromotedPaths(previousManifest *manifest.Manifest) []string {
|
||||
if previousManifest == nil || len(previousManifest.Stages) == 0 {
|
||||
return nil
|
||||
}
|
||||
sr := previousManifest.Stages["archive"]
|
||||
if sr == nil || sr.Metadata == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := sr.Metadata["promoted_paths"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
values, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
asString, ok := value.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalized, err := normalizeArchiveRelativePath(asString)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, normalized)
|
||||
}
|
||||
return dedupeOrderedStrings(out)
|
||||
}
|
||||
|
||||
func normalizeArchiveRelativePath(rel string) (string, error) {
|
||||
trimmed := strings.TrimSpace(rel)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("relative path is required")
|
||||
}
|
||||
cleaned := filepath.ToSlash(filepath.Clean(filepath.FromSlash(trimmed)))
|
||||
if cleaned == "." || cleaned == "" {
|
||||
return "", fmt.Errorf("relative path is required")
|
||||
}
|
||||
if filepath.IsAbs(trimmed) || strings.HasPrefix(cleaned, "/") || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
||||
return "", fmt.Errorf("path must be a clean relative path")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func relativeToSession(paths artifacts.SessionPaths, localPath string) (string, error) {
|
||||
root := filepath.Clean(paths.Root)
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return "", fmt.Errorf("session root is required")
|
||||
}
|
||||
rel, err := filepath.Rel(root, filepath.Clean(localPath))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve previous-cache relative path: %w", err)
|
||||
}
|
||||
normalized, err := normalizeArchiveRelativePath(filepath.ToSlash(rel))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve previous-cache relative path: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func dedupeOrderedStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func downloadObjectToTemp(ctx context.Context, store storage.ObjectStore, key, pattern string) (string, error) {
|
||||
tmp, err := os.CreateTemp("", pattern)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
path := tmp.Name()
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return "", fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := store.Download(ctx, key, path); err != nil {
|
||||
_ = os.Remove(path)
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
180
internal/previouscache/previouscache_test.go
Normal file
180
internal/previouscache/previouscache_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package previouscache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func TestBuildPlanResolvesPromotedArtifactFromPreviousManifest(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
store := &storage.FakeBackend{}
|
||||
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"artifacts/session_recap.md"}))
|
||||
previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
||||
store.SeedObject(storage.FakeObject{Key: previousPrefix + "artifacts/session_recap.md", Data: []byte("# recap\n")})
|
||||
|
||||
plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
|
||||
{Name: "session_recap", Required: true},
|
||||
}, store)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPlan() error = %v", err)
|
||||
}
|
||||
if plan.PreviousRunID != "previous-run" {
|
||||
t.Fatalf("PreviousRunID = %q, want previous-run", plan.PreviousRunID)
|
||||
}
|
||||
got := recordRelPaths(plan.Records)
|
||||
want := []string{"previous/artifacts/session_recap.md", "previous/manifest.json"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("record rel paths = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanFallsBackToConfiguredOutputPath(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
store := &storage.FakeBackend{}
|
||||
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil))
|
||||
previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
||||
store.SeedObject(storage.FakeObject{Key: previousPrefix + "artifacts/session_recap.md", Data: []byte("# recap\n")})
|
||||
|
||||
plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
|
||||
{Name: "session_recap", Required: true},
|
||||
}, store)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPlan() error = %v", err)
|
||||
}
|
||||
if len(plan.Records) != 2 {
|
||||
t.Fatalf("records len = %d, want 2", len(plan.Records))
|
||||
}
|
||||
if plan.Records[0].LocalRelativePath != "previous/artifacts/session_recap.md" {
|
||||
t.Fatalf("artifact local relative path = %q", plan.Records[0].LocalRelativePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSkipsMissingOptionalPreviousArtifact(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
store := &storage.FakeBackend{}
|
||||
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil))
|
||||
|
||||
plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
|
||||
{Name: "session_recap", Required: false},
|
||||
}, store)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPlan() error = %v", err)
|
||||
}
|
||||
if strings.Join(plan.SkippedMissing, ",") != "session_recap" {
|
||||
t.Fatalf("SkippedMissing = %#v, want session_recap", plan.SkippedMissing)
|
||||
}
|
||||
if len(plan.Records) != 1 || plan.Records[0].Kind != InputKindManifest {
|
||||
t.Fatalf("records = %#v, want manifest only", plan.Records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanMissingRequiredPreviousArtifactFails(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
store := &storage.FakeBackend{}
|
||||
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil))
|
||||
|
||||
_, err := BuildPlan(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("BuildPlan() error = %v, want required missing error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanValidatesPreviousManifestIdentity(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
store := &storage.FakeBackend{}
|
||||
manifest := previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", nil)
|
||||
manifest.SessionID = "wrong-session"
|
||||
seedPreviousCurrent(t, store, cfg, manifest)
|
||||
|
||||
_, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
|
||||
{Name: "session_recap", Required: true},
|
||||
}, store)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match configured previous_session_id") {
|
||||
t.Fatalf("BuildPlan() error = %v, want identity validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func previousCacheTestConfig(t *testing.T) (*config.Config, artifacts.SessionPaths) {
|
||||
t.Helper()
|
||||
workspaceRoot := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Pipeline: &config.PipelineConfig{
|
||||
Workspace: config.WorkspaceConfig{Root: workspaceRoot},
|
||||
Storage: config.StorageConfig{S3: &config.StorageS3Config{
|
||||
Bucket: "test-bucket",
|
||||
RootPrefix: "dnd",
|
||||
}},
|
||||
Scriptorium: &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
||||
"session_recap": {
|
||||
Enabled: true,
|
||||
OutputPath: "artifacts/session_recap.md",
|
||||
},
|
||||
}},
|
||||
},
|
||||
Session: &config.SessionConfig{
|
||||
Campaign: "sample-campaign",
|
||||
SessionID: "2026-05-03",
|
||||
PreviousSessionID: "2026-04-26",
|
||||
},
|
||||
}
|
||||
return cfg, artifacts.NewLocalStore(workspaceRoot).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
}
|
||||
|
||||
func seedPreviousCurrent(t *testing.T, store *storage.FakeBackend, cfg *config.Config, m *manifest.Manifest) {
|
||||
t.Helper()
|
||||
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
||||
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(previousPrefix)
|
||||
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("previous-run\n")})
|
||||
data, err := marshalManifestForPreviousCacheTest(m)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal manifest: %v", err)
|
||||
}
|
||||
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: data})
|
||||
}
|
||||
|
||||
func previousManifestWithOutput(t *testing.T, cfg *config.Config, rel string, promoted []string) *manifest.Manifest {
|
||||
t.Helper()
|
||||
m := manifest.New(cfg.Session.PreviousSessionID, time.Date(2026, 4, 26, 10, 0, 0, 0, time.UTC))
|
||||
m.Campaign = cfg.Session.Campaign
|
||||
m.RunID = "previous-run"
|
||||
m.LocalWorkDir = filepath.Join("/var/lib/narratio/work", cfg.Session.Campaign, cfg.Session.PreviousSessionID, "runs", m.RunID)
|
||||
if strings.TrimSpace(rel) != "" {
|
||||
m.MarkStageSucceeded("analyze", time.Date(2026, 4, 26, 10, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{
|
||||
{SourceID: "narratio.artifact.session_recap", LocalPath: filepath.Join(filepath.Dir(filepath.Dir(m.LocalWorkDir)), filepath.FromSlash(rel))},
|
||||
})
|
||||
}
|
||||
if promoted != nil {
|
||||
if m.Stages["archive"] == nil {
|
||||
m.MarkStageSucceeded("archive", time.Date(2026, 4, 26, 10, 2, 0, 0, time.UTC), nil)
|
||||
}
|
||||
m.Stages["archive"].Metadata = map[string]any{"promoted_paths": promoted}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func marshalManifestForPreviousCacheTest(m *manifest.Manifest) ([]byte, error) {
|
||||
data, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
func recordRelPaths(records []Record) []string {
|
||||
out := make([]string, 0, len(records))
|
||||
for _, record := range records {
|
||||
out = append(out, record.LocalRelativePath)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user