Add the artifact regeneration command

This commit is contained in:
2026-08-29 18:19:54 +00:00
parent 966b95b176
commit 6639775d7d
6 changed files with 195 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ This runs the canonical full pipeline for session `2026-04-04`.
Top-level commands:
- `run <session_id>`: run all or one contiguous range of the canonical stage order.
- `regenerate-artifacts <session_id>`: force-run extraction through analysis.
- `run-stage <stage> <session_id>`: run one stage.
- `analyze <session_id>`: force-run analyze.
- `publish <session_id>`: force-run publish.
@@ -95,6 +96,29 @@ Behavior:
When `--artifacts` is present, the selected range must contain `analyze` or
`publish`. Either consumer is sufficient, including a one-stage range.
### `regenerate-artifacts`
```bash
narratio regenerate-artifacts <session_id> [--artifacts <name[,name...]>] [...common config flags]
```
Exactly equivalent to:
```bash
narratio run <session_id> --force --from extract --through analyze [caller options]
```
The command always reruns extraction. Analysis rebuilds the selected configured
artifacts and any prerequisites required by those targets; without
`--artifacts`, it uses the normal default analysis selection. Publish and notify
never run. Common session/configuration options and repeatable artifact values
pass through unchanged.
Because the expansion owns `--force`, `--from`, and `--through`, callers cannot
supply those options. The shared `run` parser reports them as duplicate
singleton flags. The alias has no private execution options or behavior, and
runtime diagnostics may identify the operation as `run`.
### `run-stage`
```bash

View File

@@ -142,6 +142,20 @@ shared session lifecycle. In particular, render does not require Notarius or
Scriptorium, extract does not require Scriptorium, and analyze does not require
the transcription, Seriatim, Audita, or Notarius adapters.
For the common post-transcript development loop, use:
```bash
narratio regenerate-artifacts 2026-04-04
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
```
This command is a transparent expansion to a forced bounded `run` from
`extract` through `analyze`. Extraction always rebuilds its complete configured
bundle. Analysis rebuilds the selected targets and their required analysis
prerequisites, or uses the normal default selection when no artifact names are
given. The command does not run publish or notify; delivery remains a separate
operator action.
## Artifact Selection
`--artifacts` can be used on `run`, `session plan`, `run-stage`, `analyze`, and

View File

@@ -264,6 +264,8 @@ when excluded upstream work cannot support the selected stages.
## Stage 5 — Exact `regenerate-artifacts` Alias
**Status: Completed**
### Goal
Add the transparent convenience command without creating another orchestration

View File

@@ -7,7 +7,9 @@ import (
"strings"
)
var supportedCommands = []string{"run", "run-stage", "analyze", "publish", "clean", "session"}
var supportedCommands = []string{"run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session"}
var runCommandFn = Run
// Execute dispatches CLI commands and returns a process exit code.
func Execute(args []string, stdout, stderr io.Writer) int {
@@ -23,7 +25,9 @@ func Execute(args []string, stdout, stderr io.Writer) int {
var err error
switch cmd {
case "run":
err = Run(ctx, cmdArgs, stdout)
err = runCommandFn(ctx, cmdArgs, stdout)
case "regenerate-artifacts":
err = RegenerateArtifacts(ctx, cmdArgs, stdout)
case "run-stage":
err = RunStage(ctx, cmdArgs, stdout)
case "analyze":

View File

@@ -0,0 +1,43 @@
package app
import (
"context"
"fmt"
"io"
)
// RegenerateArtifacts expands the convenience command into its canonical run
// invocation. The run command remains the sole owner of parsing and execution.
func RegenerateArtifacts(ctx context.Context, args []string, out io.Writer) error {
if containsHelpOption(args) {
printRegenerateArtifactsHelp(out)
return nil
}
expanded := make([]string, 0, len(args)+5)
if len(args) > 0 {
expanded = append(expanded, args[0])
args = args[1:]
}
expanded = append(expanded, "--force", "--from", "extract", "--through", "analyze")
expanded = append(expanded, args...)
return runCommandFn(ctx, expanded, out)
}
func containsHelpOption(args []string) bool {
for _, arg := range args {
if arg == "-h" || arg == "--help" {
return true
}
}
return false
}
func printRegenerateArtifactsHelp(out io.Writer) {
_, _ = fmt.Fprintln(out, "Usage: narratio regenerate-artifacts <session_id> [--artifacts <name[,name...]>] [common config flags]")
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintln(out, "Exactly equivalent to:")
_, _ = fmt.Fprintln(out, " narratio run <session_id> --force --from extract --through analyze [caller options]")
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintln(out, "Extraction always runs; selected analysis artifacts and their required prerequisites are rebuilt. Publish and notify never run.")
}

View File

@@ -0,0 +1,106 @@
package app
import (
"bytes"
"context"
"io"
"reflect"
"strings"
"testing"
)
func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
original := runCommandFn
t.Cleanup(func() { runCommandFn = original })
var captured []string
runCommandFn = func(_ context.Context, args []string, _ io.Writer) error {
captured = append([]string(nil), args...)
return nil
}
code := Execute([]string{
"regenerate-artifacts", "2026-05-03",
"--artifacts", "session_recap,player_handout",
"--artifacts=player_handout",
"--config", "pipeline.yml",
"--campaign", "sample-campaign",
}, io.Discard, io.Discard)
if code != 0 {
t.Fatalf("Execute() code = %d, want 0", code)
}
want := []string{
"2026-05-03", "--force", "--from", "extract", "--through", "analyze",
"--artifacts", "session_recap,player_handout",
"--artifacts=player_handout",
"--config", "pipeline.yml",
"--campaign", "sample-campaign",
}
if !reflect.DeepEqual(captured, want) {
t.Fatalf("forwarded args = %#v, want %#v", captured, want)
}
}
func TestRegenerateArtifactsHelpDoesNotInvokeRun(t *testing.T) {
original := runCommandFn
t.Cleanup(func() { runCommandFn = original })
called := false
runCommandFn = func(_ context.Context, _ []string, _ io.Writer) error {
called = true
return nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := Execute([]string{"regenerate-artifacts", "--help"}, &stdout, &stderr); code != 0 {
t.Fatalf("Execute() code = %d, stderr = %q", code, stderr.String())
}
if called {
t.Fatal("help invoked canonical run handler")
}
for _, detail := range []string{
"narratio run <session_id> --force --from extract --through analyze",
"Extraction always runs",
"Publish and notify never run",
} {
if !strings.Contains(stdout.String(), detail) {
t.Fatalf("help = %q, want %q", stdout.String(), detail)
}
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestRegenerateArtifactsOwnedOptionsFailThroughRunParser(t *testing.T) {
for _, test := range []struct {
name string
args []string
owned string
}{
{name: "force", args: []string{"--force"}, owned: "force"},
{name: "from", args: []string{"--from=render"}, owned: "from"},
{name: "through", args: []string{"--through", "publish"}, owned: "through"},
} {
t.Run(test.name, func(t *testing.T) {
args := []string{"regenerate-artifacts", "2026-05-03"}
args = append(args, test.args...)
var stderr bytes.Buffer
if code := Execute(args, io.Discard, &stderr); code == 0 {
t.Fatalf("Execute(%#v) code = 0", args)
}
if !strings.Contains(stderr.String(), "--"+test.owned+" may be specified only once") {
t.Fatalf("stderr = %q, want shared duplicate %s error", stderr.String(), test.owned)
}
})
}
}
func TestRegenerateArtifactsRejectsUnknownOptionsThroughRunParser(t *testing.T) {
var stderr bytes.Buffer
if code := Execute([]string{"regenerate-artifacts", "2026-05-03", "--regenerate-only"}, io.Discard, &stderr); code == 0 {
t.Fatal("Execute() code = 0")
}
if !strings.Contains(stderr.String(), "flag provided but not defined") || !strings.Contains(stderr.String(), "regenerate-only") {
t.Fatalf("stderr = %q, want canonical parser unknown-option error", stderr.String())
}
}