diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 9b2e0d7..6f16c26 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -22,11 +22,11 @@ func TestExecuteValidCommands(t *testing.T) { args []string wantOut string }{ - {name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: completed 8 placeholder stages for session 2026-05-03; manifest updated at"}, - {name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "prepare\ntranscribe\nnormalize\nmerge\npolish\nanalyze\narchive\nnotify"}, + {name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=8 skipped=0; manifest="}, + {name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nnormalize: skip\nmerge: skip\npolish: skip\nanalyze: skip\narchive: skip\nnotify: skip"}, {name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"}, - {name: "resume", args: []string{"resume"}, wantOut: "narratio resume: not yet implemented"}, - {name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: selected stage polish (1 stage in plan)"}, + {name: "resume", args: []string{"resume", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, + {name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, } for _, tc := range cases { @@ -57,6 +57,7 @@ func TestExecuteMissingRequiredFlags(t *testing.T) { {name: "run missing flags", args: []string{"run"}, want: "run: --config and --session are required"}, {name: "plan missing flags", args: []string{"plan"}, want: "plan: --config and --session are required"}, {name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"}, + {name: "resume missing flags", args: []string{"resume"}, want: "resume: --config and --session are required"}, {name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected exactly one stage name"}, {name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: --config and --session are required"}, } diff --git a/internal/app/plan.go b/internal/app/plan.go index 471f2bc..6db5d0c 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -8,10 +8,11 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) // Plan validates configuration, prepares the local workdir, and prints stage order. -func Plan(_ context.Context, args []string, out io.Writer) error { +func Plan(ctx context.Context, args []string, out io.Writer) error { fs := flag.NewFlagSet("plan", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -46,17 +47,32 @@ func Plan(_ context.Context, args []string, out io.Writer) error { return fmt.Errorf("plan: prepare workdir: %w", err) } - _ = force // TODO: use --force in future skip/stale planning behavior. - stages := BuildFullPlan() + var m *manifest.Manifest + m, err = loadManifestIfPresent(ctx, cfg) + if err != nil { + return fmt.Errorf("plan: %w", err) + } + decisions := decideStageActions(stages, m, force) + + runCount := 0 + skipCount := 0 if _, err := fmt.Fprintf(out, "narratio plan: workdir prepared at %s\n", paths.Root); err != nil { return err } - for _, s := range stages { - if _, err := fmt.Fprintln(out, s.Name()); err != nil { + for _, d := range decisions { + if d.Action == stageActionRun { + runCount++ + } else { + skipCount++ + } + if _, err := fmt.Fprintf(out, "%s: %s\n", d.Stage.Name(), d.Action); err != nil { return err } } + if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil { + return err + } return nil } diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index a960fc3..69552a4 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -7,8 +7,10 @@ import ( "path/filepath" "strings" "testing" + "time" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) func TestPlanCreatesAndReusesWorkdir(t *testing.T) { @@ -26,10 +28,13 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) { t.Fatalf("first output = %q, want workdir prepared", got) } for _, name := range []string{"prepare", "transcribe", "normalize", "merge", "polish", "analyze", "archive", "notify"} { - if !strings.Contains(got, name) { + if !strings.Contains(got, name+": run") { t.Fatalf("first output = %q, missing stage %q", got, name) } } + if !strings.Contains(got, "totals: run=8 skip=0") { + t.Fatalf("first output = %q, want totals", got) + } sessionWorkdir := artifacts.SessionWorkDir(workspaceRoot, "2026-05-03") expectedDirs := []string{ @@ -55,6 +60,35 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) { } } +func TestPlanShowsRunAndSkipFromManifest(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + manifestPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "manifest.json") + + store := &manifest.LocalStore{} + m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) + m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil) + if err := store.Save(context.Background(), manifestPath, m); err != nil { + t.Fatalf("save manifest: %v", err) + } + + var out bytes.Buffer + if err := Plan(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out); err != nil { + t.Fatalf("Plan() error = %v", err) + } + got := out.String() + if !strings.Contains(got, "prepare: skip") || !strings.Contains(got, "transcribe: skip") { + t.Fatalf("output = %q, want prepare/transcribe skipped", got) + } + if !strings.Contains(got, "normalize: run") { + t.Fatalf("output = %q, want normalize run", got) + } + if !strings.Contains(got, "totals: run=6 skip=2") { + t.Fatalf("output = %q, want totals run=6 skip=2", got) + } +} + func assertDir(t *testing.T, path string) { t.Helper() info, err := os.Stat(path) diff --git a/internal/app/resume.go b/internal/app/resume.go index 27ae85a..e9997a5 100644 --- a/internal/app/resume.go +++ b/internal/app/resume.go @@ -2,10 +2,90 @@ package app import ( "context" + "flag" + "fmt" "io" + + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) -// Resume is a placeholder for future resume-from-manifest behavior. -func Resume(_ context.Context, _ []string, out io.Writer) error { - return placeholder(out, "resume") +// Resume continues execution from the first non-succeeded stage in the manifest. +func Resume(ctx context.Context, args []string, out io.Writer) error { + fs := flag.NewFlagSet("resume", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var pipelinePath string + var sessionPath string + var force bool + fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml") + fs.StringVar(&sessionPath, "session", "", "path to session.yml") + fs.BoolVar(&force, "force", false, "force stage execution") + + if err := fs.Parse(args); err != nil { + return fmt.Errorf("resume: invalid flags: %w", err) + } + if fs.NArg() != 0 { + return fmt.Errorf("resume: unexpected positional arguments") + } + if pipelinePath == "" || sessionPath == "" { + return fmt.Errorf("resume: --config and --session are required") + } + + cfg, err := config.Load(pipelinePath, sessionPath) + if err != nil { + return fmt.Errorf("resume: %w", err) + } + if err := config.Validate(cfg); err != nil { + return fmt.Errorf("resume: %w", err) + } + + full := BuildFullPlan() + selected := full + if !force { + m, err := loadManifestIfPresent(ctx, cfg) + if err != nil { + return fmt.Errorf("resume: %w", err) + } + if m != nil { + start := firstNonSucceededIndex(full, m) + if start >= len(full) { + _, err := fmt.Fprintf(out, "narratio resume: session %s has no remaining stages\n", cfg.Session.SessionID) + return err + } + selected = full[start:] + } + } + + summary, err := executeStages(ctx, cfg, selected, RunOptions{Force: force}) + if err != nil { + return fmt.Errorf("resume: %w", err) + } + + _, err = fmt.Fprintf( + out, + "narratio resume: session %s; executed=%d skipped=%d; manifest=%s\n", + summary.SessionID, + len(summary.Executed), + len(summary.Skipped), + summary.ManifestPath, + ) + return err +} + +func loadManifestIfPresent(ctx context.Context, cfg *config.Config) (*manifest.Manifest, error) { + path := manifestPathFor(cfg) + exists, err := fileExists(path) + if err != nil { + return nil, fmt.Errorf("check manifest %q: %w", path, err) + } + if !exists { + return nil, nil + } + store := &manifest.LocalStore{} + m, err := store.Load(ctx, path) + if err != nil { + return nil, fmt.Errorf("load manifest %q: %w", path, err) + } + return m, nil } diff --git a/internal/app/resume_run_stage_test.go b/internal/app/resume_run_stage_test.go new file mode 100644 index 0000000..922614b --- /dev/null +++ b/internal/app/resume_run_stage_test.go @@ -0,0 +1,149 @@ +package app + +import ( + "bytes" + "context" + "path/filepath" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestResumeStartsAfterCompletedStages(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + manifestPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "manifest.json") + + store := &manifest.LocalStore{} + m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) + m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil) + if err := store.Save(context.Background(), manifestPath, m); err != nil { + t.Fatalf("save manifest: %v", err) + } + + var out bytes.Buffer + err := Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out) + if err != nil { + t.Fatalf("Resume() error = %v", err) + } + if !strings.Contains(out.String(), "executed=6 skipped=0") { + t.Fatalf("output = %q, want executed=6 skipped=0", out.String()) + } + + loaded, err := store.Load(context.Background(), manifestPath) + if err != nil { + t.Fatalf("load manifest: %v", err) + } + if loaded.Stages["normalize"] == nil || loaded.Stages["normalize"].Status != manifest.StatusSucceeded { + t.Fatalf("normalize stage = %#v, want succeeded", loaded.Stages["normalize"]) + } +} + +func TestResumeNoRemainingStages(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + manifestPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "manifest.json") + + store := &manifest.LocalStore{} + m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + for _, name := range []string{"prepare", "transcribe", "normalize", "merge", "polish", "analyze", "archive", "notify"} { + m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) + } + if err := store.Save(context.Background(), manifestPath, m); err != nil { + t.Fatalf("save manifest: %v", err) + } + + var out bytes.Buffer + err := Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out) + if err != nil { + t.Fatalf("Resume() error = %v", err) + } + if !strings.Contains(out.String(), "has no remaining stages") { + t.Fatalf("output = %q, want no remaining stages", out.String()) + } +} + +func TestResumeForceRerunsSucceeded(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + manifestPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "manifest.json") + + store := &manifest.LocalStore{} + m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + for _, name := range []string{"prepare", "transcribe", "normalize", "merge", "polish", "analyze", "archive", "notify"} { + m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) + } + if err := store.Save(context.Background(), manifestPath, m); err != nil { + t.Fatalf("save manifest: %v", err) + } + + var out bytes.Buffer + err := Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--force"}, &out) + if err != nil { + t.Fatalf("Resume() error = %v", err) + } + if !strings.Contains(out.String(), "executed=8 skipped=0") { + t.Fatalf("output = %q, want forced full rerun", out.String()) + } +} + +func TestRunStageExecutesOnlySelectedStage(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + manifestPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "manifest.json") + + var out bytes.Buffer + err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "polish"}, &out) + if err != nil { + t.Fatalf("RunStage() error = %v", err) + } + if !strings.Contains(out.String(), "stage=polish executed=1 skipped=0") { + t.Fatalf("output = %q, want stage executed", out.String()) + } + + store := &manifest.LocalStore{} + m, err := store.Load(context.Background(), manifestPath) + if err != nil { + t.Fatalf("load manifest: %v", err) + } + if m.Stages["polish"] == nil || m.Stages["polish"].Status != manifest.StatusSucceeded { + t.Fatalf("polish stage = %#v, want succeeded", m.Stages["polish"]) + } + if m.Stages["prepare"] != nil { + t.Fatalf("prepare should not run in run-stage, got %#v", m.Stages["prepare"]) + } +} + +func TestRunStageSkipAndForce(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + manifestPath := filepath.Join(workspaceRoot, "work", "2026-05-03", "manifest.json") + + store := &manifest.LocalStore{} + m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + m.MarkStageSucceeded("polish", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) + if err := store.Save(context.Background(), manifestPath, m); err != nil { + t.Fatalf("save manifest: %v", err) + } + + var out bytes.Buffer + err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "polish"}, &out) + if err != nil { + t.Fatalf("RunStage() error = %v", err) + } + if !strings.Contains(out.String(), "stage=polish executed=0 skipped=1 force=false") { + t.Fatalf("output = %q, want skip", out.String()) + } + + out.Reset() + err = RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--force", "polish"}, &out) + if err != nil { + t.Fatalf("RunStage(force) error = %v", err) + } + if !strings.Contains(out.String(), "stage=polish executed=1 skipped=0 force=true") { + t.Fatalf("output = %q, want force rerun", out.String()) + } +} diff --git a/internal/app/run.go b/internal/app/run.go index 50732f1..6558305 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -45,6 +45,13 @@ func Run(ctx context.Context, args []string, out io.Writer) error { return fmt.Errorf("run: %w", err) } - _, err = fmt.Fprintf(out, "narratio run: completed %d placeholder stages for session %s; manifest updated at %s\n", len(summary.StageNames), summary.SessionID, summary.ManifestPath) + _, err = fmt.Fprintf( + out, + "narratio run: session %s; executed=%d skipped=%d; manifest=%s\n", + summary.SessionID, + len(summary.Executed), + len(summary.Skipped), + summary.ManifestPath, + ) return err } diff --git a/internal/app/run_control.go b/internal/app/run_control.go new file mode 100644 index 0000000..e54288c --- /dev/null +++ b/internal/app/run_control.go @@ -0,0 +1,51 @@ +package app + +import ( + "gitea.maximumdirect.net/eric/narratio/internal/manifest" + "gitea.maximumdirect.net/eric/narratio/internal/stage" +) + +type stageAction string + +const ( + stageActionRun stageAction = "run" + stageActionSkip stageAction = "skip" +) + +type stageDecision struct { + Stage stage.Stage + Action stageAction +} + +func decideStageActions(stages []stage.Stage, m *manifest.Manifest, force bool) []stageDecision { + out := make([]stageDecision, 0, len(stages)) + for _, s := range stages { + action := stageActionRun + // TODO: incorporate stale detection once checksum/input change tracking is implemented. + if !force && stageSucceeded(m, s.Name()) { + action = stageActionSkip + } + out = append(out, stageDecision{ + Stage: s, + Action: action, + }) + } + return out +} + +func stageSucceeded(m *manifest.Manifest, name string) bool { + if m == nil || m.Stages == nil { + return false + } + sr := m.Stages[name] + return sr != nil && sr.Status == manifest.StatusSucceeded +} + +func firstNonSucceededIndex(stages []stage.Stage, m *manifest.Manifest) int { + for i, s := range stages { + if !stageSucceeded(m, s.Name()) { + return i + } + } + return len(stages) +} diff --git a/internal/app/run_control_test.go b/internal/app/run_control_test.go new file mode 100644 index 0000000..79359ed --- /dev/null +++ b/internal/app/run_control_test.go @@ -0,0 +1,42 @@ +package app + +import ( + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestFirstNonSucceededIndex(t *testing.T) { + stages := BuildFullPlan() + m := manifest.New("2026-05-03", time.Now().UTC()) + m.MarkStageSucceeded("prepare", time.Now().UTC(), nil) + m.MarkStageSucceeded("transcribe", time.Now().UTC(), nil) + + got := firstNonSucceededIndex(stages, m) + if got != 2 { + t.Fatalf("firstNonSucceededIndex() = %d, want 2", got) + } +} + +func TestDecideStageActions(t *testing.T) { + stages := BuildFullPlan()[:2] + m := manifest.New("2026-05-03", time.Now().UTC()) + m.MarkStageSucceeded("prepare", time.Now().UTC(), nil) + + got := decideStageActions(stages, m, false) + if len(got) != 2 { + t.Fatalf("len(decisions) = %d, want 2", len(got)) + } + if got[0].Action != stageActionSkip { + t.Fatalf("prepare action = %q, want %q", got[0].Action, stageActionSkip) + } + if got[1].Action != stageActionRun { + t.Fatalf("transcribe action = %q, want %q", got[1].Action, stageActionRun) + } + + forced := decideStageActions(stages, m, true) + if forced[0].Action != stageActionRun { + t.Fatalf("forced prepare action = %q, want %q", forced[0].Action, stageActionRun) + } +} diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index 6f90b04..38b2f6a 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -5,10 +5,12 @@ import ( "flag" "fmt" "io" + + "gitea.maximumdirect.net/eric/narratio/internal/config" ) -// RunStage validates stage selection and reserved execution flags. -func RunStage(_ context.Context, args []string, out io.Writer) error { +// RunStage executes exactly one selected stage. +func RunStage(ctx context.Context, args []string, out io.Writer) error { fs := flag.NewFlagSet("run-stage", flag.ContinueOnError) fs.SetOutput(io.Discard) @@ -35,8 +37,27 @@ func RunStage(_ context.Context, args []string, out io.Writer) error { return fmt.Errorf("run-stage: %w", err) } - _ = force // TODO: use in future behavior. + cfg, err := config.Load(pipelinePath, sessionPath) + if err != nil { + return fmt.Errorf("run-stage: %w", err) + } + if err := config.Validate(cfg); err != nil { + return fmt.Errorf("run-stage: %w", err) + } - _, err = fmt.Fprintf(out, "narratio run-stage: selected stage %s (%d stage in plan)\n", stages[0].Name(), len(stages)) + summary, err := executeStages(ctx, cfg, stages, RunOptions{Force: force}) + if err != nil { + return fmt.Errorf("run-stage: %w", err) + } + + _, err = fmt.Fprintf( + out, + "narratio run-stage: stage=%s executed=%d skipped=%d force=%t; manifest=%s\n", + stages[0].Name(), + len(summary.Executed), + len(summary.Skipped), + force, + summary.ManifestPath, + ) return err } diff --git a/internal/app/runner.go b/internal/app/runner.go index 11526af..17d882e 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -29,6 +29,8 @@ type RunSummary struct { SessionID string ManifestPath string StageNames []string + Executed []string + Skipped []string } func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) { @@ -89,12 +91,21 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage stageEnv := toStageEnv(env) - _ = opts.Force // TODO: use --force behavior in future skip/stale logic. + decisions := decideStageActions(stages, m, opts.Force) - runNames := make([]string, 0, len(stages)) - for _, s := range stages { + runNames := make([]string, 0, len(decisions)) + executed := make([]string, 0, len(decisions)) + skipped := make([]string, 0, len(decisions)) + for _, d := range decisions { + s := d.Stage runNames = append(runNames, s.Name()) + if d.Action == stageActionSkip { + skipped = append(skipped, s.Name()) + continue + } + executed = append(executed, s.Name()) + now := nowUTC() m.MarkStageRunning(s.Name(), now) if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil { @@ -123,6 +134,8 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage SessionID: cfg.Session.SessionID, ManifestPath: manifestPath, StageNames: runNames, + Executed: executed, + Skipped: skipped, }, nil } diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index dca74f7..fffefe7 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -32,6 +32,18 @@ func (s failingStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) return nil, s.err } +type countingStage struct { + name string + runs *int +} + +func (s countingStage) Name() string { return s.name } +func (s countingStage) Declares() stage.IODecl { return stage.IODecl{} } +func (s countingStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) { + *s.runs = *s.runs + 1 + return &stage.StageResult{Metadata: map[string]any{"counting": true}}, nil +} + func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { cfg := testConfig(t) @@ -39,8 +51,8 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { if err != nil { t.Fatalf("executeStages() error = %v", err) } - if len(summary.StageNames) != 8 { - t.Fatalf("stage count = %d, want 8", len(summary.StageNames)) + if len(summary.StageNames) != 8 || len(summary.Executed) != 8 || len(summary.Skipped) != 0 { + t.Fatalf("summary = %#v, want all 8 executed", summary) } store := &manifest.LocalStore{} @@ -76,6 +88,75 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) { } } +func TestExecuteStagesSkipSucceededWhenNotForced(t *testing.T) { + cfg := testConfig(t) + manifestPath := manifestPathFor(cfg) + store := &manifest.LocalStore{} + + existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC)) + existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{Kind: "transcript_raw", LocalPath: "existing.json"}}) + existing.Stages["transcribe"].Metadata = map[string]any{"kept": true} + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := store.Save(context.Background(), manifestPath, existing); err != nil { + t.Fatalf("Save manifest error = %v", err) + } + + runs := 0 + stageToRun := countingStage{name: "transcribe", runs: &runs} + summary, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{}) + if err != nil { + t.Fatalf("executeStages() error = %v", err) + } + if runs != 0 { + t.Fatalf("runs = %d, want 0 due to skip", runs) + } + if len(summary.Executed) != 0 || len(summary.Skipped) != 1 || summary.Skipped[0] != "transcribe" { + t.Fatalf("summary = %#v, want skipped transcribe", summary) + } + + loaded, err := store.Load(context.Background(), manifestPath) + if err != nil { + t.Fatalf("Load manifest error = %v", err) + } + sr := loaded.Stages["transcribe"] + if sr == nil || sr.Status != manifest.StatusSucceeded { + t.Fatalf("transcribe status = %#v, want succeeded", sr) + } + if sr.Metadata == nil || sr.Metadata["kept"] != true { + t.Fatalf("transcribe metadata = %#v, want preserved", sr.Metadata) + } +} + +func TestExecuteStagesForceRerunsSucceeded(t *testing.T) { + cfg := testConfig(t) + manifestPath := manifestPathFor(cfg) + store := &manifest.LocalStore{} + + existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC)) + existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil) + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := store.Save(context.Background(), manifestPath, existing); err != nil { + t.Fatalf("Save manifest error = %v", err) + } + + runs := 0 + stageToRun := countingStage{name: "transcribe", runs: &runs} + summary, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: true}) + if err != nil { + t.Fatalf("executeStages() error = %v", err) + } + if runs != 1 { + t.Fatalf("runs = %d, want 1 with force", runs) + } + if len(summary.Executed) != 1 || summary.Executed[0] != "transcribe" || len(summary.Skipped) != 0 { + t.Fatalf("summary = %#v, want executed transcribe", summary) + } +} + func TestExecuteStagesFailureUpdatesManifest(t *testing.T) { cfg := testConfig(t)