Implemented narratio publish as a shortcut to run the archive stage only
This commit is contained in:
40
docs/cli.md
40
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 <path>`
|
||||
- `--campaign <path>`
|
||||
- `--session <path>`
|
||||
- `--session-id <value>`
|
||||
- `--previous-session-id <value>`
|
||||
|
||||
`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 <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>]
|
||||
```
|
||||
|
||||
Success output:
|
||||
- `narratio publish: executed=<n> skipped=<n> force=true; manifest=<path>`
|
||||
|
||||
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 <manifest.json>
|
||||
```
|
||||
|
||||
Get manifest path from previous output:
|
||||
- `run`, `resume`, `run-stage`, and `analyze` print `manifest=<path>` on success.
|
||||
- `run`, `resume`, `run-stage`, `analyze`, and `publish` print `manifest=<path>` on success.
|
||||
|
||||
## `--artifacts` and `--force`
|
||||
|
||||
|
||||
@@ -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.<artifact_key>`.
|
||||
@@ -139,6 +140,14 @@ Canonical previous-session input behavior:
|
||||
|
||||
## Remote archive layout and publish contract
|
||||
|
||||
Preferred manual publish command:
|
||||
|
||||
```bash
|
||||
narratio publish --session-id <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}/`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user