519 lines
20 KiB
Go
519 lines
20 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
)
|
|
|
|
func TestRestorePlanDefaultScope(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
|
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
|
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
|
seedRestoreObject(store, current.SessionPrefix+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}"))
|
|
seedRestoreObject(store, current.SessionPrefix+"logs/publish.log", []byte("log"))
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
got := actionRelPaths(plan.Actions)
|
|
want := []string{"artifacts/session_recap.md", "manifest.json", "transcripts/full.json"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
|
}
|
|
if plan.DownloadCount != 3 || plan.SkipSameCount != 0 || plan.ConflictCount != 0 {
|
|
t.Fatalf("counts = download=%d skip_same=%d conflict=%d, want 3/0/0", plan.DownloadCount, plan.SkipSameCount, plan.ConflictCount)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanIncludeAudio(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
got := actionRelPaths(plan.Actions)
|
|
want := []string{"audio/alice.flac", "manifest.json"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanExistingAudioRequiresVerifiedReplacement(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "local")
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
if len(store.Downloads) != 0 {
|
|
t.Fatalf("downloads = %d, want no remote checksum download for audio", len(store.Downloads))
|
|
}
|
|
actionByRel := map[string]RestoreAction{}
|
|
for _, action := range plan.Actions {
|
|
actionByRel[action.LocalRelativePath] = action
|
|
}
|
|
audioAction := actionByRel["audio/alice.flac"]
|
|
if audioAction.Kind != RestoreActionConflict {
|
|
t.Fatalf("audio action kind = %q, want %q", audioAction.Kind, RestoreActionConflict)
|
|
}
|
|
|
|
forcedPlan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true, Force: true})
|
|
if err != nil {
|
|
t.Fatalf("forced buildRestorePlan() error = %v", err)
|
|
}
|
|
for _, action := range forcedPlan.Actions {
|
|
if action.LocalRelativePath == "audio/alice.flac" && action.Kind != RestoreActionDownload {
|
|
t.Fatalf("forced audio action kind = %q, want %q", action.Kind, RestoreActionDownload)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
configureRestorePlanPreviousRequirement(cfg, true)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestorePreviousCurrent(t, store, cfg, "# previous recap\n")
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
got := actionRelPaths(plan.Actions)
|
|
want := []string{"manifest.json", "previous/artifacts/session_recap.md", "previous/manifest.json"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanIgnoresCurrentSessionArchivedPreviousCache(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`))
|
|
seedRestoreObject(store, current.SessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
got := actionRelPaths(plan.Actions)
|
|
want := []string{"manifest.json"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanMissingOptionalPreviousCacheSkipsArtifact(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
configureRestorePlanPreviousRequirement(cfg, false)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
got := actionRelPaths(plan.Actions)
|
|
want := []string{"manifest.json", "previous/manifest.json"}
|
|
if !slices.Equal(got, want) {
|
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanMissingRequiredPreviousCacheFails(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
configureRestorePlanPreviousRequirement(cfg, true)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
|
|
|
|
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "required previous-session artifact") {
|
|
t.Fatalf("buildRestorePlan() error = %v, want required previous artifact failure", err)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanPreviousCacheConflictRequiresForce(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
configureRestorePlanPreviousRequirement(cfg, true)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedRestorePreviousCurrent(t, store, cfg, "# remote previous recap\n")
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
if plan.ConflictCount != 1 {
|
|
t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount)
|
|
}
|
|
|
|
plan, err = buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan(force) error = %v", err)
|
|
}
|
|
if plan.ConflictCount != 0 {
|
|
t.Fatalf("force ConflictCount = %d, want 0", plan.ConflictCount)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanClassifiesSameAndConflict(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
|
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1]}`)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
if plan.SkipSameCount != 1 {
|
|
t.Fatalf("SkipSameCount = %d, want 1", plan.SkipSameCount)
|
|
}
|
|
if plan.ConflictCount != 1 {
|
|
t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount)
|
|
}
|
|
|
|
actionByRel := map[string]RestoreAction{}
|
|
for _, action := range plan.Actions {
|
|
actionByRel[action.LocalRelativePath] = action
|
|
}
|
|
if actionByRel["transcripts/full.json"].Kind != RestoreActionSkipSame {
|
|
t.Fatalf("transcripts/full.json kind = %q, want %q", actionByRel["transcripts/full.json"].Kind, RestoreActionSkipSame)
|
|
}
|
|
if actionByRel["artifacts/session_recap.md"].Kind != RestoreActionConflict {
|
|
t.Fatalf("artifacts/session_recap.md kind = %q, want %q", actionByRel["artifacts/session_recap.md"].Kind, RestoreActionConflict)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanForceTurnsConflictsIntoDownloads(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
|
|
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
|
|
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
|
}
|
|
|
|
actionByRel := map[string]RestoreAction{}
|
|
for _, action := range plan.Actions {
|
|
actionByRel[action.LocalRelativePath] = action
|
|
}
|
|
recap := actionByRel["artifacts/session_recap.md"]
|
|
if recap.Kind != RestoreActionDownload {
|
|
t.Fatalf("artifacts/session_recap.md kind = %q, want %q", recap.Kind, RestoreActionDownload)
|
|
}
|
|
if plan.ConflictCount != 0 {
|
|
t.Fatalf("ConflictCount = %d, want 0", plan.ConflictCount)
|
|
}
|
|
}
|
|
|
|
func TestRestorePlanSymlinksRemainNonRegularConflicts(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
setup func(t *testing.T, sessionRoot string)
|
|
}{
|
|
{
|
|
name: "leaf",
|
|
setup: func(t *testing.T, sessionRoot string) {
|
|
t.Helper()
|
|
outside := filepath.Join(t.TempDir(), "recap.md")
|
|
mustWriteTestFile(t, outside, "remote-content\n")
|
|
if err := os.MkdirAll(filepath.Join(sessionRoot, "artifacts"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll() error = %v", err)
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(sessionRoot, "artifacts", "session_recap.md")); err != nil {
|
|
t.Skipf("Symlink() unavailable: %v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "ancestor",
|
|
setup: func(t *testing.T, sessionRoot string) {
|
|
t.Helper()
|
|
outside := t.TempDir()
|
|
mustWriteTestFile(t, filepath.Join(outside, "session_recap.md"), "remote-content\n")
|
|
if err := os.Symlink(outside, filepath.Join(sessionRoot, "artifacts")); err != nil {
|
|
t.Skipf("Symlink() unavailable: %v", err)
|
|
}
|
|
},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
|
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
tt.setup(t, sessionRoot)
|
|
|
|
for _, options := range []RestorePlanOptions{{DryRun: true}, {Force: true}} {
|
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, options)
|
|
if err != nil {
|
|
t.Fatalf("buildRestorePlan(%+v) error = %v", options, err)
|
|
}
|
|
action := restoreActionForPath(t, plan, "artifacts/session_recap.md")
|
|
if action.Kind != RestoreActionConflict || action.ConflictKind != RestoreConflictNonRegular {
|
|
t.Fatalf("action = %#v, want non-regular conflict", action)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func restoreActionForPath(t *testing.T, plan *RestorePlan, path string) RestoreAction {
|
|
t.Helper()
|
|
for _, action := range plan.Actions {
|
|
if action.LocalRelativePath == path {
|
|
return action
|
|
}
|
|
}
|
|
t.Fatalf("restore action for %q not found", path)
|
|
return RestoreAction{}
|
|
}
|
|
|
|
func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) {
|
|
cfg := restorePlanConfig(t)
|
|
current := restorePlanCurrentState(t, cfg)
|
|
store := &storage.FakeBackend{}
|
|
|
|
seedCurrentRestoreObject(t, store, current, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
|
seedCurrentRestoreObject(t, store, current, current.SessionPrefix+"artifacts/../../escape.txt", []byte("bad"))
|
|
|
|
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "clean relative object key") {
|
|
t.Fatalf("error = %v, want traversal safety failure", err)
|
|
}
|
|
}
|
|
|
|
func seedCurrentRestoreObject(t *testing.T, store *storage.FakeBackend, current *RemoteCurrentState, key string, data []byte) {
|
|
t.Helper()
|
|
artifactType := artifacts.RemoteArtifactTypePublishedOutput
|
|
sourceKey := strings.NewReplacer("/", "_", ".", "_", "-", "_").Replace(strings.TrimPrefix(key, current.SessionPrefix))
|
|
source := artifacts.ConfiguredArtifactSourceID(sourceKey)
|
|
destination := key
|
|
if key == current.CurrentManifestKey {
|
|
artifactType = artifacts.RemoteArtifactTypeSessionManifest
|
|
source = "session.manifest"
|
|
current.ManifestData = append([]byte(nil), data...)
|
|
} else if strings.HasPrefix(key, current.SessionPrefix) {
|
|
destination = artifacts.S3RunPrefix(current.SessionPrefix, current.RunID) + "published/" + strings.TrimPrefix(key, current.SessionPrefix)
|
|
}
|
|
seedRestoreObject(store, destination, data)
|
|
current.Commit.Artifacts = append(current.Commit.Artifacts, remoteRestoreArtifact(store, artifactType, source, destination))
|
|
}
|
|
|
|
func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) {
|
|
if strings.HasSuffix(key, "/current/commit-pointer.json") {
|
|
if _, exists := store.Objects[key]; exists {
|
|
return
|
|
}
|
|
}
|
|
store.SeedObject(storage.FakeObject{Key: key, Data: data})
|
|
for pointerKey, pointerObject := range store.Objects {
|
|
if !strings.HasSuffix(pointerKey, "/current/commit-pointer.json") || key == pointerKey {
|
|
continue
|
|
}
|
|
pointer, err := artifacts.DecodeCurrentCommitPointer(pointerObject.Data)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
commitObject, ok := store.Objects[pointer.CommitKey]
|
|
if !ok {
|
|
continue
|
|
}
|
|
commit, err := artifacts.DecodeRemoteCommitManifest(commitObject.Data)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
runPrefix := strings.TrimSuffix(pointer.CommitKey, "commit.json")
|
|
sessionPrefix := strings.TrimSuffix(runPrefix, "runs/"+pointer.RunID+"/")
|
|
if !strings.HasPrefix(key, sessionPrefix) {
|
|
continue
|
|
}
|
|
destination := key
|
|
if strings.HasPrefix(key, sessionPrefix) && !strings.HasPrefix(key, runPrefix) {
|
|
relative := strings.TrimPrefix(key, sessionPrefix)
|
|
destination = runPrefix + "published/" + relative
|
|
store.SeedObject(storage.FakeObject{Key: destination, Data: data})
|
|
}
|
|
typeID := artifacts.RemoteArtifactTypePublishedOutput
|
|
source := artifacts.ConfiguredArtifactSourceID("test")
|
|
if destination == artifacts.S3RunSessionManifestKey(sessionPrefix, pointer.RunID) {
|
|
typeID, source = artifacts.RemoteArtifactTypeSessionManifest, "session.manifest"
|
|
} else if relative := strings.TrimPrefix(destination, runPrefix); strings.HasPrefix(relative, "artifacts/") || strings.HasPrefix(relative, "published/artifacts/") {
|
|
name := strings.TrimPrefix(relative, "published/")
|
|
name = strings.TrimSuffix(strings.TrimPrefix(name, "artifacts/"), filepath.Ext(name))
|
|
source = artifacts.ConfiguredArtifactSourceID(name)
|
|
}
|
|
artifact := remoteRestoreArtifact(store, typeID, source, destination)
|
|
replaced := false
|
|
for index := range commit.Artifacts {
|
|
if commit.Artifacts[index].DestinationKey == destination {
|
|
commit.Artifacts[index] = artifact
|
|
replaced = true
|
|
}
|
|
}
|
|
if !replaced {
|
|
commit.Artifacts = append(commit.Artifacts, artifact)
|
|
}
|
|
commitData, err := artifacts.EncodeRemoteCommitManifest(*commit)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
store.SeedObject(storage.FakeObject{Key: pointer.CommitKey, Data: commitData})
|
|
commitObject = store.Objects[pointer.CommitKey]
|
|
pointer.CommitSHA256 = restoreCommitSHA256(commitData)
|
|
pointer.CommitSize = int64(len(commitData))
|
|
pointer.CommitGeneration = commitObject.ETag
|
|
pointerData, err := artifacts.EncodeCurrentCommitPointer(*pointer)
|
|
if err == nil {
|
|
store.SeedObject(storage.FakeObject{Key: pointerKey, Data: pointerData})
|
|
}
|
|
}
|
|
}
|
|
|
|
func actionRelPaths(actions []RestoreAction) []string {
|
|
out := make([]string, 0, len(actions))
|
|
for _, action := range actions {
|
|
out = append(out, action.LocalRelativePath)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func restorePlanConfig(t *testing.T) *config.Config {
|
|
t.Helper()
|
|
workspaceRoot := t.TempDir()
|
|
return &config.Config{
|
|
Pipeline: &config.PipelineConfig{
|
|
Workspace: config.WorkspaceConfig{Root: workspaceRoot},
|
|
Storage: config.StorageConfig{S3: &config.StorageS3Config{
|
|
Bucket: "test-bucket",
|
|
RootPrefix: "dnd",
|
|
}},
|
|
},
|
|
Session: &config.SessionConfig{
|
|
SessionID: "2026-05-03",
|
|
Campaign: "sample-campaign",
|
|
},
|
|
}
|
|
}
|
|
|
|
func configureRestorePlanPreviousRequirement(cfg *config.Config, required bool) {
|
|
cfg.Session.PreviousSessionID = "2026-04-26"
|
|
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
|
|
Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
|
"session_recap": {
|
|
Enabled: true,
|
|
PromptID: "dnd.session_recap",
|
|
OutputPath: "artifacts/session_recap.md",
|
|
Inputs: map[string]config.ScriptoriumInputConfig{
|
|
"previous_recap": {
|
|
Source: "narratio.previous_session.artifact.session_recap",
|
|
Required: required,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState {
|
|
t.Helper()
|
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
|
return &RemoteCurrentState{
|
|
Bucket: "test-bucket",
|
|
SessionPrefix: sessionPrefix,
|
|
CurrentManifestKey: artifacts.S3RunSessionManifestKey(sessionPrefix, "20260519T010203Z-a1b2c3d4"),
|
|
RunID: "20260519T010203Z-a1b2c3d4",
|
|
SessionID: cfg.Session.SessionID,
|
|
Campaign: cfg.Session.Campaign,
|
|
Commit: &artifacts.RemoteCommitManifest{
|
|
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
|
Campaign: cfg.Session.Campaign,
|
|
SessionID: cfg.Session.SessionID,
|
|
RunID: "20260519T010203Z-a1b2c3d4",
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestWriteRestorePlan(t *testing.T) {
|
|
current := &RemoteCurrentState{Campaign: "sample-campaign", SessionID: "2026-05-03", RunID: "r-1"}
|
|
plan := &RestorePlan{Actions: []RestoreAction{{Kind: RestoreActionDownload, LocalRelativePath: "manifest.json", RemoteKey: "k", Reason: "local file missing"}}, DownloadCount: 1}
|
|
var out strings.Builder
|
|
if err := writeRestorePlan(&out, current, plan, RestorePlanOptions{DryRun: true}); err != nil {
|
|
t.Fatalf("writeRestorePlan() error = %v", err)
|
|
}
|
|
text := out.String()
|
|
if !strings.Contains(text, "restore plan: session sample-campaign/2026-05-03 run=r-1") {
|
|
t.Fatalf("output = %q, want plan summary", text)
|
|
}
|
|
if !strings.Contains(text, "download manifest.json <- k") {
|
|
t.Fatalf("output = %q, want action line", text)
|
|
}
|
|
}
|