Replace restore phase globals

This commit is contained in:
2026-09-05 18:05:18 +00:00
parent f51b9bb046
commit 85663f5bc9
7 changed files with 93 additions and 185 deletions

View File

@@ -244,7 +244,7 @@ no factory global, and remote behavior remains deterministic and offline.
## Stage 4 — Replace Restore Phase Globals With Explicit Dependencies
**Status:** Pending
**Status:** Completed
### Goal

View File

@@ -65,7 +65,7 @@ func sessionWithDependencies(ctx context.Context, args []string, out io.Writer,
case "plan":
return planWithDependencies(ctx, args[1:], out, deps)
case "restore":
return Restore(ctx, args[1:], out)
return restoreWithDependencies(ctx, args[1:], out, deps)
case "artifacts":
return artifactsListWithDependencies(ctx, args[1:], out, deps)
case "locks":

View File

@@ -10,19 +10,18 @@ import (
"os"
"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/logging"
)
var newObjectStoreFromConfigFn = storage.NewObjectStoreFromConfig
var discoverRemoteCurrentStateFn = discoverRemoteCurrentState
var buildRestorePlanFn = buildRestorePlan
var executeRestorePlanFn = executeRestorePlan
// Restore validates restore CLI/config inputs and storage preflight for future restore phases.
func Restore(ctx context.Context, args []string, out io.Writer) (resultErr error) {
return restoreWithDependencies(ctx, args, out, commandDependencies{})
}
func restoreWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) (resultErr error) {
deps = normalizeCommandDependencies(deps)
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
fs.SetOutput(out)
@@ -54,8 +53,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) (resultErr error
if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("restore: session_id is required")
}
restoreDeps := commandDependencies{newObjectStore: newObjectStoreFromConfigFn}
loaded, err := loadCommandConfigWithDependencies(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions(), restoreDeps)
loaded, err := loadCommandConfigWithDependencies(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions(), deps)
if err != nil {
return fmt.Errorf("restore: %w", err)
}
@@ -64,16 +62,16 @@ func Restore(ctx context.Context, args []string, out io.Writer) (resultErr error
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("restore: %w", err)
}
objectStore, err := newCommandObjectStoreWithDependencies(ctx, cfg, logging.NewLogger(os.Stderr, slog.LevelInfo), restoreDeps)
objectStore, err := newCommandObjectStoreWithDependencies(ctx, cfg, logging.NewLogger(os.Stderr, slog.LevelInfo), deps)
if err != nil {
return fmt.Errorf("restore: %w", err)
}
current, err := discoverRemoteCurrentStateFn(ctx, cfg, objectStore)
current, err := deps.discoverRemoteCurrentState(ctx, cfg, objectStore)
if err != nil {
return fmt.Errorf("restore: %w", err)
}
if dryRun {
plan, err := buildRestorePlanFn(ctx, cfg, current, objectStore, RestorePlanOptions{
plan, err := deps.buildRestorePlan(ctx, cfg, current, objectStore, RestorePlanOptions{
IncludeAudio: includeAudio,
Force: force,
DryRun: true,
@@ -116,7 +114,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) (resultErr error
// Classification and installation share the same transition lock as a
// runner. This prevents a runner from making reuse decisions against state
// that restore is about to replace.
plan, err := buildRestorePlanFn(ctx, cfg, current, objectStore, RestorePlanOptions{
plan, err := deps.buildRestorePlan(ctx, cfg, current, objectStore, RestorePlanOptions{
IncludeAudio: includeAudio,
Force: force,
})
@@ -148,7 +146,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) (resultErr error
return fmt.Errorf("restore: mark incomplete restore: %w", err)
}
result, err := executeRestorePlanFn(ctx, cfg, current, plan, report, objectStore)
result, err := deps.executeRestorePlan(ctx, cfg, current, plan, report, objectStore)
if err != nil {
report.setFailed(err)
if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil {

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
@@ -34,11 +35,11 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
"audio/alice.flac": []byte("remote-audio"),
})
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
code := executeRestore([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -99,11 +100,11 @@ func TestExecuteRestoreRoundTripsPublishedExtractionAndManifestMetadata(t *testi
t.Fatal(err)
}
seedRestoreObject(fake, manifestKey, manifestBody)
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
code := executeRestore([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -130,11 +131,11 @@ func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio"))
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
code := executeRestore([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -156,11 +157,11 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
audioKey := sessionPrefix + "audio/alice.flac"
seedRestoreObject(fake, audioKey, []byte("remote-audio"))
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
code := executeRestore([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -176,7 +177,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
}
stdout.Reset()
stderr.Reset()
code = Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
code = executeRestore([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -195,11 +196,11 @@ func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestorePreviousCurrent(t, fake, cfg, "# previous recap\n")
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
code := executeRestore([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -228,11 +229,11 @@ func TestExecuteRestoreDryRunReportsPreviousCacheWithoutWriting(t *testing.T) {
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestorePreviousCurrent(t, fake, cfg, "# previous recap\n")
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
code := executeRestore([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -260,11 +261,11 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
code := executeRestore([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -292,11 +293,11 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
code := executeRestore([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -322,11 +323,11 @@ func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
code := executeRestore([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -348,11 +349,11 @@ func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
}
defer func() { _ = store.ReleaseSessionLock(lock) }()
restoreWithStoreAndRealPhases(t, fake)
deps := restoreCommandDependencies(fake)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
err = Restore(ctx, []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &bytes.Buffer{})
err = restoreWithDependencies(ctx, []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &bytes.Buffer{}, deps)
if err == nil || !strings.Contains(err.Error(), "acquire session lock") {
t.Fatalf("Restore() error = %v, want lock failure", err)
}
@@ -448,10 +449,9 @@ func TestExecuteRestoreInvalidManifestReportsFailureAndRetainsRecoveryMarker(t *
t.Fatalf("read existing local manifest: %v", err)
}
restoreWithStoreAndRealPhases(t, store)
realBuildRestorePlan := buildRestorePlanFn
buildRestorePlanFn = func(ctx context.Context, cfg *config.Config, current *RemoteCurrentState, objectStore storage.ObjectStore, opts RestorePlanOptions) (*RestorePlan, error) {
plan, err := realBuildRestorePlan(ctx, cfg, current, objectStore, opts)
deps := restoreCommandDependencies(store)
deps.buildRestorePlan = func(ctx context.Context, cfg *config.Config, current *RemoteCurrentState, objectStore storage.ObjectStore, opts RestorePlanOptions) (*RestorePlan, error) {
plan, err := buildRestorePlan(ctx, cfg, current, objectStore, opts)
if err != nil {
return nil, err
}
@@ -476,7 +476,7 @@ func TestExecuteRestoreInvalidManifestReportsFailureAndRetainsRecoveryMarker(t *
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", cfg.Session.SessionID, "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
code := executeWithDependencies([]string{"session", "restore", cfg.Session.SessionID, "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr, deps, executePlan)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -573,25 +573,17 @@ func mustReadRestoreReport(t *testing.T, path string) *RestoreReport {
return &report
}
func restoreWithStoreAndRealPhases(t *testing.T, objectStore storage.ObjectStore) {
t.Helper()
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
func restoreCommandDependencies(objectStore storage.ObjectStore) commandDependencies {
return commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) {
return objectStore, nil
}}
}
func restoreCommandExecutor(objectStore storage.ObjectStore) func([]string, io.Writer, io.Writer) int {
deps := restoreCommandDependencies(objectStore)
return func(args []string, stdout, stderr io.Writer) int {
return executeWithDependencies(args, stdout, stderr, deps, executePlan)
}
discoverRemoteCurrentStateFn = discoverRemoteCurrentState
buildRestorePlanFn = buildRestorePlan
executeRestorePlanFn = executeRestorePlan
}
func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipelinePath, campaignPath, sessionPath string) (*config.Config, string, string, string) {

View File

@@ -21,26 +21,17 @@ func TestRestoreHoldsTransitionLockBeforePlanning(t *testing.T) {
fake := &storage.FakeBackend{}
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
})
current, err := discoverRemoteCurrentState(context.Background(), cfg, fake)
if err != nil {
t.Fatalf("discoverRemoteCurrentState() error = %v", err)
}
planning := make(chan struct{})
releasePlanning := make(chan struct{})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { return fake, nil }
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
deps := restoreCommandDependencies(fake)
deps.discoverRemoteCurrentState = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
return current, nil
}
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
deps.buildRestorePlan = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
close(planning)
<-releasePlanning
return &RestorePlan{}, nil
@@ -48,12 +39,12 @@ func TestRestoreHoldsTransitionLockBeforePlanning(t *testing.T) {
restoreDone := make(chan error, 1)
go func() {
restoreDone <- Restore(context.Background(), []string{
restoreDone <- restoreWithDependencies(context.Background(), []string{
cfg.Session.SessionID,
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &bytes.Buffer{})
}, &bytes.Buffer{}, deps)
}()
<-planning
@@ -102,12 +93,12 @@ func TestForcedRestoreRetryClearsIncompleteMarker(t *testing.T) {
}
return nil
}
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
args := []string{"session", "restore", cfg.Session.SessionID, "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}
if code := Execute(args, &stdout, &stderr); code == 0 {
if code := executeRestore(args, &stdout, &stderr); code == 0 {
t.Fatal("forced restore exit code = 0, want partial-install failure")
}
markerPath := artifacts.SessionRestoreMarkerPathForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
@@ -119,7 +110,7 @@ func TestForcedRestoreRetryClearsIncompleteMarker(t *testing.T) {
fake.DownloadHook = nil
stdout.Reset()
stderr.Reset()
if code := Execute(args, &stdout, &stderr); code != 0 {
if code := executeRestore(args, &stdout, &stderr); code != 0 {
t.Fatalf("restore retry exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if _, err := os.Stat(markerPath); !os.IsNotExist(err) {

View File

@@ -37,28 +37,16 @@ func TestExecuteRestoreHelp(t *testing.T) {
}
}
func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
func TestExecuteRestoreDryRunPrintsPlanWithoutWriting(t *testing.T) {
deps := restoreCommandDependencies(&storage.FakeBackend{})
deps.discoverRemoteCurrentState = 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) {
deps.buildRestorePlan = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
return &RestorePlan{
Actions: []RestoreAction{
{
@@ -77,7 +65,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
code := executeWithDependencies(
[]string{
"session", "restore", "2026-05-03",
"--config", pipelinePath,
@@ -89,6 +77,8 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
},
&stdout,
&stderr,
deps,
executePlan,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0 for --dry-run restore planning; stderr=%q", code, stderr.String())
@@ -109,7 +99,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
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-4 restore planning; stat err=%v", err)
t.Fatalf("manifest should not be created during dry-run restore planning; stat err=%v", err)
}
reportPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "reports", "restore-latest.json")
if _, err := os.Stat(reportPath); !os.IsNotExist(err) {
@@ -133,13 +123,6 @@ func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
}
func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
})
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot)
@@ -155,16 +138,8 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
}
func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
deps := restoreCommandDependencies(&storage.FakeBackend{})
deps.discoverRemoteCurrentState = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
return nil, fmt.Errorf("remote current commit pointer missing: %q", "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/commit-pointer.json")
}
@@ -173,7 +148,7 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
code := executeWithDependencies([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr, deps, executePlan)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -183,17 +158,6 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
}
func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
const accessKeyEnv = "OBJECT_STORAGE_KEY_ID"
const secretKeyEnv = "OBJECT_STORAGE_KEY"
restoreEnv := func(name string) {
@@ -225,7 +189,7 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
}
storeInitCalled := false
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
deps := commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) {
storeInitCalled = true
gotID, okID := os.LookupEnv(accessKeyEnv)
if !okID || gotID != "test-access-key-id" {
@@ -236,15 +200,15 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
return nil, fmt.Errorf("missing or unexpected %s: %q (set=%t)", secretKeyEnv, gotSecret, okSecret)
}
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
}}
deps.discoverRemoteCurrentState = 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) {
deps.buildRestorePlan = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
return &RestorePlan{
Actions: []RestoreAction{
{
@@ -260,7 +224,7 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
code := executeWithDependencies(
[]string{
"session", "restore", "2026-05-03",
"--config", pipelinePath,
@@ -270,6 +234,8 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
},
&stdout,
&stderr,
deps,
executePlan,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -279,28 +245,16 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
}
}
func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
func TestExecuteRestoreNonDryRunConflictDoesNotExecute(t *testing.T) {
deps := restoreCommandDependencies(&storage.FakeBackend{})
deps.discoverRemoteCurrentState = 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) {
deps.buildRestorePlan = 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"},
@@ -313,7 +267,7 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
code := executeWithDependencies([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr, deps, executePlan)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -323,33 +277,18 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
if !strings.Contains(stderr.String(), "restore conflict: 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 TestExecuteRestoreForceStillBlocksUnresolvedConflict(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
deps := restoreCommandDependencies(&storage.FakeBackend{})
deps.discoverRemoteCurrentState = 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) {
deps.buildRestorePlan = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
return &RestorePlan{Actions: []RestoreAction{{Kind: RestoreActionConflict, LocalRelativePath: "artifacts/session_recap.md", Reason: "local path is a directory"}}, ConflictCount: 1}, nil
}
executed := false
executeRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, *RestoreReport, storage.ObjectStore) (*RestoreExecutionResult, error) {
deps.executeRestorePlan = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, *RestoreReport, storage.ObjectStore) (*RestoreExecutionResult, error) {
executed = true
return &RestoreExecutionResult{}, nil
}
@@ -358,7 +297,7 @@ func TestExecuteRestoreForceStillBlocksUnresolvedConflict(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
code := executeWithDependencies([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr, deps, executePlan)
if code == 0 {
t.Fatal("exit code = 0, want conflict failure")
}
@@ -368,27 +307,15 @@ func TestExecuteRestoreForceStillBlocksUnresolvedConflict(t *testing.T) {
}
func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
deps := restoreCommandDependencies(&storage.FakeBackend{})
deps.discoverRemoteCurrentState = 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) {
deps.buildRestorePlan = 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"},
@@ -396,7 +323,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
DownloadCount: 1,
}, nil
}
executeRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, *RestoreReport, storage.ObjectStore) (*RestoreExecutionResult, error) {
deps.executeRestorePlan = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, *RestoreReport, storage.ObjectStore) (*RestoreExecutionResult, error) {
return &RestoreExecutionResult{DownloadedCount: 1}, nil
}
@@ -404,7 +331,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
code := executeWithDependencies([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr, deps, executePlan)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}

View File

@@ -27,7 +27,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`+"\n"))
seedRestoreObject(fake, sessionPrefix+"artifacts/session_recap.md", []byte("# restored recap\n"))
restoreWithStoreAndRealPhases(t, fake)
executeRestore := restoreCommandExecutor(fake)
executor := func(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
if opts.Env == nil {
@@ -39,7 +39,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
restoreCode := Execute(
restoreCode := executeRestore(
[]string{
"session", "restore", cfg.Session.SessionID,
"--config", pipelinePath,
@@ -164,11 +164,11 @@ previous_session_id: 2026-04-26
seedRestoreObject(fakeStore, sessionPrefix+"transcripts/final.trimmed.json", []byte(`{"segments":[]}`+"\n"))
seedRestorePreviousCurrent(t, fakeStore, cfg, "# previous recap\n")
restoreWithStoreAndRealPhases(t, fakeStore)
executeRestore := restoreCommandExecutor(fakeStore)
var stdout bytes.Buffer
var stderr bytes.Buffer
restoreCode := Execute(
restoreCode := executeRestore(
[]string{
"session", "restore", cfg.Session.SessionID,
"--config", pipelinePath,