Add previous session ID templating and CLI support

This commit is contained in:
2026-05-20 14:26:32 +00:00
parent 2a4e1e912c
commit 7824afd4a5
13 changed files with 286 additions and 25 deletions

View File

@@ -30,6 +30,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
- `--config <path>`: optional explicit `pipeline.yml` path. - `--config <path>`: optional explicit `pipeline.yml` path.
- `--session <path>`: optional explicit `session.yml` path. - `--session <path>`: optional explicit `session.yml` path.
- `--session-id <value>`: session template variable value. - `--session-id <value>`: session template variable value.
- `--previous-session-id <value>`: previous-session template variable value.
- `--force`: force stage execution. - `--force`: force stage execution.
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated). - `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
@@ -38,6 +39,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
- `--config <path>` - `--config <path>`
- `--session <path>` - `--session <path>`
- `--session-id <value>` - `--session-id <value>`
- `--previous-session-id <value>`
- `--force` - `--force`
### `resume` ### `resume`
@@ -45,6 +47,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
- `--config <path>` - `--config <path>`
- `--session <path>` - `--session <path>`
- `--session-id <value>` - `--session-id <value>`
- `--previous-session-id <value>`
- `--force` - `--force`
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated). - `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
@@ -53,6 +56,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
- `--config <path>` - `--config <path>`
- `--session <path>` - `--session <path>`
- `--session-id <value>` - `--session-id <value>`
- `--previous-session-id <value>`
- `--force` - `--force`
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated). - `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
- positional `<stage>`: required stage name. - positional `<stage>`: required stage name.
@@ -74,6 +78,7 @@ Valid stage names:
- `--config <path>` - `--config <path>`
- `--session <path>` - `--session <path>`
- `--session-id <value>` - `--session-id <value>`
- `--previous-session-id <value>`
- `--dry-run`: plan restore actions without writing local files. - `--dry-run`: plan restore actions without writing local files.
- `--force`: overwrite local conflicting files with remote archive files. - `--force`: overwrite local conflicting files with remote archive files.
- `--include-audio`: include durable archived `audio/**` files in restore scope. - `--include-audio`: include durable archived `audio/**` files in restore scope.
@@ -92,7 +97,7 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio run [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>] narratio run [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
``` ```
Success output: Success output:
@@ -112,7 +117,7 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio plan [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] narratio plan [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force]
``` ```
Success output includes: Success output includes:
@@ -132,7 +137,7 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio resume [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>] narratio resume [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
``` ```
Success output: Success output:
@@ -172,7 +177,7 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio run-stage [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>] <stage> narratio run-stage [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] <stage>
``` ```
Success output: Success output:
@@ -196,7 +201,7 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio restore [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--dry-run] [--force] [--include-audio] narratio restore [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--dry-run] [--force] [--include-audio]
``` ```
Success output (dry-run): Success output (dry-run):

View File

@@ -48,9 +48,13 @@ Template behavior:
- supported placeholders: - supported placeholders:
- `{{session_id}}` - `{{session_id}}`
- `{{ session_id }}` - `{{ session_id }}`
- `{{previous_session_id}}`
- `{{ previous_session_id }}`
- `--session-id <value>` supplies the placeholder value. - `--session-id <value>` supplies the placeholder value.
- `--previous-session-id <value>` supplies the previous-session placeholder value.
- unresolved placeholders fail load. - unresolved placeholders fail load.
- if rendered `session_id` mismatches `--session-id`, load fails. - if rendered `session_id` mismatches `--session-id`, load fails.
- if rendered `previous_session_id` mismatches `--previous-session-id`, load fails.
## 4. Minimal pipeline config ## 4. Minimal pipeline config
@@ -69,6 +73,7 @@ Why this is sufficient:
```yaml ```yaml
session_id: "{{ session_id }}" session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign campaign: sample-campaign
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
@@ -279,6 +284,7 @@ Restore-related implications:
| Path | Type | Required | Default | | Path | Type | Required | Default |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `session.session_id` | string | Yes | none | | `session.session_id` | string | Yes | none |
| `session.previous_session_id` | string | No | empty |
| `session.campaign` | string | Yes | none | | `session.campaign` | string | Yes | none |
| `session.date` | string | No | empty | | `session.date` | string | No | empty |
| `session.title` | string | No | empty | | `session.title` | string | No | empty |
@@ -297,6 +303,10 @@ Audio-source rule:
- `audio_s3.prefix` - `audio_s3.prefix`
- `audio_s3` cannot be combined with local audio fields. - `audio_s3` cannot be combined with local audio fields.
Previous-session rule:
- if `session.previous_session_id` is set, it must not equal `session.session_id`.
## 9. Secrets ## 9. Secrets
Narratio supports filesystem-based secret injection via `pipeline.secrets.env_dir`. Narratio supports filesystem-based secret injection via `pipeline.secrets.env_dir`.

View File

@@ -22,10 +22,12 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string
var force bool var force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") 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.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
@@ -44,7 +46,8 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
} }
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID,
}) })
if err != nil { if err != nil {
return fmt.Errorf("plan: %w", err) return fmt.Errorf("plan: %w", err)

View File

@@ -28,17 +28,19 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string
var dryRun bool var dryRun bool
var force bool var force bool
var includeAudio bool var includeAudio bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") 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.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files") fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files")
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state") fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects") fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
fs.Usage = func() { fs.Usage = func() {
_, _ = fmt.Fprintln(out, "Usage: narratio restore [--config <path>] [--session <path>] [--session-id <value>] [--dry-run] [--force] [--include-audio]") _, _ = fmt.Fprintln(out, "Usage: narratio restore [--config <path>] [--session <path>] [--session-id <value>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]")
_, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintln(out, "Flags:") _, _ = fmt.Fprintln(out, "Flags:")
fs.PrintDefaults() fs.PrintDefaults()
@@ -63,7 +65,8 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
} }
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID,
}) })
if err != nil { if err != nil {
return fmt.Errorf("restore: %w", err) return fmt.Errorf("restore: %w", err)

View File

@@ -19,11 +19,13 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") 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.BoolVar(&force, "force", false, "force stage execution") fs.BoolVar(&force, "force", false, "force stage execution")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
@@ -43,7 +45,8 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
} }
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID,
}) })
if err != nil { if err != nil {
return fmt.Errorf("resume: %w", err) return fmt.Errorf("resume: %w", err)

View File

@@ -17,11 +17,13 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") 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.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
@@ -41,7 +43,8 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
} }
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID,
}) })
if err != nil { if err != nil {
return fmt.Errorf("run: %w", err) return fmt.Errorf("run: %w", err)

View File

@@ -17,11 +17,13 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") 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.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
@@ -54,7 +56,8 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
} }
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID,
}) })
if err != nil { if err != nil {
return fmt.Errorf("run-stage: %w", err) return fmt.Errorf("run-stage: %w", err)

View File

@@ -9,11 +9,12 @@ import (
"testing" "testing"
) )
func TestPlanUsesDiscoveredSessionTemplateWithSessionID(t *testing.T) { func TestPlanUsesDiscoveredSessionTemplateWithSessionIDs(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
sessionTemplate := `session_id: "{{ session_id }}" sessionTemplate := `session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign campaign: sample-campaign
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
@@ -36,7 +37,11 @@ inputs:
t.Cleanup(func() { _ = os.Chdir(originalWD) }) t.Cleanup(func() { _ = os.Chdir(originalWD) })
var out bytes.Buffer var out bytes.Buffer
if err := Plan(context.Background(), []string{"--config", pipelinePath, "--session-id", "2026-04-04"}, &out); err != nil { if err := Plan(context.Background(), []string{
"--config", pipelinePath,
"--session-id", "2026-04-04",
"--previous-session-id", "2026-03-28",
}, &out); err != nil {
t.Fatalf("Plan() error = %v", err) t.Fatalf("Plan() error = %v", err)
} }
if !strings.Contains(out.String(), "narratio plan: workdir prepared") { if !strings.Contains(out.String(), "narratio plan: workdir prepared") {
@@ -58,6 +63,38 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
} }
} }
func TestPlanFailsWhenPreviousSessionIDMismatchesConcreteSession(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
sessionYAML := `session_id: 2026-05-03
previous_session_id: 2026-04-26
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
var out bytes.Buffer
err := Plan(context.Background(), []string{
"--config", pipelinePath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--previous-session-id", "2026-04-25",
}, &out)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "previous_session_id mismatch") {
t.Fatalf("error = %q, want mismatch context", err.Error())
}
}
func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) { func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)

View File

@@ -27,11 +27,12 @@ type PipelineConfig struct {
// SessionConfig contains per-session inputs and metadata. // SessionConfig contains per-session inputs and metadata.
type SessionConfig struct { type SessionConfig struct {
SessionID string `yaml:"session_id"` SessionID string `yaml:"session_id"`
Campaign string `yaml:"campaign"` PreviousSessionID string `yaml:"previous_session_id"`
Date string `yaml:"date"` Campaign string `yaml:"campaign"`
Title string `yaml:"title"` Date string `yaml:"date"`
Inputs SessionInputsConfig `yaml:"inputs"` Title string `yaml:"title"`
Inputs SessionInputsConfig `yaml:"inputs"`
} }
// WorkspaceConfig configures local workspace behavior. // WorkspaceConfig configures local workspace behavior.

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -28,7 +29,8 @@ func LoadSession(path string) (*SessionConfig, error) {
// SessionLoadOptions configures session template rendering behavior. // SessionLoadOptions configures session template rendering behavior.
type SessionLoadOptions struct { type SessionLoadOptions struct {
SessionID string SessionID string
PreviousSessionID string
} }
// LoadSessionWithOptions loads session configuration from a YAML file with // LoadSessionWithOptions loads session configuration from a YAML file with
@@ -56,6 +58,16 @@ func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfi
strings.TrimSpace(cfg.SessionID), strings.TrimSpace(cfg.SessionID),
) )
} }
if strings.TrimSpace(opts.PreviousSessionID) != "" &&
strings.TrimSpace(cfg.PreviousSessionID) != "" &&
strings.TrimSpace(cfg.PreviousSessionID) != strings.TrimSpace(opts.PreviousSessionID) {
return nil, fmt.Errorf(
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match rendered previous_session_id %q",
path,
strings.TrimSpace(opts.PreviousSessionID),
strings.TrimSpace(cfg.PreviousSessionID),
)
}
return &cfg, nil return &cfg, nil
} }
@@ -114,24 +126,36 @@ var sessionTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)
func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) { func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) {
sessionID := strings.TrimSpace(opts.SessionID) sessionID := strings.TrimSpace(opts.SessionID)
previousSessionID := strings.TrimSpace(opts.PreviousSessionID)
rendered := content rendered := content
if sessionID != "" { if sessionID != "" {
rendered = strings.ReplaceAll(rendered, "{{session_id}}", sessionID) rendered = replaceTemplateVariable(rendered, "session_id", sessionID)
rendered = strings.ReplaceAll(rendered, "{{ session_id }}", sessionID) }
if previousSessionID != "" {
rendered = replaceTemplateVariable(rendered, "previous_session_id", previousSessionID)
} }
unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1) unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1)
if len(unresolved) > 0 { if len(unresolved) > 0 {
seenVars := map[string]struct{}{}
vars := make([]string, 0, len(unresolved)) vars := make([]string, 0, len(unresolved))
for _, m := range unresolved { for _, m := range unresolved {
if len(m) > 1 { if len(m) > 1 {
vars = append(vars, m[1]) name := m[1]
if _, ok := seenVars[name]; ok {
continue
}
seenVars[name] = struct{}{}
vars = append(vars, name)
} }
} }
sort.Strings(vars)
if len(vars) > 0 { if len(vars) > 0 {
hints := unresolvedTemplateHints(vars)
return "", fmt.Errorf( return "", fmt.Errorf(
"session file template rendering failed: unresolved template variable(s): %s; pass --session-id when using {{ session_id }}", "session file template rendering failed: unresolved template variable(s): %s%s",
strings.Join(vars, ", "), strings.Join(vars, ", "),
hints,
) )
} }
return "", fmt.Errorf("session file template rendering failed: unresolved template placeholders remain") return "", fmt.Errorf("session file template rendering failed: unresolved template placeholders remain")
@@ -140,6 +164,35 @@ func renderSessionTemplate(content string, opts SessionLoadOptions) (string, err
return rendered, nil return rendered, nil
} }
func replaceTemplateVariable(content, name, value string) string {
rendered := strings.ReplaceAll(content, "{{"+name+"}}", value)
rendered = strings.ReplaceAll(rendered, "{{ "+name+" }}", value)
return rendered
}
func unresolvedTemplateHints(vars []string) string {
seen := map[string]struct{}{}
flags := make([]string, 0, 2)
for _, name := range vars {
switch name {
case "session_id":
if _, ok := seen["--session-id"]; !ok {
seen["--session-id"] = struct{}{}
flags = append(flags, "--session-id")
}
case "previous_session_id":
if _, ok := seen["--previous-session-id"]; !ok {
seen["--previous-session-id"] = struct{}{}
flags = append(flags, "--previous-session-id")
}
}
}
if len(flags) == 0 {
return ""
}
return "; pass " + strings.Join(flags, " and ") + " when using those template variable(s)"
}
func shortName(path, fallback string) string { func shortName(path, fallback string) string {
base := filepath.Base(path) base := filepath.Base(path)
if base == "." || base == string(filepath.Separator) { if base == "." || base == string(filepath.Separator) {

View File

@@ -187,6 +187,43 @@ inputs:
`, `,
wantValidate: "session config \"session.yml\" invalid: session.session_id is required", wantValidate: "session config \"session.yml\" invalid: session.session_id is required",
}, },
{
name: "valid previous_session_id passes",
pipelineYAML: `workspace:
root: /tmp/narratio
whisperx:
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
seriatim:
binary: seriatim
`,
sessionYAML: `session_id: 2026-05-03
previous_session_id: 2026-04-26
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`,
},
{
name: "previous_session_id equal to session_id fails",
pipelineYAML: `workspace:
root: /tmp/narratio
whisperx:
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
seriatim:
binary: seriatim
`,
sessionYAML: `session_id: 2026-05-03
previous_session_id: 2026-05-03
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`,
wantValidate: "session config \"session.yml\" invalid: session.previous_session_id must not equal session.session_id",
},
{ {
name: "missing transcribe_url fails", name: "missing transcribe_url fails",
pipelineYAML: `workspace: pipelineYAML: `workspace:

View File

@@ -55,6 +55,34 @@ inputs:
} }
} }
func TestLoadSessionWithOptionsRendersPreviousSessionPlaceholder(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{
SessionID: "2026-04-04",
PreviousSessionID: "2026-03-28",
})
if err != nil {
t.Fatalf("LoadSessionWithOptions() error = %v", err)
}
if cfg.PreviousSessionID != "2026-03-28" {
t.Fatalf("PreviousSessionID = %q, want 2026-03-28", cfg.PreviousSessionID)
}
}
func TestLoadSessionWithOptionsUnresolvedPlaceholderFails(t *testing.T) { func TestLoadSessionWithOptionsUnresolvedPlaceholderFails(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
@@ -82,6 +110,37 @@ inputs:
} }
} }
func TestLoadSessionWithOptionsUnresolvedPreviousSessionPlaceholderFails(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: 2026-05-03
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "unresolved template variable") {
t.Fatalf("error = %q, want unresolved-variable context", err.Error())
}
if !strings.Contains(err.Error(), "previous_session_id") {
t.Fatalf("error = %q, want previous_session_id variable", err.Error())
}
if !strings.Contains(err.Error(), "--previous-session-id") {
t.Fatalf("error = %q, want previous-session-id guidance", err.Error())
}
}
func TestLoadSessionWithOptionsMismatchFails(t *testing.T) { func TestLoadSessionWithOptionsMismatchFails(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
@@ -106,6 +165,34 @@ inputs:
} }
} }
func TestLoadSessionWithOptionsPreviousSessionMismatchFails(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: 2026-05-03
previous_session_id: 2026-04-26
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{
SessionID: "2026-05-03",
PreviousSessionID: "2026-04-25",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "previous_session_id mismatch") {
t.Fatalf("error = %q, want mismatch context", err.Error())
}
}
func TestLoadSessionWithOptionsUnknownFieldStillRejectedAfterRendering(t *testing.T) { func TestLoadSessionWithOptionsUnknownFieldStillRejectedAfterRendering(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")

View File

@@ -487,8 +487,14 @@ func validateScriptorium(cfg *ScriptoriumConfig) error {
} }
func validateSession(cfg *SessionConfig) error { func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.SessionID) == "" { if err := validateSessionIdentifier("session.session_id", cfg.SessionID, true); err != nil {
return fmt.Errorf("session.session_id is required") return err
}
if err := validateSessionIdentifier("session.previous_session_id", cfg.PreviousSessionID, false); err != nil {
return err
}
if strings.TrimSpace(cfg.PreviousSessionID) != "" && strings.TrimSpace(cfg.PreviousSessionID) == strings.TrimSpace(cfg.SessionID) {
return fmt.Errorf("session.previous_session_id must not equal session.session_id")
} }
if strings.TrimSpace(cfg.Campaign) == "" { if strings.TrimSpace(cfg.Campaign) == "" {
return fmt.Errorf("session.campaign is required") return fmt.Errorf("session.campaign is required")
@@ -525,6 +531,16 @@ func validateSession(cfg *SessionConfig) error {
return nil return nil
} }
func validateSessionIdentifier(fieldName, value string, required bool) error {
if strings.TrimSpace(value) == "" {
if required {
return fmt.Errorf("%s is required", fieldName)
}
return nil
}
return nil
}
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error { func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error {
if pipeline == nil || session == nil { if pipeline == nil || session == nil {
return nil return nil