Implement resume, skip, and stage selection
This commit is contained in:
@@ -22,11 +22,11 @@ func TestExecuteValidCommands(t *testing.T) {
|
|||||||
args []string
|
args []string
|
||||||
wantOut 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: "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\ntranscribe\nnormalize\nmerge\npolish\nanalyze\narchive\nnotify"},
|
{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: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"},
|
||||||
{name: "resume", args: []string{"resume"}, wantOut: "narratio resume: not yet implemented"},
|
{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: selected stage polish (1 stage in plan)"},
|
{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 {
|
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: "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: "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: "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 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"},
|
{name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: --config and --session are required"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"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.
|
// 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 := flag.NewFlagSet("plan", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
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)
|
return fmt.Errorf("plan: prepare workdir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = force // TODO: use --force in future skip/stale planning behavior.
|
|
||||||
|
|
||||||
stages := BuildFullPlan()
|
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 {
|
if _, err := fmt.Fprintf(out, "narratio plan: workdir prepared at %s\n", paths.Root); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, s := range stages {
|
for _, d := range decisions {
|
||||||
if _, err := fmt.Fprintln(out, s.Name()); err != nil {
|
if d.Action == stageActionRun {
|
||||||
|
runCount++
|
||||||
|
} else {
|
||||||
|
skipCount++
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, "%s: %s\n", d.Stage.Name(), d.Action); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
||||||
@@ -26,10 +28,13 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
|||||||
t.Fatalf("first output = %q, want workdir prepared", got)
|
t.Fatalf("first output = %q, want workdir prepared", got)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"prepare", "transcribe", "normalize", "merge", "polish", "analyze", "archive", "notify"} {
|
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)
|
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")
|
sessionWorkdir := artifacts.SessionWorkDir(workspaceRoot, "2026-05-03")
|
||||||
expectedDirs := []string{
|
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) {
|
func assertDir(t *testing.T, path string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
|
|||||||
@@ -2,10 +2,90 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
"io"
|
"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.
|
// Resume continues execution from the first non-succeeded stage in the manifest.
|
||||||
func Resume(_ context.Context, _ []string, out io.Writer) error {
|
func Resume(ctx context.Context, args []string, out io.Writer) error {
|
||||||
return placeholder(out, "resume")
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
149
internal/app/resume_run_stage_test.go
Normal file
149
internal/app/resume_run_stage_test.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,13 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
return fmt.Errorf("run: %w", err)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
51
internal/app/run_control.go
Normal file
51
internal/app/run_control.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
42
internal/app/run_control_test.go
Normal file
42
internal/app/run_control_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunStage validates stage selection and reserved execution flags.
|
// RunStage executes exactly one selected stage.
|
||||||
func RunStage(_ context.Context, args []string, out io.Writer) error {
|
func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("run-stage", flag.ContinueOnError)
|
fs := flag.NewFlagSet("run-stage", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
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)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type RunSummary struct {
|
|||||||
SessionID string
|
SessionID string
|
||||||
ManifestPath string
|
ManifestPath string
|
||||||
StageNames []string
|
StageNames []string
|
||||||
|
Executed []string
|
||||||
|
Skipped []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
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)
|
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))
|
runNames := make([]string, 0, len(decisions))
|
||||||
for _, s := range stages {
|
executed := make([]string, 0, len(decisions))
|
||||||
|
skipped := make([]string, 0, len(decisions))
|
||||||
|
for _, d := range decisions {
|
||||||
|
s := d.Stage
|
||||||
runNames = append(runNames, s.Name())
|
runNames = append(runNames, s.Name())
|
||||||
|
|
||||||
|
if d.Action == stageActionSkip {
|
||||||
|
skipped = append(skipped, s.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
executed = append(executed, s.Name())
|
||||||
|
|
||||||
now := nowUTC()
|
now := nowUTC()
|
||||||
m.MarkStageRunning(s.Name(), now)
|
m.MarkStageRunning(s.Name(), now)
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
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,
|
SessionID: cfg.Session.SessionID,
|
||||||
ManifestPath: manifestPath,
|
ManifestPath: manifestPath,
|
||||||
StageNames: runNames,
|
StageNames: runNames,
|
||||||
|
Executed: executed,
|
||||||
|
Skipped: skipped,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ func (s failingStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest)
|
|||||||
return nil, s.err
|
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) {
|
func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
|
|
||||||
@@ -39,8 +51,8 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(summary.StageNames) != 8 {
|
if len(summary.StageNames) != 8 || len(summary.Executed) != 8 || len(summary.Skipped) != 0 {
|
||||||
t.Fatalf("stage count = %d, want 8", len(summary.StageNames))
|
t.Fatalf("summary = %#v, want all 8 executed", summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
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) {
|
func TestExecuteStagesFailureUpdatesManifest(t *testing.T) {
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user