diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e1030b2..b705848 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -152,7 +152,7 @@ local dependency values. ## Stage 2 — Carry Dependencies Through Pipeline Commands -**Status:** Pending +**Status:** Completed ### Goal diff --git a/internal/app/assembled_workflow_test.go b/internal/app/assembled_workflow_test.go index 57cac88..a6ad489 100644 --- a/internal/app/assembled_workflow_test.go +++ b/internal/app/assembled_workflow_test.go @@ -58,11 +58,12 @@ func TestAssembledCanonicalAndAliasArtifactRegenerationRequestsMatch(t *testing. "2026-05-03", "--force", "--from", "extract", "--through", "analyze", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, } - if err := runWithPipelineExecutor(context.Background(), base, &bytes.Buffer{}, executor); err != nil { + deps := fakeCommandDependencies(&storage.FakeBackend{}, nil) + if err := runWithDependencies(context.Background(), base, &bytes.Buffer{}, executor, deps); err != nil { t.Fatalf("canonical unselected Run() error = %v", err) } selected := append(append([]string(nil), base...), "--artifacts", "session_recap") - if err := runWithPipelineExecutor(context.Background(), selected, &bytes.Buffer{}, executor); err != nil { + if err := runWithDependencies(context.Background(), selected, &bytes.Buffer{}, executor, deps); err != nil { t.Fatalf("canonical selected Run() error = %v", err) } var stdout, stderr bytes.Buffer @@ -70,7 +71,7 @@ func TestAssembledCanonicalAndAliasArtifactRegenerationRequestsMatch(t *testing. "regenerate-artifacts", "2026-05-03", "--artifacts", "session_recap", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, } - if code := executeWithPipelineExecutor(alias, &stdout, &stderr, executor); code != 0 { + if code := executeWithDependencies(alias, &stdout, &stderr, deps, executor); code != 0 { t.Fatalf("alias exit=%d stderr=%q", code, stderr.String()) } if len(captured) != 3 { diff --git a/internal/app/commands.go b/internal/app/commands.go index 0743985..fcd4ca3 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -11,10 +11,15 @@ var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run- // Execute dispatches CLI commands and returns a process exit code. func Execute(args []string, stdout, stderr io.Writer) int { - return executeWithPipelineExecutor(args, stdout, stderr, executePlan) + return executeWithDependencies(args, stdout, stderr, commandDependencies{}, executePlan) } func executeWithPipelineExecutor(args []string, stdout, stderr io.Writer, executor pipelineExecutor) int { + return executeWithDependencies(args, stdout, stderr, commandDependencies{}, executor) +} + +func executeWithDependencies(args []string, stdout, stderr io.Writer, deps commandDependencies, executor pipelineExecutor) int { + deps = normalizeCommandDependencies(deps) if len(args) == 0 { printUsage(stderr) return 1 @@ -29,17 +34,17 @@ func executeWithPipelineExecutor(args []string, stdout, stderr io.Writer, execut case "version": err = Version(cmdArgs, stdout) case "run": - err = runWithPipelineExecutor(ctx, cmdArgs, stdout, executor) + err = runWithDependencies(ctx, cmdArgs, stdout, executor, deps) case "regenerate-artifacts": - err = regenerateArtifactsWithPipelineExecutor(ctx, cmdArgs, stdout, executor) + err = regenerateArtifactsWithDependencies(ctx, cmdArgs, stdout, executor, deps) case "run-stage": - err = runStageWithPipelineExecutor(ctx, cmdArgs, stdout, executor) + err = runStageWithDependencies(ctx, cmdArgs, stdout, executor, deps) case "analyze": - err = analyzeWithPipelineExecutor(ctx, cmdArgs, stdout, executor) + err = analyzeWithDependencies(ctx, cmdArgs, stdout, executor, deps) case "publish": - err = publishWithPipelineExecutor(ctx, cmdArgs, stdout, executor) + err = publishWithDependencies(ctx, cmdArgs, stdout, executor, deps) case "session": - err = Session(ctx, cmdArgs, stdout) + err = sessionWithDependencies(ctx, cmdArgs, stdout, deps) case "clean": err = Clean(ctx, cmdArgs, stdout) case "config": diff --git a/internal/app/operator_helpers.go b/internal/app/operator_helpers.go index ad4d6b9..73da766 100644 --- a/internal/app/operator_helpers.go +++ b/internal/app/operator_helpers.go @@ -47,6 +47,12 @@ 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, + }) +} + +func sessionWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { if len(args) == 0 { return fmt.Errorf("session: expected subcommand: init|validate|status|plan|restore|artifacts|locks") } @@ -56,9 +62,9 @@ func Session(ctx context.Context, args []string, out io.Writer) error { case "validate": return SessionValidate(ctx, args[1:], out) case "status": - return Status(ctx, args[1:], out) + return statusWithDependencies(ctx, args[1:], out, deps) case "plan": - return Plan(ctx, args[1:], out) + return planWithDependencies(ctx, args[1:], out, deps) case "restore": return Restore(ctx, args[1:], out) case "artifacts": @@ -142,10 +148,16 @@ 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, + }) +} + +func objectStoreIfConfiguredWithDependencies(ctx context.Context, cfg *config.Config, deps commandDependencies) (storage.ObjectStore, error) { if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Storage.S3 == nil || strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket) == "" { return nil, nil } - store, err := newCommandObjectStore(ctx, cfg, nil) + store, err := newCommandObjectStoreWithDependencies(ctx, cfg, nil, deps) if err != nil { return nil, err } diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index e35b36b..902fda0 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -493,7 +493,7 @@ func TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) { stdout.Reset() stderr.Reset() statusArgs := append([]string{"session", "status"}, commonArgs...) - if code := Execute(statusArgs, &stdout, &stderr); code != 0 { + if code := executeWithDependencies(statusArgs, &stdout, &stderr, fakeCommandDependencies(fake, &storeInitCalls), 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) { @@ -1057,15 +1057,16 @@ 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 var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -1103,15 +1104,16 @@ 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 var stderr bytes.Buffer - code := Execute([]string{ + code := executeWithDependencies([]string{ "session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -1130,15 +1132,16 @@ func TestExecuteStatusReportsMissingRemoteCurrentStateWithoutFailing(t *testing. 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", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -1155,15 +1158,16 @@ func TestExecuteStatusReportsPreviousStateReadinessWithoutFailing(t *testing.T) 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", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -1185,15 +1189,16 @@ func TestExecuteStatusDetectsMissingRequiredPreviousArtifact(t *testing.T) { seedRestorePreviousCurrentManifestOnly(t, fake, cfg) 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", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, - }, &stdout, &stderr) + }, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } diff --git a/internal/app/operator_inspection.go b/internal/app/operator_inspection.go index 3d038f9..67af769 100644 --- a/internal/app/operator_inspection.go +++ b/internal/app/operator_inspection.go @@ -184,10 +184,17 @@ func inspectPreviousArtifactReadiness( } func inspectRemoteCurrentState(ctx context.Context, cfg *config.Config, store storage.ObjectStore) remoteCurrentStateCheck { + return inspectRemoteCurrentStateWithDependencies(ctx, cfg, store, commandDependencies{ + discoverRemoteCurrentState: discoverRemoteCurrentStateFn, + }) +} + +func inspectRemoteCurrentStateWithDependencies(ctx context.Context, cfg *config.Config, store storage.ObjectStore, deps commandDependencies) remoteCurrentStateCheck { if store == nil { return remoteCurrentStateCheck{} } - current, err := discoverRemoteCurrentStateFn(ctx, cfg, store) + deps = normalizeCommandDependencies(deps) + current, err := deps.discoverRemoteCurrentState(ctx, cfg, store) if err != nil { return remoteCurrentStateCheck{Err: err} } diff --git a/internal/app/operator_status.go b/internal/app/operator_status.go index 0300b6f..a2d9e50 100644 --- a/internal/app/operator_status.go +++ b/internal/app/operator_status.go @@ -16,6 +16,11 @@ import ( // Status reports effective local/remote session state. func Status(ctx context.Context, args []string, out io.Writer) error { + return statusWithDependencies(ctx, args, out, commandDependencies{}) +} + +func statusWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { + deps = normalizeCommandDependencies(deps) fs := flag.NewFlagSet("status", flag.ContinueOnError) fs.SetOutput(io.Discard) var flags commonConfigFlags @@ -26,7 +31,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error { if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("status: session_id is required") } - 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("status: %w", err) } @@ -56,13 +61,13 @@ func Status(ctx context.Context, args []string, out io.Writer) error { writeStageStatuses(out, m) } - store, storeErr := objectStoreIfConfigured(ctx, cfg) + store, storeErr := objectStoreIfConfiguredWithDependencies(ctx, cfg, deps) var remoteCurrent *RemoteCurrentState var remoteCurrentErr error if storeErr != nil { fmt.Fprintf(out, "Remote publish: unavailable: %v\n", storeErr) } else if store != nil { - current := inspectRemoteCurrentState(ctx, cfg, store) + current := inspectRemoteCurrentStateWithDependencies(ctx, cfg, store, deps) if current.Err != nil { remoteCurrentErr = current.Err fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", current.Err) diff --git a/internal/app/plan.go b/internal/app/plan.go index d98e18f..0f14edd 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -18,6 +18,11 @@ import ( // Plan validates configuration and prints a read-only execution preview. func Plan(ctx context.Context, args []string, out io.Writer) error { + return planWithDependencies(ctx, args, out, commandDependencies{}) +} + +func planWithDependencies(ctx context.Context, args []string, out io.Writer, deps commandDependencies) error { + deps = normalizeCommandDependencies(deps) request, err := parseBoundedRunRequest("plan", args, out) if err != nil { if errors.Is(err, flag.ErrHelp) { @@ -26,7 +31,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { return err } flags := request.Config - 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("plan: %w", err) } diff --git a/internal/app/regenerate_artifacts.go b/internal/app/regenerate_artifacts.go index b8a53b8..9e6a406 100644 --- a/internal/app/regenerate_artifacts.go +++ b/internal/app/regenerate_artifacts.go @@ -9,10 +9,14 @@ import ( // RegenerateArtifacts expands the convenience command into its canonical run // invocation. The run command remains the sole owner of parsing and execution. func RegenerateArtifacts(ctx context.Context, args []string, out io.Writer) error { - return regenerateArtifactsWithPipelineExecutor(ctx, args, out, executePlan) + return regenerateArtifactsWithDependencies(ctx, args, out, executePlan, commandDependencies{}) } func regenerateArtifactsWithPipelineExecutor(ctx context.Context, args []string, out io.Writer, executor pipelineExecutor) error { + return regenerateArtifactsWithDependencies(ctx, args, out, executor, commandDependencies{}) +} + +func regenerateArtifactsWithDependencies(ctx context.Context, args []string, out io.Writer, executor pipelineExecutor, deps commandDependencies) error { if containsHelpOption(args) { printRegenerateArtifactsHelp(out) return nil @@ -25,7 +29,7 @@ func regenerateArtifactsWithPipelineExecutor(ctx context.Context, args []string, } expanded = append(expanded, "--force", "--from", "extract", "--through", "analyze") expanded = append(expanded, args...) - return runWithPipelineExecutor(ctx, expanded, out, executor) + return runWithDependencies(ctx, expanded, out, executor, deps) } func containsHelpOption(args []string) bool { diff --git a/internal/app/remote_session_test.go b/internal/app/remote_session_test.go index f7c5340..2425e32 100644 --- a/internal/app/remote_session_test.go +++ b/internal/app/remote_session_test.go @@ -25,11 +25,12 @@ inputs: prefix: audio/ `) var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + setSessionConfigSearchPaths(t, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -154,7 +155,8 @@ inputs: fake := &storage.FakeBackend{} seedRemoteSessionConfig(t, fake, "2026-05-03", tt.sessionYAML) var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + setSessionConfigSearchPaths(t, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) tempRoot := t.TempDir() t.Setenv("TMPDIR", tempRoot) @@ -166,7 +168,7 @@ inputs: args := append(append([]string(nil), tt.command...), "--config", pipelinePath, "--campaign-file", campaignPath) var stdout bytes.Buffer var stderr bytes.Buffer - code := executeWithPipelineExecutor(args, &stdout, &stderr, executor) + code := executeWithDependencies(args, &stdout, &stderr, deps, executor) if tt.wantSuccessful && code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -243,23 +245,17 @@ inputs: audio_s3: prefix: audio/ `) - origStoreFn := newObjectStoreFromConfigFn - origSessionDefaults := append([]string(nil), config.DefaultSessionConfigSearchPaths...) - config.DefaultSessionConfigSearchPaths = []string{filepath.Join(t.TempDir(), "session.yml")} - newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + setSessionConfigSearchPaths(t, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) { if os.Getenv(accessKeyEnv) != "remote-session-key-id" || os.Getenv(secretKeyEnv) != "remote-session-secret" { return nil, fmt.Errorf("secrets were not loaded before remote session object store init") } return fake, nil - } - t.Cleanup(func() { - newObjectStoreFromConfigFn = origStoreFn - config.DefaultSessionConfigSearchPaths = origSessionDefaults - }) + }} var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } @@ -270,11 +266,12 @@ func TestExecuteExplicitLocalSessionPrecedenceSkipsRemote(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + setSessionConfigSearchPaths(t, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -288,11 +285,12 @@ func TestExecuteLocalSessionDiscoveryPrecedenceSkipsRemote(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) + setSessionConfigSearchPaths(t, []string{sessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr, deps, executePlan) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -307,11 +305,12 @@ func TestExecuteRemoteSessionMissingObjectFailsClearly(t *testing.T) { fake := &storage.FakeBackend{} var storeInitCalls int missingSessionPath := filepath.Join(t.TempDir(), "session.yml") - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{missingSessionPath}) + setSessionConfigSearchPaths(t, []string{missingSessionPath}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr, deps, executePlan) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -327,11 +326,12 @@ func TestExecuteRemoteSessionRequiresSessionID(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) var storeInitCalls int - restoreAppConfigTestGlobals(t, &storage.FakeBackend{}, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + setSessionConfigSearchPaths(t, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(&storage.FakeBackend{}, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "plan", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr, deps, executePlan) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -346,20 +346,14 @@ func TestExecuteRemoteSessionRequiresSessionID(t *testing.T) { func TestExecuteRemoteSessionStorageInitErrorFailsClearly(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) - origStoreFn := newObjectStoreFromConfigFn - origSessionDefaults := append([]string(nil), config.DefaultSessionConfigSearchPaths...) - config.DefaultSessionConfigSearchPaths = []string{filepath.Join(t.TempDir(), "session.yml")} - newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) { + setSessionConfigSearchPaths(t, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) { return nil, errors.New("storage unavailable") - } - t.Cleanup(func() { - newObjectStoreFromConfigFn = origStoreFn - config.DefaultSessionConfigSearchPaths = origSessionDefaults - }) + }} var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr, deps, executePlan) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -374,11 +368,12 @@ func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) { fake := &storage.FakeBackend{} seedRemoteSessionConfig(t, fake, "2026-05-03", "session_id: 2026-05-03\nunknown: true\n") var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + setSessionConfigSearchPaths(t, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr, deps, executePlan) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -397,11 +392,12 @@ inputs: prefix: audio/ `) var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + setSessionConfigSearchPaths(t, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr, deps, executePlan) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -416,11 +412,12 @@ func TestExecuteRemoteSessionMismatchFails(t *testing.T) { fake := &storage.FakeBackend{} seedRemoteSessionConfig(t, fake, "2026-05-03", "session_id: 2026-05-04\ninputs:\n audio_s3:\n prefix: audio/\n") var storeInitCalls int - restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")}) + setSessionConfigSearchPaths(t, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) + code := executeWithDependencies([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr, deps, executePlan) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -448,12 +445,16 @@ func restoreAppConfigTestGlobals(t *testing.T, fake *storage.FakeBackend, storeI func remoteSessionLoaderDependencies(fake *storage.FakeBackend) (commandDependencies, *int) { storeInitCalls := new(int) - return commandDependencies{ - newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) { + return fakeCommandDependencies(fake, storeInitCalls), storeInitCalls +} + +func fakeCommandDependencies(fake *storage.FakeBackend, storeInitCalls *int) commandDependencies { + return commandDependencies{newObjectStore: func(context.Context, *config.Config) (storage.ObjectStore, error) { + if storeInitCalls != nil { *storeInitCalls++ - return fake, nil - }, - }, storeInitCalls + } + return fake, nil + }} } func setSessionConfigSearchPaths(t *testing.T, paths []string) { diff --git a/internal/app/run.go b/internal/app/run.go index 9c71f69..b18f69c 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -12,10 +12,15 @@ import ( // Run executes the pipeline plan and persists manifest state. func Run(ctx context.Context, args []string, out io.Writer) error { - return runWithPipelineExecutor(ctx, args, out, executePlan) + return runWithDependencies(ctx, args, out, executePlan, commandDependencies{}) } func runWithPipelineExecutor(ctx context.Context, args []string, out io.Writer, executor pipelineExecutor) error { + return runWithDependencies(ctx, args, out, executor, commandDependencies{}) +} + +func runWithDependencies(ctx context.Context, args []string, out io.Writer, executor pipelineExecutor, deps commandDependencies) error { + deps = normalizeCommandDependencies(deps) request, err := parseBoundedRunRequest("run", args, out) if err != nil { if errors.Is(err, flag.ErrHelp) { @@ -24,7 +29,7 @@ func runWithPipelineExecutor(ctx context.Context, args []string, out io.Writer, return err } flags := request.Config - 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("run: %w", err) } diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index 6b3cd77..f4ba0e7 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -12,10 +12,14 @@ import ( // RunStage executes exactly one selected stage. func RunStage(ctx context.Context, args []string, out io.Writer) error { - return runStageWithPipelineExecutor(ctx, args, out, executePlan) + return runStageWithDependencies(ctx, args, out, executePlan, commandDependencies{}) } func runStageWithPipelineExecutor(ctx context.Context, args []string, out io.Writer, executor pipelineExecutor) error { + return runStageWithDependencies(ctx, args, out, executor, commandDependencies{}) +} + +func runStageWithDependencies(ctx context.Context, args []string, out io.Writer, executor pipelineExecutor, deps commandDependencies) error { var stageName string var positionalSessionID string if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) { @@ -78,7 +82,7 @@ func runStageWithPipelineExecutor(ctx context.Context, args []string, out io.Wri PreviousSessionID: flags.previousSessionID, Force: force, SelectedArtifacts: normalizedArtifacts, - }, executor) + }, executor, deps) if err != nil { return err } @@ -97,10 +101,14 @@ func runStageWithPipelineExecutor(ctx context.Context, args []string, out io.Wri // Analyze force-runs the analyze stage. func Analyze(ctx context.Context, args []string, out io.Writer) error { - return analyzeWithPipelineExecutor(ctx, args, out, executePlan) + return analyzeWithDependencies(ctx, args, out, executePlan, commandDependencies{}) } func analyzeWithPipelineExecutor(ctx context.Context, args []string, out io.Writer, executor pipelineExecutor) error { + return analyzeWithDependencies(ctx, args, out, executor, commandDependencies{}) +} + +func analyzeWithDependencies(ctx context.Context, args []string, out io.Writer, executor pipelineExecutor, deps commandDependencies) error { fs := flag.NewFlagSet("analyze", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -131,7 +139,7 @@ func analyzeWithPipelineExecutor(ctx context.Context, args []string, out io.Writ PreviousSessionID: flags.previousSessionID, Force: true, SelectedArtifacts: normalizedArtifacts, - }, executor) + }, executor, deps) if err != nil { return err } @@ -148,10 +156,14 @@ func analyzeWithPipelineExecutor(ctx context.Context, args []string, out io.Writ // Publish force-runs the publish stage. func Publish(ctx context.Context, args []string, out io.Writer) error { - return publishWithPipelineExecutor(ctx, args, out, executePlan) + return publishWithDependencies(ctx, args, out, executePlan, commandDependencies{}) } func publishWithPipelineExecutor(ctx context.Context, args []string, out io.Writer, executor pipelineExecutor) error { + return publishWithDependencies(ctx, args, out, executor, commandDependencies{}) +} + +func publishWithDependencies(ctx context.Context, args []string, out io.Writer, executor pipelineExecutor, deps commandDependencies) error { fs := flag.NewFlagSet("publish", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -182,7 +194,7 @@ func publishWithPipelineExecutor(ctx context.Context, args []string, out io.Writ PreviousSessionID: flags.previousSessionID, Force: true, SelectedArtifacts: normalizedArtifacts, - }, executor) + }, executor, deps) if err != nil { return err } @@ -210,16 +222,17 @@ type singleStageCommand struct { SelectedArtifacts []string } -func runSingleStageCommand(ctx context.Context, req singleStageCommand, executor pipelineExecutor) (*RunSummary, error) { +func runSingleStageCommand(ctx context.Context, req singleStageCommand, executor pipelineExecutor, deps commandDependencies) (*RunSummary, error) { + deps = normalizeCommandDependencies(deps) plan, err := buildSingleStageExecutionPlan(req.StageName) if err != nil { return nil, fmt.Errorf("%s: %w", req.CommandName, err) } - loaded, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{ + loaded, err := loadCommandConfigWithDependencies(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{ SessionID: req.SessionID, PreviousSessionID: req.PreviousSessionID, - }) + }, deps) if err != nil { return nil, fmt.Errorf("%s: %w", req.CommandName, err) } diff --git a/internal/app/session_oriented_cli_test.go b/internal/app/session_oriented_cli_test.go index e3c6843..e83d9f7 100644 --- a/internal/app/session_oriented_cli_test.go +++ b/internal/app/session_oriented_cli_test.go @@ -106,7 +106,8 @@ func TestExecuteRemoteSessionFallbackUsesPositionalSessionID(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")}) + setSessionConfigSearchPaths(t, []string{filepath.Join(t.TempDir(), "session.yml")}) + deps := fakeCommandDependencies(fake, &storeInitCalls) remoteKey := seedRemoteSessionConfig(t, fake, "2026-06-07", `session_id: 2026-06-07 inputs: audio_s3: @@ -122,12 +123,12 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := executeWithPipelineExecutor([]string{ + code := executeWithDependencies([]string{ "run", "2026-06-07", "--config", pipelinePath, "--campaign-file", campaignPath, - }, &stdout, &stderr, executor) + }, &stdout, &stderr, deps, executor) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) }