diff --git a/internal/app/restore.go b/internal/app/restore.go index 55742ee..0f8a222 100644 --- a/internal/app/restore.go +++ b/internal/app/restore.go @@ -13,6 +13,7 @@ import ( var newObjectStoreFromConfigFn = storage.NewObjectStoreFromConfig var discoverRemoteCurrentStateFn = discoverRemoteCurrentState +var buildRestorePlanFn = buildRestorePlan // Restore validates restore CLI/config inputs and storage preflight for future restore phases. func Restore(ctx context.Context, args []string, out io.Writer) error { @@ -74,13 +75,32 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { if err != nil { return fmt.Errorf("restore: %w", err) } - - // Phase 3 boundary: discovery is implemented; planning/execution is deferred. - _ = dryRun - _ = force - _ = includeAudio + plan, err := buildRestorePlanFn(ctx, cfg, current, objectStore, RestorePlanOptions{ + IncludeAudio: includeAudio, + Force: force, + DryRun: dryRun, + }) + if err != nil { + return fmt.Errorf("restore: %w", err) + } + if err := writeRestorePlan(out, current, plan, RestorePlanOptions{ + IncludeAudio: includeAudio, + Force: force, + DryRun: dryRun, + }); err != nil { + return fmt.Errorf("restore: write plan output: %w", err) + } + if dryRun { + return nil + } + if plan.ConflictCount > 0 && !force { + return fmt.Errorf( + "restore: plan has %d conflicting path(s); rerun with --force or resolve local conflicts", + plan.ConflictCount, + ) + } return fmt.Errorf( - "restore: discovered remote current state for %s/%s (run %s); not yet implemented (phase 3: remote current-state discovery)", + "restore: planned remote restore for %s/%s (run %s); not yet implemented (phase 4: restore execution)", current.Campaign, current.SessionID, current.RunID, diff --git a/internal/app/restore_plan.go b/internal/app/restore_plan.go new file mode 100644 index 0000000..07ad78e --- /dev/null +++ b/internal/app/restore_plan.go @@ -0,0 +1,344 @@ +package app + +import ( + "context" + "fmt" + "io" + "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" +) + +// RestoreActionKind identifies one restore planner action. +type RestoreActionKind string + +const ( + RestoreActionDownload RestoreActionKind = "download" + RestoreActionSkipSame RestoreActionKind = "skip_same" + RestoreActionConflict RestoreActionKind = "conflict" +) + +// RestoreAction is one deterministic planner action. +type RestoreAction struct { + Kind RestoreActionKind + RemoteKey string + LocalRelativePath string + LocalPath string + Size int64 + ETag string + ExistsLocal bool + SameLocal bool + Conflict bool + Reason string +} + +// RestorePlan is the deterministic output of restore planning. +type RestorePlan struct { + Actions []RestoreAction + DownloadCount int + SkipSameCount int + ConflictCount int +} + +// RestorePlanOptions control restore planning scope and classification. +type RestorePlanOptions struct { + IncludeAudio bool + Force bool + DryRun bool +} + +func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCurrentState, store storage.ObjectStore, opts RestorePlanOptions) (*RestorePlan, error) { + if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil { + return nil, fmt.Errorf("resolved config with pipeline/session is required") + } + if current == nil { + return nil, fmt.Errorf("remote current state is required") + } + if store == nil { + return nil, fmt.Errorf("remote object store is required") + } + prefix := normalizeRemoteKey(current.SessionPrefix) + if strings.TrimSpace(prefix) == "" { + return nil, fmt.Errorf("remote session prefix is required") + } + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + sessionPaths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID) + objects, err := store.List(ctx, prefix) + if err != nil { + return nil, fmt.Errorf("list remote session objects under %q: %w", prefix, err) + } + + candidates := make(map[string]storage.ObjectInfo, len(objects)+1) + for _, obj := range objects { + key := normalizeRemoteKey(obj.Key) + if key == "" { + continue + } + obj.Key = key + candidates[key] = obj + } + if strings.TrimSpace(current.CurrentManifestKey) != "" { + key := normalizeRemoteKey(current.CurrentManifestKey) + if _, ok := candidates[key]; !ok { + candidates[key] = storage.ObjectInfo{Key: key} + } + } + + actions := make([]RestoreAction, 0, len(candidates)) + for key, obj := range candidates { + rel, include, err := restoreLocalRelativePathForKey(prefix, normalizeRemoteKey(current.CurrentManifestKey), key, opts.IncludeAudio) + if err != nil { + return nil, fmt.Errorf("map remote key %q: %w", key, err) + } + if !include { + continue + } + localPath, err := joinWithinSessionRoot(sessionPaths.Root, rel) + if err != nil { + return nil, fmt.Errorf("map remote key %q: %w", key, err) + } + + action, err := classifyRestoreAction(ctx, store, obj, rel, localPath, opts.Force) + if err != nil { + return nil, fmt.Errorf("classify remote key %q: %w", key, err) + } + actions = append(actions, action) + } + + sort.Slice(actions, func(i, j int) bool { + if actions[i].LocalRelativePath == actions[j].LocalRelativePath { + return actions[i].RemoteKey < actions[j].RemoteKey + } + return actions[i].LocalRelativePath < actions[j].LocalRelativePath + }) + + plan := &RestorePlan{Actions: actions} + for _, action := range actions { + switch action.Kind { + case RestoreActionDownload: + plan.DownloadCount++ + case RestoreActionSkipSame: + plan.SkipSameCount++ + case RestoreActionConflict: + plan.ConflictCount++ + } + } + + _ = opts.DryRun + return plan, nil +} + +func normalizeRemoteKey(v string) string { + return strings.Trim(strings.ReplaceAll(strings.TrimSpace(v), "\\", "/"), "/") +} + +func restoreLocalRelativePathForKey(sessionPrefix, currentManifestKey, key string, includeAudio bool) (string, bool, error) { + if key == "" { + return "", false, nil + } + if key == currentManifestKey { + return config.PathManifestFile, true, nil + } + if !strings.HasPrefix(key, sessionPrefix) { + return "", false, fmt.Errorf("key is outside resolved session prefix %q", sessionPrefix) + } + + rel := strings.TrimPrefix(key, sessionPrefix) + rel = strings.TrimSpace(rel) + if rel == "" { + return "", false, nil + } + + cleanRel := path.Clean(rel) + if cleanRel == "." || cleanRel == "" { + return "", false, nil + } + if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") || strings.HasPrefix(cleanRel, "/") { + return "", false, fmt.Errorf("key relative path %q escapes session scope", rel) + } + + if cleanRel == config.PathManifestFile { + return config.PathManifestFile, true, nil + } + if strings.HasPrefix(cleanRel, config.S3CurrentSegment+"/") { + return "", false, nil + } + if strings.HasPrefix(cleanRel, config.S3RunsSegment+"/") { + return "", false, nil + } + + excludedRoots := []string{ + config.PathLogsDirSegment, + config.PathReportsDirSegment, + config.PathConfigDirSegment, + config.PathInputsDirSegment, + } + for _, root := range excludedRoots { + if cleanRel == root || strings.HasPrefix(cleanRel, root+"/") { + return "", false, nil + } + } + + if cleanRel == config.PathTranscriptsSegment || strings.HasPrefix(cleanRel, config.PathTranscriptsSegment+"/") { + return cleanRel, true, nil + } + if cleanRel == config.PathArtifactsDirSegment || strings.HasPrefix(cleanRel, config.PathArtifactsDirSegment+"/") { + return cleanRel, true, nil + } + if includeAudio && (cleanRel == config.PathAudioDirSegment || strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/")) { + return cleanRel, true, nil + } + + return "", false, nil +} + +func joinWithinSessionRoot(sessionRoot, relative string) (string, error) { + if strings.TrimSpace(sessionRoot) == "" { + return "", fmt.Errorf("session root is required") + } + cleanRel := path.Clean(strings.TrimSpace(relative)) + if cleanRel == "." || cleanRel == "" { + return "", fmt.Errorf("relative path is required") + } + if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") || strings.HasPrefix(cleanRel, "/") { + return "", fmt.Errorf("relative path escapes session root") + } + abs := filepath.Clean(filepath.Join(sessionRoot, filepath.FromSlash(cleanRel))) + root := filepath.Clean(sessionRoot) + if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) { + return "", fmt.Errorf("resolved local path escapes session root") + } + return abs, nil +} + +func classifyRestoreAction( + ctx context.Context, + store storage.ObjectStore, + object storage.ObjectInfo, + localRelPath string, + localPath string, + force bool, +) (RestoreAction, error) { + action := RestoreAction{ + RemoteKey: normalizeRemoteKey(object.Key), + LocalRelativePath: localRelPath, + LocalPath: localPath, + Size: object.Size, + ETag: object.ETag, + } + + info, err := os.Stat(localPath) + if err != nil { + if os.IsNotExist(err) { + action.Kind = RestoreActionDownload + action.Reason = "local file missing" + return action, nil + } + return RestoreAction{}, fmt.Errorf("stat local file: %w", err) + } + + action.ExistsLocal = true + if info.IsDir() { + action.Kind = RestoreActionConflict + action.Conflict = true + action.Reason = "local path is a directory" + return action, nil + } + + if object.Size > 0 && info.Size() != object.Size { + if force { + action.Kind = RestoreActionDownload + action.Reason = "local file differs (size mismatch); overwrite with --force" + return action, nil + } + action.Kind = RestoreActionConflict + action.Conflict = true + action.Reason = "local file differs (size mismatch)" + return action, nil + } + + localDigest, err := artifacts.SHA256File(localPath) + if err != nil { + return RestoreAction{}, fmt.Errorf("checksum local file: %w", err) + } + remotePath, err := downloadObjectToTemp(ctx, store, action.RemoteKey, "narratio-restore-plan-remote-*.tmp") + if err != nil { + return RestoreAction{}, fmt.Errorf("download remote object: %w", err) + } + defer func() { _ = os.Remove(remotePath) }() + + remoteDigest, err := artifacts.SHA256File(remotePath) + if err != nil { + return RestoreAction{}, fmt.Errorf("checksum remote object: %w", err) + } + + if remoteDigest == localDigest { + action.Kind = RestoreActionSkipSame + action.SameLocal = true + action.Reason = "local file matches remote content" + return action, nil + } + + if force { + action.Kind = RestoreActionDownload + action.Reason = "local file differs; overwrite with --force" + return action, nil + } + + action.Kind = RestoreActionConflict + action.Conflict = true + action.Reason = "local file differs" + return action, nil +} + +func writeRestorePlan(out io.Writer, current *RemoteCurrentState, plan *RestorePlan, opts RestorePlanOptions) error { + if out == nil { + return fmt.Errorf("output writer is required") + } + if current == nil { + return fmt.Errorf("remote current state is required") + } + if plan == nil { + return fmt.Errorf("restore plan is required") + } + + if _, err := fmt.Fprintf( + out, + "restore plan: session %s/%s run=%s actions=%d download=%d skip_same=%d conflict=%d dry_run=%t force=%t include_audio=%t\n", + current.Campaign, + current.SessionID, + current.RunID, + len(plan.Actions), + plan.DownloadCount, + plan.SkipSameCount, + plan.ConflictCount, + opts.DryRun, + opts.Force, + opts.IncludeAudio, + ); err != nil { + return err + } + for _, action := range plan.Actions { + if _, err := fmt.Fprintf(out, "%s %s <- %s", action.Kind, action.LocalRelativePath, action.RemoteKey); err != nil { + return err + } + if strings.TrimSpace(action.Reason) != "" { + if _, err := fmt.Fprintf(out, " (%s)", action.Reason); err != nil { + return err + } + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + return nil +} diff --git a/internal/app/restore_plan_test.go b/internal/app/restore_plan_test.go new file mode 100644 index 0000000..b4eae06 --- /dev/null +++ b/internal/app/restore_plan_test.go @@ -0,0 +1,199 @@ +package app + +import ( + "context" + "path/filepath" + "reflect" + "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{} + + seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) + seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`)) + seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n")) + seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio")) + seedRestoreObject(store, current.SessionPrefix+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}")) + seedRestoreObject(store, current.SessionPrefix+"logs/archive.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 !reflect.DeepEqual(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{} + + seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) + seedRestoreObject(store, 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 !reflect.DeepEqual(got, want) { + t.Fatalf("action local paths = %#v, want %#v", got, want) + } +} + +func TestRestorePlanClassifiesSameAndConflict(t *testing.T) { + cfg := restorePlanConfig(t) + current := restorePlanCurrentState(t, cfg) + store := &storage.FakeBackend{} + + seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) + seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`)) + seedRestoreObject(store, 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{} + + seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) + seedRestoreObject(store, 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 TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) { + cfg := restorePlanConfig(t) + current := restorePlanCurrentState(t, cfg) + store := &storage.FakeBackend{} + + seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`)) + seedRestoreObject(store, 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(), "escapes session scope") { + t.Fatalf("error = %v, want traversal safety failure", err) + } +} + +func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) { + store.SeedObject(storage.FakeObject{Key: key, Data: data}) +} + +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}, + }, + Session: &config.SessionConfig{ + SessionID: "2026-05-03", + Campaign: "sample-campaign", + }, + } +} + +func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState { + t.Helper() + sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) + manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) + return &RemoteCurrentState{ + Bucket: "test-bucket", + SessionPrefix: sessionPrefix, + CurrentManifestKey: manifestKey, + CurrentRunIDKey: runIDKey, + RunID: "20260519T010203Z-a1b2c3d4", + SessionID: cfg.Session.SessionID, + Campaign: cfg.Session.Campaign, + } +} + +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) + } +} diff --git a/internal/app/restore_test.go b/internal/app/restore_test.go index 721d60f..e969ff7 100644 --- a/internal/app/restore_test.go +++ b/internal/app/restore_test.go @@ -37,9 +37,11 @@ func TestExecuteRestoreHelp(t *testing.T) { func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { origStoreFn := newObjectStoreFromConfigFn origDiscoverFn := discoverRemoteCurrentStateFn + origPlanFn := buildRestorePlanFn t.Cleanup(func() { newObjectStoreFromConfigFn = origStoreFn discoverRemoteCurrentStateFn = origDiscoverFn + buildRestorePlanFn = origPlanFn }) newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { return &storage.FakeBackend{}, nil @@ -51,6 +53,19 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { RunID: "20260519T010203Z-a1b2c3d4", }, nil } + buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) { + return &RestorePlan{ + Actions: []RestoreAction{ + { + Kind: RestoreActionDownload, + LocalRelativePath: "manifest.json", + RemoteKey: "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json", + Reason: "local file missing", + }, + }, + DownloadCount: 1, + }, nil + } workspaceRoot := t.TempDir() pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) @@ -70,23 +85,23 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { &stdout, &stderr, ) - if code == 0 { - t.Fatal("exit code = 0, want non-zero (phase 3 NYI boundary)") + if code != 0 { + t.Fatalf("exit code = %d, want 0 for --dry-run restore planning; stderr=%q", code, stderr.String()) } - errText := stderr.String() - if !strings.Contains(errText, "discovered remote current state for sample-campaign/2026-05-03 (run 20260519T010203Z-a1b2c3d4)") { - t.Fatalf("stderr = %q, want discovery summary context", errText) + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) } - if !strings.Contains(errText, "not yet implemented (phase 3: remote current-state discovery)") { - t.Fatalf("stderr = %q, want phase-3 NYI marker", errText) + outText := stdout.String() + if !strings.Contains(outText, "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") { + t.Fatalf("stdout = %q, want restore plan summary", outText) } - if strings.Contains(errText, "unknown command") { - t.Fatalf("stderr = %q, restore should be recognized command", errText) + if !strings.Contains(outText, "download manifest.json <- dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json") { + t.Fatalf("stdout = %q, want action output", outText) } manifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03") if _, err := os.Stat(manifestPath); !os.IsNotExist(err) { - t.Fatalf("manifest should not be created during phase-3 restore discovery; stat err=%v", err) + t.Fatalf("manifest should not be created during phase-4 restore planning; stat err=%v", err) } } @@ -155,6 +170,97 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) { } } +func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) { + origStoreFn := newObjectStoreFromConfigFn + origDiscoverFn := discoverRemoteCurrentStateFn + origPlanFn := buildRestorePlanFn + t.Cleanup(func() { + newObjectStoreFromConfigFn = origStoreFn + discoverRemoteCurrentStateFn = origDiscoverFn + buildRestorePlanFn = origPlanFn + }) + newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + return &storage.FakeBackend{}, nil + } + discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) { + return &RemoteCurrentState{ + SessionID: "2026-05-03", + Campaign: "sample-campaign", + RunID: "20260519T010203Z-a1b2c3d4", + }, nil + } + buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) { + return &RestorePlan{ + Actions: []RestoreAction{ + {Kind: RestoreActionConflict, LocalRelativePath: "transcripts/full.json", RemoteKey: "k", Reason: "local file differs"}, + }, + ConflictCount: 1, + }, nil + } + + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stdout.String(), "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") { + t.Fatalf("stdout = %q, want plan output", stdout.String()) + } + if !strings.Contains(stderr.String(), "plan has 1 conflicting path(s)") { + t.Fatalf("stderr = %q, want conflict failure", stderr.String()) + } + if strings.Contains(stderr.String(), "phase 4: restore execution") { + t.Fatalf("stderr = %q, should fail before phase-4 NYI boundary", stderr.String()) + } +} + +func TestExecuteRestoreNonDryRunForceStillStopsAtNYI(t *testing.T) { + origStoreFn := newObjectStoreFromConfigFn + origDiscoverFn := discoverRemoteCurrentStateFn + origPlanFn := buildRestorePlanFn + t.Cleanup(func() { + newObjectStoreFromConfigFn = origStoreFn + discoverRemoteCurrentStateFn = origDiscoverFn + buildRestorePlanFn = origPlanFn + }) + newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + return &storage.FakeBackend{}, nil + } + discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) { + return &RemoteCurrentState{ + SessionID: "2026-05-03", + Campaign: "sample-campaign", + RunID: "20260519T010203Z-a1b2c3d4", + }, nil + } + buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) { + return &RestorePlan{ + Actions: []RestoreAction{ + {Kind: RestoreActionDownload, LocalRelativePath: "transcripts/full.json", RemoteKey: "k", Reason: "local file differs; overwrite with --force"}, + }, + DownloadCount: 1, + }, nil + } + + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero at phase-4 execution boundary") + } + if !strings.Contains(stdout.String(), "restore plan: session sample-campaign/2026-05-03 run=20260519T010203Z-a1b2c3d4") { + t.Fatalf("stdout = %q, want plan output", stdout.String()) + } + if !strings.Contains(stderr.String(), "not yet implemented (phase 4: restore execution)") { + t.Fatalf("stderr = %q, want phase-4 NYI marker", stderr.String()) + } +} + func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) { t.Helper()