diff --git a/docs/cli.md b/docs/cli.md index 3af79ed..9c1fac2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -19,6 +19,7 @@ Implemented commands: - `resume`: continue from first non-succeeded stage unless forced. - `status`: read an existing manifest or inspect local/remote state for a session. - `run-stage`: execute exactly one stage. +- `analyze`: force-rerun the analyze stage. - `restore`: restore durable local session state from the committed remote archive state. - `session validate`: run read-only preflight checks for a session. - `session init`: create local or remote `session.yml`. @@ -72,6 +73,17 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle - `--artifacts `: analyze artifact keys to execute (repeatable or comma-separated). - positional ``: required stage name. +### `analyze` + +- `--config ` +- `--campaign ` +- `--session ` +- `--session-id ` +- `--previous-session-id ` +- `--artifacts `: analyze artifact keys to execute (repeatable or comma-separated). + +`analyze` is force-by-design and does not accept `--force`. + Valid stage names: - `prepare` @@ -355,6 +367,26 @@ Common failure cases: - unknown stage name. - using `--artifacts` with any non-`analyze` stage. +### `analyze` + +Purpose: +- Force-rerun the analyze stage. +- Provide a shorter equivalent for `narratio run-stage --force analyze`. + +Syntax: + +```bash +narratio analyze [--config ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--artifacts ] +``` + +Success output: +- `narratio analyze: executed= skipped= force=true; manifest=` + +Common failure cases: +- positional arguments. +- `--force`, because force is implicit. +- unknown configured artifact keys. + ### `restore` Purpose: @@ -442,10 +474,10 @@ Resume with selected analyze artifacts: narratio resume --session-id 2026-04-04 --artifacts player_handout ``` -Run only analyze stage with selected artifacts: +Force-rerun analyze with selected artifacts: ```bash -narratio run-stage --session-id 2026-04-04 --artifacts player_handout analyze +narratio analyze --session-id 2026-04-04 --artifacts player_handout ``` Preview restore actions without writes: @@ -458,7 +490,7 @@ Restore and then force analyze: ```bash narratio restore --session-id 2026-04-04 -narratio run-stage --session-id 2026-04-04 --force analyze +narratio analyze --session-id 2026-04-04 ``` Rehydrate canonical previous-session inputs after artifact-input changes: @@ -490,7 +522,7 @@ narratio status --manifest ``` Get manifest path from previous output: -- `run`, `resume`, and `run-stage` print `manifest=` on success. +- `run`, `resume`, `run-stage`, and `analyze` print `manifest=` on success. ## `--artifacts` and `--force` diff --git a/docs/operations.md b/docs/operations.md index 02b1949..4373534 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -56,7 +56,7 @@ narratio restore --session-id 2026-04-04 Post-restore analyze rerun pattern: ```bash -narratio run-stage --session-id 2026-04-04 --force analyze +narratio analyze --session-id 2026-04-04 ``` Restore source-of-truth: @@ -124,8 +124,9 @@ Configured artifact source reuse: - reused configured artifact provenance is `filesystem.disabled_artifact_output`. `--artifacts` behavior: -- accepted on `run`, `resume`, and `run-stage analyze`. -- filters analyze execution only; does not force stage rerun. +- accepted on `run`, `resume`, `run-stage analyze`, and `analyze`. +- filters analyze execution only. +- does not imply force on `run`, `resume`, or `run-stage`; `narratio analyze` is force-by-design. Canonical previous-session input behavior: - canonical sources use `narratio.previous_session.artifact.`. diff --git a/internal/app/analyze_artifacts_commands_test.go b/internal/app/analyze_artifacts_commands_test.go index fa63afc..a8087f4 100644 --- a/internal/app/analyze_artifacts_commands_test.go +++ b/internal/app/analyze_artifacts_commands_test.go @@ -9,7 +9,9 @@ import ( "testing" "time" + "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" + "gitea.maximumdirect.net/eric/narratio/internal/stage" ) func TestExecuteRunStageArtifactsNonAnalyzeFails(t *testing.T) { @@ -104,6 +106,150 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { } } +func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) + + var capturedStages []string + var capturedForce bool + origExecuteStagesFn := executeStagesFn + t.Cleanup(func() { + executeStagesFn = origExecuteStagesFn + }) + executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) { + for _, s := range stages { + capturedStages = append(capturedStages, s.Name()) + } + capturedForce = opts.Force + return &RunSummary{ + ManifestPath: filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json"), + Executed: []string{"analyze"}, + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute( + []string{"analyze", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, + &stdout, + &stderr, + ) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if len(capturedStages) != 1 || capturedStages[0] != "analyze" { + t.Fatalf("captured stages = %#v, want [analyze]", capturedStages) + } + if !capturedForce { + t.Fatal("captured force = false, want true") + } + if !strings.Contains(stdout.String(), "narratio analyze: executed=1 skipped=0 force=true; manifest=") { + t.Fatalf("stdout = %q, want analyze summary", stdout.String()) + } +} + +func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) + + var capturedArtifacts []string + origExecuteStagesFn := executeStagesFn + t.Cleanup(func() { + executeStagesFn = origExecuteStagesFn + }) + executeStagesFn = func(_ context.Context, _ *config.Config, _ []stage.Stage, opts RunOptions) (*RunSummary, error) { + capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...) + return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute( + []string{ + "analyze", + "--config", pipelinePath, + "--campaign", campaignPath, + "--session", sessionPath, + "--artifacts", "player_handout,session_recap", + }, + &stdout, + &stderr, + ) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if strings.Join(capturedArtifacts, ",") != "player_handout,session_recap" { + t.Fatalf("captured artifacts = %#v, want sorted selected artifacts", capturedArtifacts) + } +} + +func TestExecuteAnalyzeUnknownArtifactFailsValidation(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute( + []string{"analyze", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, + &stdout, + &stderr, + ) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr.String(), `analyze: --artifacts includes unknown artifact "unknown_artifact"`) { + t.Fatalf("stderr = %q, want unknown-artifact validation error", stderr.String()) + } +} + +func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {name: "positional", args: []string{"analyze", "extra"}, want: "analyze: unexpected positional arguments"}, + {name: "force flag", args: []string{"analyze", "--force"}, want: "analyze: invalid flags: flag provided but not defined: -force"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute(tc.args, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr.String(), tc.want) { + t.Fatalf("stderr = %q, want %q", stderr.String(), tc.want) + } + }) + } +} + +func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"analyze"}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr.String(), "analyze: no pipeline config path provided and no default pipeline config found; searched:") { + t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String()) + } +} + +func TestExecuteUsageIncludesAnalyze(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute(nil, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr.String(), "analyze") { + t.Fatalf("stderr = %q, want usage to include analyze", stderr.String()) + } +} + func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string, string) { t.Helper() diff --git a/internal/app/commands.go b/internal/app/commands.go index 0dbde4e..7a8bfd9 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -7,7 +7,7 @@ import ( "strings" ) -var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage", "restore", "session", "artifacts", "locks", "clean"} +var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage", "analyze", "restore", "session", "artifacts", "locks", "clean"} // Execute dispatches CLI commands and returns a process exit code. func Execute(args []string, stdout, stderr io.Writer) int { @@ -32,6 +32,8 @@ func Execute(args []string, stdout, stderr io.Writer) int { err = Resume(ctx, cmdArgs, stdout) case "run-stage": err = RunStage(ctx, cmdArgs, stdout) + case "analyze": + err = Analyze(ctx, cmdArgs, stdout) case "restore": err = Restore(ctx, cmdArgs, stdout) case "session": diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index 808479e..7f410ce 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -35,45 +35,34 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { if fs.NArg() != 1 { return fmt.Errorf("run-stage: expected exactly one stage name") } - stageName := fs.Arg(0) normalizedArtifacts, err := selectedArtifacts.Normalize() if err != nil { return fmt.Errorf("run-stage: invalid --artifacts: %w", err) } + stageName := fs.Arg(0) if len(normalizedArtifacts) > 0 && stageName != "analyze" { return fmt.Errorf("run-stage: --artifacts is only supported for stage \"analyze\"") } - stages, err := BuildSingleStagePlan(stageName) - if err != nil { - return fmt.Errorf("run-stage: %w", err) - } - cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{ + summary, err := runSingleStageCommand(ctx, singleStageCommand{ + CommandName: "run-stage", + StageName: stageName, + PipelinePath: pipelinePath, + CampaignPath: campaignPath, + SessionPath: sessionPath, SessionID: sessionID, PreviousSessionID: previousSessionID, - }) - if err != nil { - return fmt.Errorf("run-stage: %w", err) - } - if err := config.Validate(cfg); err != nil { - return fmt.Errorf("run-stage: %w", err) - } - if err := validateSelectedAnalyzeArtifacts(cfg, normalizedArtifacts); err != nil { - return fmt.Errorf("run-stage: %w", err) - } - - summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{ Force: force, SelectedArtifacts: normalizedArtifacts, }) if err != nil { - return fmt.Errorf("run-stage: %w", err) + return err } _, err = fmt.Fprintf( out, "narratio run-stage: stage=%s executed=%d skipped=%d force=%t; manifest=%s\n", - stages[0].Name(), + stageName, len(summary.Executed), len(summary.Skipped), force, @@ -81,3 +70,99 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { ) return err } + +// Analyze force-runs the analyze stage. +func Analyze(ctx context.Context, args []string, out io.Writer) error { + fs := flag.NewFlagSet("analyze", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var pipelinePath string + var campaignPath string + var sessionPath string + var sessionID string + var previousSessionID string + var selectedArtifacts artifactSelectionFlag + fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") + fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") + fs.StringVar(&sessionPath, "session", "", "path to session.yml") + fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") + fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") + fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)") + + if err := fs.Parse(args); err != nil { + return fmt.Errorf("analyze: invalid flags: %w", err) + } + if fs.NArg() != 0 { + return fmt.Errorf("analyze: unexpected positional arguments") + } + normalizedArtifacts, err := selectedArtifacts.Normalize() + if err != nil { + return fmt.Errorf("analyze: invalid --artifacts: %w", err) + } + + summary, err := runSingleStageCommand(ctx, singleStageCommand{ + CommandName: "analyze", + StageName: "analyze", + PipelinePath: pipelinePath, + CampaignPath: campaignPath, + SessionPath: sessionPath, + SessionID: sessionID, + PreviousSessionID: previousSessionID, + Force: true, + SelectedArtifacts: normalizedArtifacts, + }) + if err != nil { + return err + } + + _, err = fmt.Fprintf( + out, + "narratio analyze: executed=%d skipped=%d force=true; manifest=%s\n", + len(summary.Executed), + len(summary.Skipped), + summary.ManifestPath, + ) + return err +} + +type singleStageCommand struct { + CommandName string + StageName string + PipelinePath string + CampaignPath string + SessionPath string + SessionID string + PreviousSessionID string + Force bool + SelectedArtifacts []string +} + +func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSummary, error) { + stages, err := BuildSingleStagePlan(req.StageName) + if err != nil { + return nil, fmt.Errorf("%s: %w", req.CommandName, err) + } + + cfg, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.SessionPath, config.SessionLoadOptions{ + SessionID: req.SessionID, + PreviousSessionID: req.PreviousSessionID, + }) + if err != nil { + return nil, fmt.Errorf("%s: %w", req.CommandName, err) + } + if err := config.Validate(cfg); err != nil { + return nil, fmt.Errorf("%s: %w", req.CommandName, err) + } + if err := validateSelectedAnalyzeArtifacts(cfg, req.SelectedArtifacts); err != nil { + return nil, fmt.Errorf("%s: %w", req.CommandName, err) + } + + summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{ + Force: req.Force, + SelectedArtifacts: req.SelectedArtifacts, + }) + if err != nil { + return nil, fmt.Errorf("%s: %w", req.CommandName, err) + } + return summary, nil +}