Add bounded run and plan commands
This commit is contained in:
27
docs/cli.md
27
docs/cli.md
@@ -12,7 +12,7 @@ This runs the canonical full pipeline for session `2026-04-04`.
|
|||||||
|
|
||||||
Top-level commands:
|
Top-level commands:
|
||||||
|
|
||||||
- `run <session_id>`: run full stage order.
|
- `run <session_id>`: run all or one contiguous range of the canonical stage order.
|
||||||
- `run-stage <stage> <session_id>`: run one stage.
|
- `run-stage <stage> <session_id>`: run one stage.
|
||||||
- `analyze <session_id>`: force-run analyze.
|
- `analyze <session_id>`: force-run analyze.
|
||||||
- `publish <session_id>`: force-run publish.
|
- `publish <session_id>`: force-run publish.
|
||||||
@@ -73,19 +73,28 @@ Commands with additional positionals keep their command-specific order:
|
|||||||
### `run`
|
### `run`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio run <session_id> [--force] [--artifacts <name[,name...]>] [...common config flags]
|
narratio run <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- evaluates full stage order;
|
- evaluates one inclusive contiguous range of the canonical stage order;
|
||||||
- runs `extract` between `trim` and `render`; an omitted or disabled Notarius
|
- 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;
|
configuration records an explicit `notarius_disabled` self-skip;
|
||||||
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
||||||
resume check finds its durable result obsolete;
|
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;
|
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- writes session and run manifests.
|
- 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`
|
### `run-stage`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -100,8 +109,8 @@ Valid stage names:
|
|||||||
- `polish`
|
- `polish`
|
||||||
- `normalize`
|
- `normalize`
|
||||||
- `trim`
|
- `trim`
|
||||||
- `extract`
|
|
||||||
- `render`
|
- `render`
|
||||||
|
- `extract`
|
||||||
- `analyze`
|
- `analyze`
|
||||||
- `publish`
|
- `publish`
|
||||||
- `notify`
|
- `notify`
|
||||||
@@ -153,10 +162,12 @@ post-publish cleanup behavior.
|
|||||||
### `session plan`
|
### `session plan`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio session plan <session_id> [--force] [...common config flags]
|
narratio session plan <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...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`
|
### `session validate`
|
||||||
|
|
||||||
@@ -252,7 +263,7 @@ and precedence.
|
|||||||
|
|
||||||
## `--artifacts` Selection Rules
|
## `--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`;
|
- names must exist in `pipeline.scriptorium.artifacts`;
|
||||||
- empty entries are invalid;
|
- empty entries are invalid;
|
||||||
- repeated names are deduplicated.
|
- repeated names are deduplicated.
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ Canonical stage order:
|
|||||||
4. `polish`
|
4. `polish`
|
||||||
5. `normalize`
|
5. `normalize`
|
||||||
6. `trim`
|
6. `trim`
|
||||||
7. `extract`
|
7. `render`
|
||||||
8. `render`
|
8. `extract`
|
||||||
9. `analyze`
|
9. `analyze`
|
||||||
10. `publish`
|
10. `publish`
|
||||||
11. `notify`
|
11. `notify`
|
||||||
@@ -90,12 +90,12 @@ Execution rules:
|
|||||||
|
|
||||||
- succeeded stages are skipped unless `--force` is set;
|
- succeeded stages are skipped unless `--force` is set;
|
||||||
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- forcing an upstream stage marks succeeded downstream stages as `stale` before
|
- forcing a stage marks succeeded transitive dependents as `stale` before the
|
||||||
the replacement runs; and
|
replacement runs; render and extract are independent siblings; and
|
||||||
- an executed failure, changed self-skip, or success that replaces a different
|
- 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
|
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
|
An explicit self-skip is a durable `skipped` stage outcome that later runs
|
||||||
reconsider. It differs from successful no-output execution: disabled `render`
|
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
|
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
|
## 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:
|
Selection behavior:
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ stage range, shared by execution and plan preview.
|
|||||||
|
|
||||||
## Stage 3 — Bounded `run` And `session plan` Command Contracts
|
## Stage 3 — Bounded `run` And `session plan` Command Contracts
|
||||||
|
|
||||||
|
**Status: Completed**
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
Expose the shared range through both commands with one parsing and structural
|
Expose the shared range through both commands with one parsing and structural
|
||||||
|
|||||||
110
internal/app/bounded_run.go
Normal file
110
internal/app/bounded_run.go
Normal file
@@ -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 <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [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
|
||||||
|
}
|
||||||
185
internal/app/bounded_run_test.go
Normal file
185
internal/app/bounded_run_test.go
Normal file
@@ -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 <session_id> [--from <stage>] [--through <stage>]"},
|
||||||
|
{name: "plan", args: []string{"session", "plan", "--help"}, want: "Usage: narratio session plan <session_id> [--from <stage>] [--through <stage>]"},
|
||||||
|
} {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -16,20 +17,14 @@ import (
|
|||||||
|
|
||||||
// Plan validates configuration, prepares the local workdir, and prints stage order.
|
// Plan validates configuration, prepares the local workdir, and prints stage order.
|
||||||
func Plan(ctx 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)
|
request, err := parseBoundedRunRequest("plan", args, out)
|
||||||
fs.SetOutput(io.Discard)
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
var flags commonConfigFlags
|
return nil
|
||||||
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 {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if flags.sessionID == "" {
|
flags := request.Config
|
||||||
return fmt.Errorf("plan: session_id is required")
|
|
||||||
}
|
|
||||||
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
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 {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
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 {
|
if _, err := loadSecretsFromConfig(cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)); err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
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)
|
return fmt.Errorf("plan: prepare workdir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stages := BuildFullPlan()
|
stages := request.Plan.Stages()
|
||||||
var m *manifest.Manifest
|
var m *manifest.Manifest
|
||||||
m, err = loadManifestIfPresent(ctx, cfg)
|
m, err = loadManifestIfPresent(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
decisions := decideStageActions(stages, m, force)
|
decisions := decideStageActions(stages, m, request.Force)
|
||||||
|
|
||||||
runCount := 0
|
runCount := 0
|
||||||
skipCount := 0
|
skipCount := 0
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -11,22 +12,14 @@ import (
|
|||||||
|
|
||||||
// Run executes the pipeline plan and persists manifest state.
|
// Run executes the pipeline plan and persists manifest state.
|
||||||
func Run(ctx context.Context, args []string, out io.Writer) error {
|
func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
request, err := parseBoundedRunRequest("run", args, out)
|
||||||
fs.SetOutput(io.Discard)
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
var flags commonConfigFlags
|
return nil
|
||||||
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 {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if flags.sessionID == "" {
|
flags := request.Config
|
||||||
return fmt.Errorf("run: session_id is required")
|
|
||||||
}
|
|
||||||
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
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 {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("run: invalid --artifacts: %w", err)
|
|
||||||
}
|
|
||||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, normalizedArtifacts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
stages := BuildFullPlan()
|
stages := request.Plan.Stages()
|
||||||
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
|
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
|
||||||
Force: force,
|
Force: request.Force,
|
||||||
SelectedArtifacts: normalizedArtifacts,
|
SelectedArtifacts: request.SelectedArtifacts,
|
||||||
EffectiveArtifacts: effectiveArtifacts,
|
EffectiveArtifacts: effectiveArtifacts,
|
||||||
|
Plan: request.Plan,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type RunOptions struct {
|
|||||||
Force bool
|
Force bool
|
||||||
SelectedArtifacts []string
|
SelectedArtifacts []string
|
||||||
EffectiveArtifacts artifacts.EffectiveArtifactSet
|
EffectiveArtifacts artifacts.EffectiveArtifactSet
|
||||||
|
Plan BoundedPlan
|
||||||
Env *Env
|
Env *Env
|
||||||
RunManifestStore manifest.RunStore
|
RunManifestStore manifest.RunStore
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user