diff --git a/docs/cli.md b/docs/cli.md index 2f04251..12a5298 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,6 +20,7 @@ Implemented commands: - `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. +- `publish`: force-rerun the archive 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`. @@ -84,6 +85,16 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle `analyze` is force-by-design and does not accept `--force`. +### `publish` + +- `--config ` +- `--campaign ` +- `--session ` +- `--session-id ` +- `--previous-session-id ` + +`publish` is force-by-design and does not accept `--force`, `--artifacts`, or a stage positional argument. + Valid stage names: - `prepare` @@ -387,6 +398,27 @@ Common failure cases: - `--force`, because force is implicit. - unknown configured artifact keys. +### `publish` + +Purpose: +- Force-rerun the archive stage. +- Provide a shorter equivalent for `narratio run-stage --force archive`. + +Syntax: + +```bash +narratio publish [--config ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] +``` + +Success output: +- `narratio publish: executed= skipped= force=true; manifest=` + +Common failure cases: +- positional arguments. +- `--force`, because force is implicit. +- `--artifacts`, because artifact selection only applies to analyze. +- archive-stage failures such as missing required promotion sources or locked storage errors. + ### `restore` Purpose: @@ -486,6 +518,12 @@ Force-rerun analyze with selected artifacts: narratio analyze --session-id 2026-04-04 --artifacts player_handout ``` +Force-rerun archive publishing: + +```bash +narratio publish --session-id 2026-04-04 +``` + Preview restore actions without writes: ```bash @@ -528,7 +566,7 @@ narratio status --manifest ``` Get manifest path from previous output: -- `run`, `resume`, `run-stage`, and `analyze` print `manifest=` on success. +- `run`, `resume`, `run-stage`, `analyze`, and `publish` print `manifest=` on success. ## `--artifacts` and `--force` diff --git a/docs/operations.md b/docs/operations.md index 960732f..9fadc1a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -129,6 +129,7 @@ Configured artifact source reuse: - 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. +- `publish` does not accept `--artifacts`; it is a force-by-design archive rerun. Canonical previous-session input behavior: - canonical sources use `narratio.previous_session.artifact.`. @@ -139,6 +140,14 @@ Canonical previous-session input behavior: ## Remote archive layout and publish contract +Preferred manual publish command: + +```bash +narratio publish --session-id +``` + +`publish` is equivalent to `narratio run-stage --force archive`; use `run-stage` when you need the general single-stage command form. + When archive is enabled and run upload is enabled, archive publishes under: - session prefix: `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/` diff --git a/internal/app/analyze_artifacts_commands_test.go b/internal/app/analyze_artifacts_commands_test.go index a8087f4..ce516af 100644 --- a/internal/app/analyze_artifacts_commands_test.go +++ b/internal/app/analyze_artifacts_commands_test.go @@ -238,7 +238,91 @@ func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) { } } -func TestExecuteUsageIncludesAnalyze(t *testing.T) { +func TestExecutePublishForceRunsArchive(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) + + var capturedStages []string + var capturedForce bool + var capturedArtifacts []string + 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 + capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...) + return &RunSummary{ + ManifestPath: filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json"), + Executed: []string{"archive"}, + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute( + []string{"publish", "--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] != "archive" { + t.Fatalf("captured stages = %#v, want [archive]", capturedStages) + } + if !capturedForce { + t.Fatal("captured force = false, want true") + } + if len(capturedArtifacts) != 0 { + t.Fatalf("captured artifacts = %#v, want empty", capturedArtifacts) + } + if !strings.Contains(stdout.String(), "narratio publish: executed=1 skipped=0 force=true; manifest=") { + t.Fatalf("stdout = %q, want publish summary", stdout.String()) + } +} + +func TestExecutePublishRejectsUnsupportedArgsAndFlags(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {name: "positional", args: []string{"publish", "archive"}, want: "publish: unexpected positional arguments"}, + {name: "force flag", args: []string{"publish", "--force"}, want: "publish: invalid flags: flag provided but not defined: -force"}, + {name: "artifacts flag", args: []string{"publish", "--artifacts", "session_recap"}, want: "publish: invalid flags: flag provided but not defined: -artifacts"}, + } + 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 TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"publish"}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr.String(), "publish: no pipeline config path provided and no default pipeline config found; searched:") { + t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String()) + } +} + +func TestExecuteUsageIncludesAnalyzeAndPublish(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute(nil, &stdout, &stderr) @@ -248,6 +332,9 @@ func TestExecuteUsageIncludesAnalyze(t *testing.T) { if !strings.Contains(stderr.String(), "analyze") { t.Fatalf("stderr = %q, want usage to include analyze", stderr.String()) } + if !strings.Contains(stderr.String(), "publish") { + t.Fatalf("stderr = %q, want usage to include publish", stderr.String()) + } } func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string, string) { diff --git a/internal/app/commands.go b/internal/app/commands.go index 7a8bfd9..2fd52ca 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", "analyze", "restore", "session", "artifacts", "locks", "clean"} +var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage", "analyze", "publish", "restore", "session", "artifacts", "locks", "clean"} // Execute dispatches CLI commands and returns a process exit code. func Execute(args []string, stdout, stderr io.Writer) int { @@ -34,6 +34,8 @@ func Execute(args []string, stdout, stderr io.Writer) int { err = RunStage(ctx, cmdArgs, stdout) case "analyze": err = Analyze(ctx, cmdArgs, stdout) + case "publish": + err = Publish(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 7f410ce..62b0928 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -125,6 +125,53 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error { return err } +// Publish force-runs the archive stage. +func Publish(ctx context.Context, args []string, out io.Writer) error { + fs := flag.NewFlagSet("publish", flag.ContinueOnError) + fs.SetOutput(io.Discard) + + var pipelinePath string + var campaignPath string + var sessionPath string + var sessionID string + var previousSessionID string + 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") + + if err := fs.Parse(args); err != nil { + return fmt.Errorf("publish: invalid flags: %w", err) + } + if fs.NArg() != 0 { + return fmt.Errorf("publish: unexpected positional arguments") + } + + summary, err := runSingleStageCommand(ctx, singleStageCommand{ + CommandName: "publish", + StageName: "archive", + PipelinePath: pipelinePath, + CampaignPath: campaignPath, + SessionPath: sessionPath, + SessionID: sessionID, + PreviousSessionID: previousSessionID, + Force: true, + }) + if err != nil { + return err + } + + _, err = fmt.Fprintf( + out, + "narratio publish: 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