diff --git a/docs/cli.md b/docs/cli.md index 29222ae..e6b4378 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -12,7 +12,7 @@ This runs the canonical full pipeline for session `2026-04-04`. Top-level commands: -- `run `: run full stage order. +- `run `: run all or one contiguous range of the canonical stage order. - `run-stage `: run one stage. - `analyze `: force-run analyze. - `publish `: force-run publish. @@ -73,19 +73,28 @@ Commands with additional positionals keep their command-specific order: ### `run` ```bash -narratio run [--force] [--artifacts ] [...common config flags] +narratio run [--from ] [--through ] [--force] [--artifacts ] [...common config flags] ``` Behavior: -- evaluates full stage order; -- runs `extract` between `trim` and `render`; an omitted or disabled Notarius +- evaluates one inclusive contiguous range of the canonical stage order; +- defaults an omitted `--from` to `prepare` and an omitted `--through` to + `notify`, so omitting both retains full-pipeline behavior; +- rejects unknown endpoints and a `--from` endpoint after `--through`; +- runs `render` before `extract`; an omitted or disabled Notarius configuration records an explicit `notarius_disabled` self-skip; - skips already-succeeded stages unless `--force` is set or a stage-specific resume check finds its durable result obsolete; +- applies `--force` only to stages in the selected range; +- rejects repeated `--from`, `--through`, or `--force` options, including + `--name=value` spellings; - continues interrupted or partially completed sessions by running non-succeeded stages; - writes session and run manifests. +When `--artifacts` is present, the selected range must contain `analyze` or +`publish`. Either consumer is sufficient, including a one-stage range. + ### `run-stage` ```bash @@ -100,8 +109,8 @@ Valid stage names: - `polish` - `normalize` - `trim` -- `extract` - `render` +- `extract` - `analyze` - `publish` - `notify` @@ -153,10 +162,12 @@ post-publish cleanup behavior. ### `session plan` ```bash -narratio session plan [--force] [...common config flags] +narratio session plan [--from ] [--through ] [--force] [--artifacts ] [...common config flags] ``` -Validates config, prepares local workdir layout, and prints run/skip decisions for each stage. +Uses the same inclusive bounds, endpoint validation, force scope, and artifact +selection contract as `run`. It validates config, prepares local workdir layout, +and prints run/skip decisions for selected stages only. ### `session validate` @@ -252,7 +263,7 @@ and precedence. ## `--artifacts` Selection Rules -- accepted on `run`, `run-stage`, `analyze`, and `publish`; +- accepted on `run`, `session plan`, `run-stage`, `analyze`, and `publish`; - names must exist in `pipeline.scriptorium.artifacts`; - empty entries are invalid; - repeated names are deduplicated. diff --git a/docs/operations.md b/docs/operations.md index 897c617..7c76c8d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -80,8 +80,8 @@ Canonical stage order: 4. `polish` 5. `normalize` 6. `trim` -7. `extract` -8. `render` +7. `render` +8. `extract` 9. `analyze` 10. `publish` 11. `notify` @@ -90,12 +90,12 @@ Execution rules: - succeeded stages are skipped unless `--force` is set; - `run` continues interrupted or partially completed sessions by running non-succeeded stages; -- forcing an upstream stage marks succeeded downstream stages as `stale` before - the replacement runs; and +- forcing a stage marks succeeded transitive dependents as `stale` before the + replacement runs; render and extract are independent siblings; and - an executed failure, changed self-skip, or success that replaces a different - effective upstream outcome also marks succeeded downstream stages stale. A + effective outcome uses the same fixed dependency relation. A repeated self-skip with the same reason and no outputs is stable and does not - perpetually rerun downstream work. + perpetually rerun dependent work. An explicit self-skip is a durable `skipped` stage outcome that later runs reconsider. It differs from successful no-output execution: disabled `render` @@ -111,9 +111,24 @@ Single-stage execution: narratio run-stage normalize 2026-04-04 --force ``` +Contiguous bounded execution uses inclusive canonical endpoints: + +```bash +narratio session plan 2026-04-04 --from extract --through analyze --force +narratio run 2026-04-04 --from extract --through analyze --force +``` + +Omitting `--from` selects from `prepare`; omitting `--through` selects through +`notify`. Force applies only within the selected range. Repeating `--from`, +`--through`, or `--force` is rejected instead of resolving by argument order. +The plan command uses the same selection contract and prints only the selected +range. + ## Artifact Selection -`--artifacts` can be used on `run`, `run-stage`, `analyze`, and `publish`. +`--artifacts` can be used on `run`, `session plan`, `run-stage`, `analyze`, and +`publish`. For a bounded run or plan, the selected range must contain `analyze` +or `publish`. Selection behavior: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 84e4387..3f0cf73 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -180,6 +180,8 @@ stage range, shared by execution and plan preview. ## Stage 3 — Bounded `run` And `session plan` Command Contracts +**Status: Completed** + ### Goal Expose the shared range through both commands with one parsing and structural diff --git a/internal/app/bounded_run.go b/internal/app/bounded_run.go new file mode 100644 index 0000000..65d98b7 --- /dev/null +++ b/internal/app/bounded_run.go @@ -0,0 +1,110 @@ +package app + +import ( + "flag" + "fmt" + "io" + "strconv" +) + +type boundedRunRequest struct { + Config commonConfigFlags + Plan BoundedPlan + Force bool + SelectedArtifacts []string +} + +type singletonStringFlag struct { + name string + value string + set bool +} + +func (f *singletonStringFlag) String() string { return f.value } + +func (f *singletonStringFlag) Set(value string) error { + if f.set { + return fmt.Errorf("--%s may be specified only once", f.name) + } + f.value = value + f.set = true + return nil +} + +type singletonBoolFlag struct { + name string + value bool + set bool +} + +func (f *singletonBoolFlag) String() string { return strconv.FormatBool(f.value) } +func (f *singletonBoolFlag) IsBoolFlag() bool { return true } + +func (f *singletonBoolFlag) Set(raw string) error { + if f.set { + return fmt.Errorf("--%s may be specified only once", f.name) + } + value, err := strconv.ParseBool(raw) + if err != nil { + return fmt.Errorf("--%s requires a boolean value: %w", f.name, err) + } + f.value = value + f.set = true + return nil +} + +func parseBoundedRunRequest(command string, args []string, help io.Writer) (boundedRunRequest, error) { + fs := flag.NewFlagSet(command, flag.ContinueOnError) + fs.SetOutput(help) + + var configFlags commonConfigFlags + var from singletonStringFlag + var through singletonStringFlag + var force singletonBoolFlag + var selectedArtifacts artifactSelectionFlag + from.name = "from" + through.name = "through" + force.name = "force" + + addCommonConfigFlags(fs, &configFlags) + fs.Var(&from, "from", "first canonical stage to select (inclusive)") + fs.Var(&through, "through", "last canonical stage to select (inclusive)") + fs.Var(&force, "force", "rerun selected stages even when already succeeded") + fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)") + fs.Usage = func() { + invocation := "narratio run" + if command == "plan" { + invocation = "narratio session plan" + } + _, _ = fmt.Fprintf(help, "Usage: %s [--from ] [--through ] [--force] [--artifacts ] [common config flags]\n\n", invocation) + _, _ = fmt.Fprintln(help, "Bounds are inclusive; omitted --from or --through selects the beginning or end of the canonical pipeline.") + _, _ = fmt.Fprintln(help) + _, _ = fmt.Fprintln(help, "Flags:") + fs.PrintDefaults() + } + + if err := parseSessionAwareFlags(command, fs, args, &configFlags.sessionID); err != nil { + return boundedRunRequest{}, err + } + if configFlags.sessionID == "" { + return boundedRunRequest{}, fmt.Errorf("%s: session_id is required", command) + } + plan, err := BuildBoundedPlan(from.value, through.value) + if err != nil { + return boundedRunRequest{}, fmt.Errorf("%s: %w", command, err) + } + normalizedArtifacts, err := selectedArtifacts.Normalize() + if err != nil { + return boundedRunRequest{}, fmt.Errorf("%s: invalid --artifacts: %w", command, err) + } + if len(normalizedArtifacts) > 0 && !plan.Contains("analyze") && !plan.Contains("publish") { + return boundedRunRequest{}, fmt.Errorf("%s: --artifacts requires a selected range containing analyze or publish", command) + } + + return boundedRunRequest{ + Config: configFlags, + Plan: plan, + Force: force.value, + SelectedArtifacts: normalizedArtifacts, + }, nil +} diff --git a/internal/app/bounded_run_test.go b/internal/app/bounded_run_test.go new file mode 100644 index 0000000..a51ea41 --- /dev/null +++ b/internal/app/bounded_run_test.go @@ -0,0 +1,185 @@ +package app + +import ( + "bytes" + "context" + "io" + "path/filepath" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/stage" +) + +func TestBoundedRunParsingIsSharedByRunAndPlan(t *testing.T) { + args := []string{ + "2026-05-03", + "--from", "extract", + "--through=publish", + "--force", + "--artifacts", "session_recap,player_handout", + "--artifacts=session_recap", + "--config", "pipeline.yml", + } + runRequest, err := parseBoundedRunRequest("run", args, io.Discard) + if err != nil { + t.Fatalf("parse run request: %v", err) + } + planRequest, err := parseBoundedRunRequest("plan", args, io.Discard) + if err != nil { + t.Fatalf("parse plan request: %v", err) + } + if !reflect.DeepEqual(runRequest.Plan.Names(), planRequest.Plan.Names()) || + runRequest.Plan.From() != planRequest.Plan.From() || + runRequest.Plan.Through() != planRequest.Plan.Through() || + runRequest.Force != planRequest.Force || + !reflect.DeepEqual(runRequest.SelectedArtifacts, planRequest.SelectedArtifacts) || + runRequest.Config != planRequest.Config { + t.Fatalf("run request = %#v, plan request = %#v", runRequest, planRequest) + } + wantArtifacts := []string{"player_handout", "session_recap"} + if !reflect.DeepEqual(runRequest.SelectedArtifacts, wantArtifacts) { + t.Fatalf("artifacts = %#v, want %#v", runRequest.SelectedArtifacts, wantArtifacts) + } +} + +func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "from separate", args: []string{"session", "--from", "render", "--from", "extract"}, want: "--from may be specified only once"}, + {name: "from equals", args: []string{"session", "--from=render", "--from=extract"}, want: "--from may be specified only once"}, + {name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"}, + {name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"}, + {name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := parseBoundedRunRequest("run", test.args, io.Discard) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + +func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) { + args := []string{"session", "--from", "publish", "--through", "render"} + runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard) + planRequest, planErr := parseBoundedRunRequest("plan", args, io.Discard) + if runErr == nil || planErr == nil { + t.Fatalf("run request=%#v error=%v; plan request=%#v error=%v", runRequest, runErr, planRequest, planErr) + } + runDetail := strings.TrimPrefix(runErr.Error(), "run: ") + planDetail := strings.TrimPrefix(planErr.Error(), "plan: ") + if runDetail != planDetail || !strings.Contains(runDetail, `from stage "publish" occurs after through stage "render"`) { + t.Fatalf("run error = %q, plan error = %q", runErr, planErr) + } +} + +func TestBoundedRunParsingGatesArtifactSelectionByRange(t *testing.T) { + for _, test := range []struct { + name string + through string + wantErr bool + }{ + {name: "render only", through: "render", wantErr: true}, + {name: "analyze only", through: "analyze"}, + {name: "publish only", through: "publish"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := parseBoundedRunRequest("run", []string{ + "session", "--from", test.through, "--through", test.through, "--artifacts", "session_recap", + }, io.Discard) + if test.wantErr && (err == nil || !strings.Contains(err.Error(), "range containing analyze or publish")) { + t.Fatalf("error = %v, want artifact/range error", err) + } + if !test.wantErr && err != nil { + t.Fatalf("error = %v", err) + } + }) + } +} + +func TestRunPassesBoundedPlanToRunner(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + + var capturedStages []string + var capturedOptions RunOptions + original := executeStagesFn + t.Cleanup(func() { executeStagesFn = original }) + executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, options RunOptions) (*RunSummary, error) { + capturedStages = stageNames(stages) + capturedOptions = options + return &RunSummary{SessionID: "2026-05-03", ManifestPath: filepath.Join(workspaceRoot, "manifest.json")}, nil + } + + var out bytes.Buffer + err := Run(context.Background(), []string{ + "2026-05-03", + "--from", "render", + "--through", "extract", + "--force", + "--config", pipelinePath, + "--campaign-file", campaignPath, + "--session", sessionPath, + }, &out) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + want := []string{"render", "extract"} + if !reflect.DeepEqual(capturedStages, want) || !reflect.DeepEqual(capturedOptions.Plan.Names(), want) || !capturedOptions.Force { + t.Fatalf("stages = %#v options = %#v", capturedStages, capturedOptions) + } +} + +func TestPlanPrintsOnlyBoundedRange(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + var out bytes.Buffer + err := Plan(context.Background(), []string{ + "2026-05-03", + "--from", "render", + "--through", "extract", + "--config", pipelinePath, + "--campaign-file", campaignPath, + "--session", sessionPath, + }, &out) + if err != nil { + t.Fatalf("Plan() error = %v", err) + } + got := out.String() + if !strings.Contains(got, "render: run\nextract: run\ntotals: run=2 skip=0") { + t.Fatalf("output = %q, want bounded decisions", got) + } + if strings.Contains(got, "trim: ") || strings.Contains(got, "analyze: ") { + t.Fatalf("output = %q, contains excluded stages", got) + } +} + +func TestBoundedRunCommandHelp(t *testing.T) { + for _, test := range []struct { + name string + args []string + want string + }{ + {name: "run", args: []string{"run", "--help"}, want: "Usage: narratio run [--from ] [--through ]"}, + {name: "plan", args: []string{"session", "plan", "--help"}, want: "Usage: narratio session plan [--from ] [--through ]"}, + } { + t.Run(test.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := Execute(test.args, &stdout, &stderr); code != 0 { + t.Fatalf("exit code = %d, stderr = %q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), test.want) || stderr.Len() != 0 { + t.Fatalf("stdout = %q stderr = %q, want %q", stdout.String(), stderr.String(), test.want) + } + }) + } +} diff --git a/internal/app/plan.go b/internal/app/plan.go index 536a4e5..9430de3 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "flag" "fmt" "io" @@ -16,20 +17,14 @@ import ( // Plan validates configuration, prepares the local workdir, and prints stage order. func Plan(ctx context.Context, args []string, out io.Writer) error { - fs := flag.NewFlagSet("plan", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - var flags commonConfigFlags - var force bool - addCommonConfigFlags(fs, &flags) - fs.BoolVar(&force, "force", false, "show all stages as scheduled to rerun") - - if err := parseSessionAwareFlags("plan", fs, args, &flags.sessionID); err != nil { + request, err := parseBoundedRunRequest("plan", args, out) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } return err } - if flags.sessionID == "" { - return fmt.Errorf("plan: session_id is required") - } + flags := request.Config loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return fmt.Errorf("plan: %w", err) @@ -39,6 +34,9 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { if err := config.Validate(cfg); err != nil { return fmt.Errorf("plan: %w", err) } + if _, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts); err != nil { + return fmt.Errorf("plan: %w", err) + } if _, err := loadSecretsFromConfig(cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)); err != nil { return fmt.Errorf("plan: %w", err) } @@ -49,13 +47,13 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { return fmt.Errorf("plan: prepare workdir: %w", err) } - stages := BuildFullPlan() + stages := request.Plan.Stages() var m *manifest.Manifest m, err = loadManifestIfPresent(ctx, cfg) if err != nil { return fmt.Errorf("plan: %w", err) } - decisions := decideStageActions(stages, m, force) + decisions := decideStageActions(stages, m, request.Force) runCount := 0 skipCount := 0 diff --git a/internal/app/run.go b/internal/app/run.go index 7250cda..d6c97a5 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "flag" "fmt" "io" @@ -11,22 +12,14 @@ import ( // Run executes the pipeline plan and persists manifest state. func Run(ctx context.Context, args []string, out io.Writer) error { - fs := flag.NewFlagSet("run", flag.ContinueOnError) - fs.SetOutput(io.Discard) - - var flags commonConfigFlags - var force bool - var selectedArtifacts artifactSelectionFlag - addCommonConfigFlags(fs, &flags) - fs.BoolVar(&force, "force", false, "rerun stages even when already succeeded") - fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)") - - if err := parseSessionAwareFlags("run", fs, args, &flags.sessionID); err != nil { + request, err := parseBoundedRunRequest("run", args, out) + if err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } return err } - if flags.sessionID == "" { - return fmt.Errorf("run: session_id is required") - } + flags := request.Config loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return fmt.Errorf("run: %w", err) @@ -36,19 +29,16 @@ func Run(ctx context.Context, args []string, out io.Writer) error { if err := config.Validate(cfg); err != nil { return fmt.Errorf("run: %w", err) } - normalizedArtifacts, err := selectedArtifacts.Normalize() - if err != nil { - return fmt.Errorf("run: invalid --artifacts: %w", err) - } - effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, normalizedArtifacts) + effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts) if err != nil { return fmt.Errorf("run: %w", err) } - stages := BuildFullPlan() + stages := request.Plan.Stages() summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{ - Force: force, - SelectedArtifacts: normalizedArtifacts, + Force: request.Force, + SelectedArtifacts: request.SelectedArtifacts, EffectiveArtifacts: effectiveArtifacts, + Plan: request.Plan, }) if err != nil { return fmt.Errorf("run: %w", err) diff --git a/internal/app/runner.go b/internal/app/runner.go index 3ad79f1..058f0de 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -26,6 +26,7 @@ type RunOptions struct { Force bool SelectedArtifacts []string EffectiveArtifacts artifacts.EffectiveArtifactSet + Plan BoundedPlan Env *Env RunManifestStore manifest.RunStore }