Implement resume, skip, and stage selection
This commit is contained in:
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
_, 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
|
||||
}
|
||||
|
||||
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"
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user