diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b705848..f17653f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -199,7 +199,7 @@ select the real storage constructor, and focused tests remain offline. ## Stage 3 — Carry Dependencies Through Operator Commands -**Status:** Pending +**Status:** Completed ### Goal diff --git a/internal/app/clean.go b/internal/app/clean.go index c05c30c..5d98048 100644 --- a/internal/app/clean.go +++ b/internal/app/clean.go @@ -17,6 +17,11 @@ import ( // Clean removes local workspace/spool state while preserving durable cache // state unless cache cleanup is explicitly requested. func Clean(ctx context.Context, args []string, out io.Writer) error { + return cleanWithDependencies(ctx, args, out, commandDependencies{}) +} + +func cleanWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { + deps = normalizeCommandDependencies(deps) fs := flag.NewFlagSet("clean", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags @@ -31,16 +36,20 @@ func Clean(ctx context.Context, args []string, out io.Writer) error { return err } if all { - return cleanAllLocal(flags, dryRun, clearCache, out) + return cleanAllLocalWithDependencies(flags, dryRun, clearCache, out, deps) } - return cleanSession(ctx, flags, dryRun, clearCache, out) + return cleanSessionWithDependencies(ctx, flags, dryRun, clearCache, out, deps) } func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error { + return cleanSessionWithDependencies(ctx, flags, dryRun, clearCache, out, commandDependencies{}) +} + +func cleanSessionWithDependencies(ctx context.Context, flags commonConfigFlags, dryRun, clearCache bool, out io.Writer, deps commandDependencies) error { if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("clean: session_id is required unless --all is set") } - loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) + loaded, err := loadCommandConfigWithDependencies(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions(), deps) if err != nil { return fmt.Errorf("clean: %w", err) } @@ -71,7 +80,7 @@ func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCac } if clearCache { - if err := cleanSessionAudioCache(ctx, cfg, dryRun, out); err != nil { + if err := cleanSessionAudioCacheWithDependencies(ctx, cfg, dryRun, out, deps); err != nil { return fmt.Errorf("clean: %w", err) } } else { @@ -81,6 +90,10 @@ func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCac } func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error { + return cleanAllLocalWithDependencies(flags, dryRun, clearCache, out, commandDependencies{}) +} + +func cleanAllLocalWithDependencies(flags commonConfigFlags, dryRun, clearCache bool, out io.Writer, deps commandDependencies) error { if strings.TrimSpace(flags.campaignPath) != "" || strings.TrimSpace(flags.campaignFilePath) != "" || strings.TrimSpace(flags.sessionPath) != "" || @@ -88,7 +101,7 @@ func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writ strings.TrimSpace(flags.previousSessionID) != "" { return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id") } - _, pipelineCfg, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}) + _, pipelineCfg, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}, deps) if err != nil { return fmt.Errorf("clean: %w", err) } @@ -195,6 +208,10 @@ func cleanableRootChildren(root, policy string) (string, []string, error) { } func cleanSessionAudioCache(ctx context.Context, cfg *config.Config, dryRun bool, out io.Writer) error { + return cleanSessionAudioCacheWithDependencies(ctx, cfg, dryRun, out, commandDependencies{}) +} + +func cleanSessionAudioCacheWithDependencies(ctx context.Context, cfg *config.Config, dryRun bool, out io.Writer, deps commandDependencies) error { if cfg.Session.Inputs.AudioS3 == nil { fmt.Fprintln(out, "Cache: skipped (session does not use audio_s3)") return nil @@ -202,7 +219,7 @@ func cleanSessionAudioCache(ctx context.Context, cfg *config.Config, dryRun bool if cfg.Pipeline.Storage.S3 == nil || strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket) == "" { return fmt.Errorf("clear cache requires pipeline.storage.s3.bucket") } - store, err := newCommandObjectStore(ctx, cfg, nil) + store, err := newCommandObjectStoreWithDependencies(ctx, cfg, nil, deps) if err != nil { return fmt.Errorf("initialize object store for cache cleanup: %w", err) } diff --git a/internal/app/clean_test.go b/internal/app/clean_test.go index 9ff73ec..1c5a02e 100644 --- a/internal/app/clean_test.go +++ b/internal/app/clean_test.go @@ -89,7 +89,7 @@ inputs: fake := &storage.FakeBackend{} fake.SeedObject(storage.FakeObject{Key: audioKey, Data: []byte("audio")}) var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) cacheRoot := filepath.Join(workspaceRoot, "cache") cachePath, err := artifacts.S3AudioCachePath(cacheRoot, "test-bucket", audioKey) @@ -105,7 +105,7 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr) + code := executeWithDependencies([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } diff --git a/internal/app/commands.go b/internal/app/commands.go index fcd4ca3..3dfe9ca 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -46,7 +46,7 @@ func executeWithDependencies(args []string, stdout, stderr io.Writer, deps comma case "session": err = sessionWithDependencies(ctx, cmdArgs, stdout, deps) case "clean": - err = Clean(ctx, cmdArgs, stdout) + err = cleanWithDependencies(ctx, cmdArgs, stdout, deps) case "config": err = Config(ctx, cmdArgs, stdout) default: diff --git a/internal/app/config_loader.go b/internal/app/config_loader.go index affeb6e..f191ed1 100644 --- a/internal/app/config_loader.go +++ b/internal/app/config_loader.go @@ -35,9 +35,7 @@ func retainedCommandConfig(cfg *config.Config) *commandConfig { } func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (loaded *commandConfig, err error) { - return loadCommandConfigWithDependencies(ctx, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag, sessionOpts, commandDependencies{ - newObjectStore: newObjectStoreFromConfigFn, - }) + return loadCommandConfigWithDependencies(ctx, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag, sessionOpts, commandDependencies{}) } func loadCommandConfigWithDependencies(ctx context.Context, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag string, sessionOpts config.SessionLoadOptions, deps commandDependencies) (loaded *commandConfig, err error) { diff --git a/internal/app/object_store.go b/internal/app/object_store.go index 21867f7..54e4dca 100644 --- a/internal/app/object_store.go +++ b/internal/app/object_store.go @@ -10,7 +10,7 @@ import ( ) func newCommandObjectStore(ctx context.Context, cfg *config.Config, logger *slog.Logger) (storage.ObjectStore, error) { - return newCommandObjectStoreWithDependencies(ctx, cfg, logger, commandDependencies{newObjectStore: newObjectStoreFromConfigFn}) + return newCommandObjectStoreWithDependencies(ctx, cfg, logger, commandDependencies{}) } func newCommandObjectStoreWithDependencies(ctx context.Context, cfg *config.Config, logger *slog.Logger, deps commandDependencies) (storage.ObjectStore, error) { diff --git a/internal/app/object_store_test.go b/internal/app/object_store_test.go index baed396..defcb4c 100644 --- a/internal/app/object_store_test.go +++ b/internal/app/object_store_test.go @@ -24,8 +24,7 @@ func TestNewCommandObjectStoreLoadsSecretsBeforeFactory(t *testing.T) { cfg := commandObjectStoreTestConfig(secretsDir) fake := &storage.FakeBackend{} called := false - origStoreFn := newObjectStoreFromConfigFn - newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + deps := commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) { called = true if got := os.Getenv(accessKeyEnv); got != "loaded-key-id" { return nil, errors.New("access key was not loaded before object store init") @@ -34,12 +33,9 @@ func TestNewCommandObjectStoreLoadsSecretsBeforeFactory(t *testing.T) { return nil, errors.New("secret key was not loaded before object store init") } return fake, nil - } - t.Cleanup(func() { - newObjectStoreFromConfigFn = origStoreFn - }) + }} - store, err := newCommandObjectStore(context.Background(), cfg, nil) + store, err := newCommandObjectStoreWithDependencies(context.Background(), cfg, nil, deps) if err != nil { t.Fatalf("newCommandObjectStore() error = %v", err) } @@ -62,8 +58,7 @@ func TestNewCommandObjectStorePreservesExistingEnv(t *testing.T) { mustWriteSecretFile(t, filepath.Join(secretsDir, secretKeyEnv), "file-secret\n") cfg := commandObjectStoreTestConfig(secretsDir) - origStoreFn := newObjectStoreFromConfigFn - newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + deps := commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) { if got := os.Getenv(accessKeyEnv); got != "existing-key-id" { return nil, errors.New("existing access key was overwritten") } @@ -71,12 +66,9 @@ func TestNewCommandObjectStorePreservesExistingEnv(t *testing.T) { return nil, errors.New("existing secret key was overwritten") } return &storage.FakeBackend{}, nil - } - t.Cleanup(func() { - newObjectStoreFromConfigFn = origStoreFn - }) + }} - if _, err := newCommandObjectStore(context.Background(), cfg, nil); err != nil { + if _, err := newCommandObjectStoreWithDependencies(context.Background(), cfg, nil, deps); err != nil { t.Fatalf("newCommandObjectStore() error = %v", err) } } @@ -84,16 +76,12 @@ func TestNewCommandObjectStorePreservesExistingEnv(t *testing.T) { func TestNewCommandObjectStoreSecretErrorStopsFactory(t *testing.T) { cfg := commandObjectStoreTestConfig(filepath.Join(t.TempDir(), "missing")) called := false - origStoreFn := newObjectStoreFromConfigFn - newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + deps := commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) { called = true return &storage.FakeBackend{}, nil - } - t.Cleanup(func() { - newObjectStoreFromConfigFn = origStoreFn - }) + }} - _, err := newCommandObjectStore(context.Background(), cfg, nil) + _, err := newCommandObjectStoreWithDependencies(context.Background(), cfg, nil, deps) if err == nil { t.Fatal("expected error, got nil") } @@ -107,15 +95,11 @@ func TestNewCommandObjectStoreSecretErrorStopsFactory(t *testing.T) { func TestNewCommandObjectStoreFactoryErrorIsContextual(t *testing.T) { cfg := commandObjectStoreTestConfig("") - origStoreFn := newObjectStoreFromConfigFn - newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + deps := commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) { return nil, errors.New("factory boom") - } - t.Cleanup(func() { - newObjectStoreFromConfigFn = origStoreFn - }) + }} - _, err := newCommandObjectStore(context.Background(), cfg, nil) + _, err := newCommandObjectStoreWithDependencies(context.Background(), cfg, nil, deps) if err == nil { t.Fatal("expected error, got nil") } diff --git a/internal/app/operator_artifacts_list.go b/internal/app/operator_artifacts_list.go index 2a47bdc..2ed99e4 100644 --- a/internal/app/operator_artifacts_list.go +++ b/internal/app/operator_artifacts_list.go @@ -10,6 +10,11 @@ import ( // ArtifactsList lists effective artifact sources. func ArtifactsList(ctx context.Context, args []string, out io.Writer) error { + return artifactsListWithDependencies(ctx, args, out, commandDependencies{}) +} + +func artifactsListWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { + deps = normalizeCommandDependencies(deps) fs := flag.NewFlagSet("artifacts list", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags @@ -22,7 +27,7 @@ func ArtifactsList(ctx context.Context, args []string, out io.Writer) error { if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("artifacts list: session_id is required") } - cfg, store, locks, m, cleanup, err := loadHelperContext(ctx, flags, remote) + cfg, store, locks, m, cleanup, err := loadHelperContextWithDependencies(ctx, flags, remote, deps) if err != nil { return fmt.Errorf("artifacts list: %w", err) } @@ -33,7 +38,7 @@ func ArtifactsList(ctx context.Context, args []string, out io.Writer) error { } publishedRemoteState := map[string]string{} if remote && store != nil { - current := inspectRemoteCurrentState(ctx, cfg, store) + current := inspectRemoteCurrentStateWithDependencies(ctx, cfg, store, deps) if current.Err != nil { publishedRemoteState = unresolvedRemotePublishedOutputAvailability(cfg, catalog, "remote=error") } else { diff --git a/internal/app/operator_helpers.go b/internal/app/operator_helpers.go index 73da766..f691c59 100644 --- a/internal/app/operator_helpers.go +++ b/internal/app/operator_helpers.go @@ -47,20 +47,19 @@ func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions { // Session dispatches session helper subcommands. func Session(ctx context.Context, args []string, out io.Writer) error { - return sessionWithDependencies(ctx, args, out, commandDependencies{ - newObjectStore: newObjectStoreFromConfigFn, - }) + return sessionWithDependencies(ctx, args, out, commandDependencies{}) } func sessionWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { + deps = normalizeCommandDependencies(deps) if len(args) == 0 { return fmt.Errorf("session: expected subcommand: init|validate|status|plan|restore|artifacts|locks") } switch args[0] { case "init": - return SessionInit(ctx, args[1:], out) + return sessionInitWithDependencies(ctx, args[1:], out, deps) case "validate": - return SessionValidate(ctx, args[1:], out) + return sessionValidateWithDependencies(ctx, args[1:], out, deps) case "status": return statusWithDependencies(ctx, args[1:], out, deps) case "plan": @@ -68,9 +67,9 @@ func sessionWithDependencies(ctx context.Context, args []string, out io.Writer, case "restore": return Restore(ctx, args[1:], out) case "artifacts": - return ArtifactsList(ctx, args[1:], out) + return artifactsListWithDependencies(ctx, args[1:], out, deps) case "locks": - return SessionLocks(ctx, args[1:], out) + return sessionLocksWithDependencies(ctx, args[1:], out, deps) default: return fmt.Errorf("session: unknown subcommand %q", args[0]) } @@ -79,32 +78,45 @@ func sessionWithDependencies(ctx context.Context, args []string, out io.Writer, // SessionLocks dispatches session-oriented publish lock list and mutation // helpers. func SessionLocks(ctx context.Context, args []string, out io.Writer) error { + return sessionLocksWithDependencies(ctx, args, out, commandDependencies{}) +} + +func sessionLocksWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { if len(args) > 0 && !isCLIFlagToken(args[0]) { switch args[0] { case "add": - return LocksAdd(ctx, args[1:], out) + return locksAddWithDependencies(ctx, args[1:], out, deps) case "remove": - return LocksRemove(ctx, args[1:], out) + return locksRemoveWithDependencies(ctx, args[1:], out, deps) } } - return LocksList(ctx, args, out) + return locksListWithDependencies(ctx, args, out, deps) } // Artifacts dispatches artifact helper subcommands. func Artifacts(ctx context.Context, args []string, out io.Writer) error { + return artifactsWithDependencies(ctx, args, out, commandDependencies{}) +} + +func artifactsWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { if len(args) == 0 { return fmt.Errorf("artifacts: expected subcommand: list") } switch args[0] { case "list": - return ArtifactsList(ctx, args[1:], out) + return artifactsListWithDependencies(ctx, args[1:], out, deps) default: return fmt.Errorf("artifacts: unknown subcommand %q", args[0]) } } func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore bool) (*config.Config, storage.ObjectStore, []artifacts.RemoteLock, *manifest.Manifest, func(), error) { - loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) + return loadHelperContextWithDependencies(ctx, flags, needStore, commandDependencies{}) +} + +func loadHelperContextWithDependencies(ctx context.Context, flags commonConfigFlags, needStore bool, deps commandDependencies) (*config.Config, storage.ObjectStore, []artifacts.RemoteLock, *manifest.Manifest, func(), error) { + deps = normalizeCommandDependencies(deps) + loaded, err := loadCommandConfigWithDependencies(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions(), deps) if err != nil { return nil, nil, nil, nil, nil, err } @@ -120,12 +132,12 @@ func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore b } var store storage.ObjectStore if needStore { - store, err = newCommandObjectStore(ctx, cfg, nil) + store, err = newCommandObjectStoreWithDependencies(ctx, cfg, nil, deps) if err != nil { return nil, nil, nil, nil, nil, err } } else { - store, _ = objectStoreIfConfigured(ctx, cfg) + store, _ = objectStoreIfConfiguredWithDependencies(ctx, cfg, deps) } var locks []artifacts.RemoteLock if store != nil { @@ -148,9 +160,7 @@ func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore b } func objectStoreIfConfigured(ctx context.Context, cfg *config.Config) (storage.ObjectStore, error) { - return objectStoreIfConfiguredWithDependencies(ctx, cfg, commandDependencies{ - newObjectStore: newObjectStoreFromConfigFn, - }) + return objectStoreIfConfiguredWithDependencies(ctx, cfg, commandDependencies{}) } func objectStoreIfConfiguredWithDependencies(ctx context.Context, cfg *config.Config, deps commandDependencies) (storage.ObjectStore, error) { diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index 902fda0..ac3fc9d 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -23,17 +23,17 @@ func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) { pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "init", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, "--title", "The Black Cabin", "--remote", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -56,14 +56,14 @@ func TestExecuteSessionInitRemoteUsesDefaultConfigDiscovery(t *testing.T) { withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "init", "2026-06-07", "--remote", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -124,16 +124,16 @@ inputs: fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "init", "2026-06-07", "--config", defaultPipeline, "--campaign-file", explicitCampaign, "--remote", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -194,20 +194,16 @@ func TestExecuteSessionInitRemoteLoadsSecretsBeforeObjectStoreInit(t *testing.T) addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv) fake := &storage.FakeBackend{} - origStoreFn := newObjectStoreFromConfigFn - newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + deps := commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) { if os.Getenv(accessKeyEnv) != "test-key-id" || os.Getenv(secretKeyEnv) != "test-secret" { return nil, fmt.Errorf("secrets were not loaded before object store init") } return fake, nil - } - t.Cleanup(func() { - newObjectStoreFromConfigFn = origStoreFn - }) + }} var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -272,16 +268,16 @@ inputs: `) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "init", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, "--remote", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -431,20 +427,16 @@ inputs: fake := &storage.FakeBackend{} audioKey := artifacts.S3PublishedOutputKey(artifacts.S3AudioPrefix(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "audio/"), "alice.flac") fake.SeedObject(storage.FakeObject{Key: audioKey, Data: []byte("audio")}) - origStoreFn := newObjectStoreFromConfigFn - newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + deps := commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) { if os.Getenv(accessKeyEnv) != "test-key-id" || os.Getenv(secretKeyEnv) != "test-secret" { return nil, fmt.Errorf("secrets were not loaded before object store init") } return fake, nil - } - t.Cleanup(func() { - newObjectStoreFromConfigFn = origStoreFn - }) + }} var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } @@ -472,7 +464,7 @@ func TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) { fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) commonArgs := []string{ "2026-05-03", "--config", pipelinePath, @@ -483,7 +475,7 @@ func TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer validateArgs := append([]string{"session", "validate"}, commonArgs...) - if code := Execute(validateArgs, &stdout, &stderr); code != 0 { + if code := executeWithDependencies(validateArgs, &stdout, &stderr, deps, executePlan); code != 0 { t.Fatalf("validate exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "OK inputs spell_catalog: "+spellPath) { @@ -493,7 +485,7 @@ func TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) { stdout.Reset() stderr.Reset() statusArgs := append([]string{"session", "status"}, commonArgs...) - if code := executeWithDependencies(statusArgs, &stdout, &stderr, fakeCommandDependencies(fake, &storeInitCalls), executePlan); code != 0 { + if code := executeWithDependencies(statusArgs, &stdout, &stderr, deps, executePlan); code != 0 { t.Fatalf("status exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } if !strings.Contains(stdout.String(), "Stable input spell_catalog: "+spellPath) { @@ -505,7 +497,7 @@ func TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) { } stdout.Reset() stderr.Reset() - if code := Execute(validateArgs, &stdout, &stderr); code == 0 { + if code := executeWithDependencies(validateArgs, &stdout, &stderr, deps, executePlan); code == 0 { t.Fatalf("validate missing spell catalog exit code = 0; stdout=%q", stdout.String()) } if !strings.Contains(stdout.String(), "ERROR inputs spell_catalog missing:") { @@ -518,17 +510,17 @@ func TestExecuteLocksAddListAndRemoveUseIndependentObjects(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "manual edit", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -547,12 +539,12 @@ func TestExecuteLocksAddListAndRemoveUseIndependentObjects(t *testing.T) { stdout.Reset() stderr.Reset() - code = Execute([]string{ + code = executeWithDependencies([]string{ "session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("locks list exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -562,12 +554,12 @@ func TestExecuteLocksAddListAndRemoveUseIndependentObjects(t *testing.T) { stdout.Reset() stderr.Reset() - code = Execute([]string{ + code = executeWithDependencies([]string{ "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -576,12 +568,12 @@ func TestExecuteLocksAddListAndRemoveUseIndependentObjects(t *testing.T) { } stdout.Reset() stderr.Reset() - code = Execute([]string{ + code = executeWithDependencies([]string{ "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 || !strings.Contains(stdout.String(), "was not locked") { t.Fatalf("idempotent remove exit code = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } @@ -595,7 +587,7 @@ func TestExecuteLocksListIsSortedAndRejectsMalformedObjects(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final_trimmed", "second") seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final", "first") @@ -603,7 +595,7 @@ func TestExecuteLocksListIsSortedAndRejectsMalformedObjects(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - if code := Execute(args, &stdout, &stderr); code != 0 { + if code := executeWithDependencies(args, &stdout, &stderr, deps, executePlan); code != 0 { t.Fatalf("locks list exit code = %d; stderr=%q", code, stderr.String()) } first := strings.Index(stdout.String(), "narratio.transcript.final ") @@ -619,7 +611,7 @@ func TestExecuteLocksListIsSortedAndRejectsMalformedObjects(t *testing.T) { fake.SeedObject(storage.FakeObject{Key: badKey, Data: []byte(`{"format_version":1}`)}) stdout.Reset() stderr.Reset() - if code := Execute(args, &stdout, &stderr); code == 0 { + if code := executeWithDependencies(args, &stdout, &stderr, deps, executePlan); code == 0 { t.Fatalf("malformed lock list exit code = 0; stdout=%q", stdout.String()) } } @@ -629,30 +621,30 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "first", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("initial locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) } stdout.Reset() stderr.Reset() - code = Execute([]string{ + code = executeWithDependencies([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "second", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code == 0 { t.Fatal("duplicate locks add exit code = 0, want non-zero") } @@ -662,14 +654,14 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) { stdout.Reset() stderr.Reset() - code = Execute([]string{ + code = executeWithDependencies([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "second", "--force", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("forced locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -688,7 +680,7 @@ func TestExecuteLocksForceReportsConcurrentReplacement(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") key := seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final_trimmed", "first") replacement, err := artifacts.NewRemoteLock("narratio.transcript.final_trimmed", "concurrent", time.Date(2026, 5, 20, 1, 2, 3, 0, time.UTC)) @@ -708,10 +700,10 @@ func TestExecuteLocksForceReportsConcurrentReplacement(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--force", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code == 0 || !strings.Contains(stderr.String(), "changed concurrently; retry") { t.Fatalf("forced conflict exit code = %d, stderr=%q", code, stderr.String()) } @@ -747,7 +739,7 @@ func TestExecuteLocksMutationRejectsSessionIDMismatch(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) tests := []struct { name string @@ -778,7 +770,7 @@ func TestExecuteLocksMutationRejectsSessionIDMismatch(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute(tt.args, &stdout, &stderr) + code := executeWithDependencies(tt.args, &stdout, &stderr, deps, executePlan) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -903,17 +895,17 @@ func TestExecuteArtifactsListRemoteReportsPublishedAvailability(t *testing.T) { "narratio.transcript.final_trimmed": "transcripts/final.trimmed.json", }) var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--remote", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -938,17 +930,17 @@ func TestExecuteArtifactsListReportsExtractionLifecycleWithoutPayload(t *testing "narratio.extraction.encounters": "artifacts/encounters.json", }) var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--remote", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -971,12 +963,12 @@ func TestExecuteArtifactsListReportsExtractionLifecycleWithoutPayload(t *testing } stdout.Reset() stderr.Reset() - code = Execute([]string{ + code = executeWithDependencies([]string{ "session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("unavailable exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -1003,17 +995,17 @@ func TestExecuteArtifactsListRemoteUsesPublishOutputDestinations(t *testing.T) { "narratio.bounds.session": "transcripts/bounds.json", }) var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--remote", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -1056,7 +1048,6 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) { }) seedOperatorRemoteLock(t, fake, sessionPrefix, "narratio.transcript.final_trimmed", "remote review") var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer @@ -1103,7 +1094,6 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin `) fake := &storage.FakeBackend{ExistsErr: fmt.Errorf("exists failed")} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer @@ -1131,7 +1121,6 @@ func TestExecuteStatusReportsMissingRemoteCurrentStateWithoutFailing(t *testing. pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer @@ -1157,7 +1146,6 @@ func TestExecuteStatusReportsPreviousStateReadinessWithoutFailing(t *testing.T) replaceInFileOrFatal(t, sessionPath, "session_id: 2026-05-03\n", "session_id: 2026-05-03\nprevious_session_id: 2026-04-26\n") fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer @@ -1188,7 +1176,6 @@ func TestExecuteStatusDetectsMissingRequiredPreviousArtifact(t *testing.T) { fake := &storage.FakeBackend{} seedRestorePreviousCurrentManifestOnly(t, fake, cfg) var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer @@ -1214,16 +1201,16 @@ func TestExecuteSessionValidateReportsPreviousStateFindingAndReturnsFindingError replaceInFileOrFatal(t, sessionPath, "session_id: 2026-05-03\n", "session_id: 2026-05-03\nprevious_session_id: 2026-04-26\n") fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code == 0 { t.Fatal("exit code = 0, want non-zero") } diff --git a/internal/app/operator_inspection.go b/internal/app/operator_inspection.go index 67af769..c17157f 100644 --- a/internal/app/operator_inspection.go +++ b/internal/app/operator_inspection.go @@ -184,9 +184,7 @@ func inspectPreviousArtifactReadiness( } func inspectRemoteCurrentState(ctx context.Context, cfg *config.Config, store storage.ObjectStore) remoteCurrentStateCheck { - return inspectRemoteCurrentStateWithDependencies(ctx, cfg, store, commandDependencies{ - discoverRemoteCurrentState: discoverRemoteCurrentStateFn, - }) + return inspectRemoteCurrentStateWithDependencies(ctx, cfg, store, commandDependencies{}) } func inspectRemoteCurrentStateWithDependencies(ctx context.Context, cfg *config.Config, store storage.ObjectStore, deps commandDependencies) remoteCurrentStateCheck { diff --git a/internal/app/operator_locks.go b/internal/app/operator_locks.go index 087eaf0..3312ab9 100644 --- a/internal/app/operator_locks.go +++ b/internal/app/operator_locks.go @@ -16,6 +16,10 @@ import ( // LocksList lists independent publish locks. func LocksList(ctx context.Context, args []string, out io.Writer) error { + return locksListWithDependencies(ctx, args, out, commandDependencies{}) +} + +func locksListWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { fs := flag.NewFlagSet("locks", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags @@ -26,7 +30,7 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error { if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("locks: session_id is required") } - cfg, store, sessionPrefix, cleanup, err := loadLockCommandContext(ctx, flags) + cfg, store, sessionPrefix, cleanup, err := loadLockCommandContextWithDependencies(ctx, flags, deps) if err != nil { return fmt.Errorf("locks: %w", err) } @@ -41,6 +45,10 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error { // LocksAdd adds or updates one remote lock. func LocksAdd(ctx context.Context, args []string, out io.Writer) error { + return locksAddWithDependencies(ctx, args, out, commandDependencies{}) +} + +func locksAddWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { fs := flag.NewFlagSet("locks add", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags @@ -56,7 +64,7 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error { if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("locks add: session_id is required") } - _, store, sessionPrefix, cleanup, err := loadLockCommandContext(ctx, flags) + _, store, sessionPrefix, cleanup, err := loadLockCommandContextWithDependencies(ctx, flags, deps) if err != nil { return fmt.Errorf("locks add: %w", err) } @@ -86,6 +94,10 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error { // LocksRemove removes one remote lock. func LocksRemove(ctx context.Context, args []string, out io.Writer) error { + return locksRemoveWithDependencies(ctx, args, out, commandDependencies{}) +} + +func locksRemoveWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { fs := flag.NewFlagSet("locks remove", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags @@ -97,7 +109,7 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error { if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("locks remove: session_id is required") } - _, store, sessionPrefix, cleanup, err := loadLockCommandContext(ctx, flags) + _, store, sessionPrefix, cleanup, err := loadLockCommandContextWithDependencies(ctx, flags, deps) if err != nil { return fmt.Errorf("locks remove: %w", err) } @@ -118,7 +130,12 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error { } func loadLockCommandContext(ctx context.Context, flags commonConfigFlags) (*config.Config, storage.ObjectStore, string, func(), error) { - loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) + return loadLockCommandContextWithDependencies(ctx, flags, commandDependencies{}) +} + +func loadLockCommandContextWithDependencies(ctx context.Context, flags commonConfigFlags, deps commandDependencies) (*config.Config, storage.ObjectStore, string, func(), error) { + deps = normalizeCommandDependencies(deps) + loaded, err := loadCommandConfigWithDependencies(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions(), deps) if err != nil { return nil, nil, "", nil, err } @@ -132,7 +149,7 @@ func loadLockCommandContext(ctx context.Context, flags commonConfigFlags) (*conf if err := config.Validate(cfg); err != nil { return nil, nil, "", nil, err } - store, err := newCommandObjectStore(ctx, cfg, nil) + store, err := newCommandObjectStoreWithDependencies(ctx, cfg, nil, deps) if err != nil { return nil, nil, "", nil, err } diff --git a/internal/app/operator_session_init.go b/internal/app/operator_session_init.go index 30cb5cb..091f652 100644 --- a/internal/app/operator_session_init.go +++ b/internal/app/operator_session_init.go @@ -19,6 +19,11 @@ import ( // SessionInit creates a local or remote session.yml skeleton. func SessionInit(ctx context.Context, args []string, out io.Writer) error { + return sessionInitWithDependencies(ctx, args, out, commandDependencies{}) +} + +func sessionInitWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { + deps = normalizeCommandDependencies(deps) fs := flag.NewFlagSet("session init", flag.ContinueOnError) fs.SetOutput(io.Discard) var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string @@ -51,7 +56,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error { return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive") } - base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Profile: profile.pointer()}) + base, err := loadPipelineCampaignConfigWithDependencies(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Profile: profile.pointer()}, deps) if err != nil { return fmt.Errorf("session init: %w", err) } @@ -98,7 +103,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error { return err } - store, err := newCommandObjectStore(ctx, cfg, nil) + store, err := newCommandObjectStoreWithDependencies(ctx, cfg, nil, deps) if err != nil { return fmt.Errorf("session init: %w", err) } diff --git a/internal/app/operator_session_validate.go b/internal/app/operator_session_validate.go index 5419aad..d513a0a 100644 --- a/internal/app/operator_session_validate.go +++ b/internal/app/operator_session_validate.go @@ -13,6 +13,11 @@ import ( // SessionValidate performs a read-only session preflight. func SessionValidate(ctx context.Context, args []string, out io.Writer) error { + return sessionValidateWithDependencies(ctx, args, out, commandDependencies{}) +} + +func sessionValidateWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { + deps = normalizeCommandDependencies(deps) fs := flag.NewFlagSet("session validate", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags @@ -25,7 +30,7 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error { } findings := []finding{} - loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) + loaded, err := loadCommandConfigWithDependencies(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions(), deps) if err != nil { findings = append(findings, errorFinding("config", err.Error())) return renderFindings(out, "", "", findings) @@ -43,7 +48,7 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error { findings = append(findings, validateStableInputFindings(cfg)...) findings = append(findings, validateLocalAudioFindings(cfg)...) - store, storeErr := objectStoreIfConfigured(ctx, cfg) + store, storeErr := objectStoreIfConfiguredWithDependencies(ctx, cfg, deps) if storeErr != nil { findings = append(findings, errorFinding("storage", storeErr.Error())) } diff --git a/internal/app/remote_session_test.go b/internal/app/remote_session_test.go index 2425e32..5629318 100644 --- a/internal/app/remote_session_test.go +++ b/internal/app/remote_session_test.go @@ -426,23 +426,6 @@ func TestExecuteRemoteSessionMismatchFails(t *testing.T) { } } -func restoreAppConfigTestGlobals(t *testing.T, fake *storage.FakeBackend, storeInitCalls *int, sessionDefaults []string) { - t.Helper() - origStoreFn := newObjectStoreFromConfigFn - origSessionDefaults := append([]string(nil), config.DefaultSessionConfigSearchPaths...) - config.DefaultSessionConfigSearchPaths = append([]string(nil), sessionDefaults...) - newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { - if storeInitCalls != nil { - (*storeInitCalls)++ - } - return fake, nil - } - t.Cleanup(func() { - newObjectStoreFromConfigFn = origStoreFn - config.DefaultSessionConfigSearchPaths = origSessionDefaults - }) -} - func remoteSessionLoaderDependencies(fake *storage.FakeBackend) (commandDependencies, *int) { storeInitCalls := new(int) return fakeCommandDependencies(fake, storeInitCalls), storeInitCalls diff --git a/internal/app/restore.go b/internal/app/restore.go index 502aaa9..6fd9b39 100644 --- a/internal/app/restore.go +++ b/internal/app/restore.go @@ -54,7 +54,8 @@ 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") } - loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) + restoreDeps := commandDependencies{newObjectStore: newObjectStoreFromConfigFn} + loaded, err := loadCommandConfigWithDependencies(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions(), restoreDeps) if err != nil { return fmt.Errorf("restore: %w", err) } @@ -63,7 +64,7 @@ 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 := newCommandObjectStore(ctx, cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)) + objectStore, err := newCommandObjectStoreWithDependencies(ctx, cfg, logging.NewLogger(os.Stderr, slog.LevelInfo), restoreDeps) if err != nil { return fmt.Errorf("restore: %w", err) } diff --git a/internal/app/session_oriented_cli_test.go b/internal/app/session_oriented_cli_test.go index e83d9f7..b846395 100644 --- a/internal/app/session_oriented_cli_test.go +++ b/internal/app/session_oriented_cli_test.go @@ -232,7 +232,7 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) { fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")}) var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) tests := []struct { name string @@ -270,7 +270,7 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) { t.Run(tt.name, func(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute(tt.args, &stdout, &stderr) + code := executeWithDependencies(tt.args, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } @@ -311,17 +311,17 @@ func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "review", - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -336,12 +336,12 @@ func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) { stdout.Reset() stderr.Reset() - code = Execute([]string{ + code = executeWithDependencies([]string{ "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String()) }