From 7824afd4a59c59ae987b1e9976130e0840e683fa Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 20 May 2026 14:26:32 +0000 Subject: [PATCH] Add previous session ID templating and CLI support --- docs/cli.md | 15 ++-- docs/config.md | 10 +++ internal/app/plan.go | 5 +- internal/app/restore.go | 7 +- internal/app/resume.go | 5 +- internal/app/run.go | 5 +- internal/app/run_stage.go | 5 +- internal/app/session_cli_test.go | 41 ++++++++++- internal/config/config.go | 11 +-- internal/config/load.go | 63 +++++++++++++++-- internal/config/load_validate_test.go | 37 ++++++++++ internal/config/session_template_test.go | 87 ++++++++++++++++++++++++ internal/config/validate.go | 20 +++++- 13 files changed, 286 insertions(+), 25 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 22564cd..2d40241 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -30,6 +30,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle - `--config `: optional explicit `pipeline.yml` path. - `--session `: optional explicit `session.yml` path. - `--session-id `: session template variable value. +- `--previous-session-id `: previous-session template variable value. - `--force`: force stage execution. - `--artifacts `: 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 ` - `--session ` - `--session-id ` +- `--previous-session-id ` - `--force` ### `resume` @@ -45,6 +47,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle - `--config ` - `--session ` - `--session-id ` +- `--previous-session-id ` - `--force` - `--artifacts `: 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 ` - `--session ` - `--session-id ` +- `--previous-session-id ` - `--force` - `--artifacts `: analyze artifact keys to execute (repeatable or comma-separated). - positional ``: required stage name. @@ -74,6 +78,7 @@ Valid stage names: - `--config ` - `--session ` - `--session-id ` +- `--previous-session-id ` - `--dry-run`: plan restore actions without writing local files. - `--force`: overwrite local conflicting files with remote archive files. - `--include-audio`: include durable archived `audio/**` files in restore scope. @@ -92,7 +97,7 @@ Purpose: Syntax: ```bash -narratio run [--config ] [--session ] [--session-id ] [--force] [--artifacts ] +narratio run [--config ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] ``` Success output: @@ -112,7 +117,7 @@ Purpose: Syntax: ```bash -narratio plan [--config ] [--session ] [--session-id ] [--force] +narratio plan [--config ] [--session ] [--session-id ] [--previous-session-id ] [--force] ``` Success output includes: @@ -132,7 +137,7 @@ Purpose: Syntax: ```bash -narratio resume [--config ] [--session ] [--session-id ] [--force] [--artifacts ] +narratio resume [--config ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] ``` Success output: @@ -172,7 +177,7 @@ Purpose: Syntax: ```bash -narratio run-stage [--config ] [--session ] [--session-id ] [--force] [--artifacts ] +narratio run-stage [--config ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] ``` Success output: @@ -196,7 +201,7 @@ Purpose: Syntax: ```bash -narratio restore [--config ] [--session ] [--session-id ] [--dry-run] [--force] [--include-audio] +narratio restore [--config ] [--session ] [--session-id ] [--previous-session-id ] [--dry-run] [--force] [--include-audio] ``` Success output (dry-run): diff --git a/docs/config.md b/docs/config.md index 9a8eb7d..4725781 100644 --- a/docs/config.md +++ b/docs/config.md @@ -48,9 +48,13 @@ Template behavior: - supported placeholders: - `{{session_id}}` - `{{ session_id }}` + - `{{previous_session_id}}` + - `{{ previous_session_id }}` - `--session-id ` supplies the placeholder value. +- `--previous-session-id ` supplies the previous-session placeholder value. - unresolved placeholders fail load. - if rendered `session_id` mismatches `--session-id`, load fails. +- if rendered `previous_session_id` mismatches `--previous-session-id`, load fails. ## 4. Minimal pipeline config @@ -69,6 +73,7 @@ Why this is sufficient: ```yaml session_id: "{{ session_id }}" +previous_session_id: "{{ previous_session_id }}" campaign: sample-campaign inputs: audio_dir: ./audio @@ -279,6 +284,7 @@ Restore-related implications: | Path | Type | Required | Default | | --- | --- | --- | --- | | `session.session_id` | string | Yes | none | +| `session.previous_session_id` | string | No | empty | | `session.campaign` | string | Yes | none | | `session.date` | string | No | empty | | `session.title` | string | No | empty | @@ -297,6 +303,10 @@ Audio-source rule: - `audio_s3.prefix` - `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 Narratio supports filesystem-based secret injection via `pipeline.secrets.env_dir`. diff --git a/internal/app/plan.go b/internal/app/plan.go index 89f9b90..3be1bcb 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -22,10 +22,12 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var sessionPath string var sessionID string + var previousSessionID string var force bool fs.StringVar(&pipelinePath, "config", "", "path to pipeline.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.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") 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{ - SessionID: sessionID, + SessionID: sessionID, + PreviousSessionID: previousSessionID, }) if err != nil { return fmt.Errorf("plan: %w", err) diff --git a/internal/app/restore.go b/internal/app/restore.go index 901df35..3668edc 100644 --- a/internal/app/restore.go +++ b/internal/app/restore.go @@ -28,17 +28,19 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var sessionPath string var sessionID string + var previousSessionID string var dryRun bool var force bool var includeAudio bool fs.StringVar(&pipelinePath, "config", "", "path to pipeline.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.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(&includeAudio, "include-audio", false, "include archived session-level audio objects") fs.Usage = func() { - _, _ = fmt.Fprintln(out, "Usage: narratio restore [--config ] [--session ] [--session-id ] [--dry-run] [--force] [--include-audio]") + _, _ = fmt.Fprintln(out, "Usage: narratio restore [--config ] [--session ] [--session-id ] [--previous-session-id ] [--dry-run] [--force] [--include-audio]") _, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out, "Flags:") 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{ - SessionID: sessionID, + SessionID: sessionID, + PreviousSessionID: previousSessionID, }) if err != nil { return fmt.Errorf("restore: %w", err) diff --git a/internal/app/resume.go b/internal/app/resume.go index 3306bfc..8e8c367 100644 --- a/internal/app/resume.go +++ b/internal/app/resume.go @@ -19,11 +19,13 @@ func Resume(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var sessionPath string var sessionID string + var previousSessionID string var force bool var selectedArtifacts artifactSelectionFlag fs.StringVar(&pipelinePath, "config", "", "path to pipeline.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.BoolVar(&force, "force", false, "force stage execution") 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{ - SessionID: sessionID, + SessionID: sessionID, + PreviousSessionID: previousSessionID, }) if err != nil { return fmt.Errorf("resume: %w", err) diff --git a/internal/app/run.go b/internal/app/run.go index 703bdcc..9350037 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -17,11 +17,13 @@ func Run(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var sessionPath string var sessionID string + var previousSessionID string var force bool var selectedArtifacts artifactSelectionFlag fs.StringVar(&pipelinePath, "config", "", "path to pipeline.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.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)") @@ -41,7 +43,8 @@ func Run(ctx context.Context, args []string, out io.Writer) error { } cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ - SessionID: sessionID, + SessionID: sessionID, + PreviousSessionID: previousSessionID, }) if err != nil { return fmt.Errorf("run: %w", err) diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index 2ff7cdd..082dc0f 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -17,11 +17,13 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var sessionPath string var sessionID string + var previousSessionID string var force bool var selectedArtifacts artifactSelectionFlag fs.StringVar(&pipelinePath, "config", "", "path to pipeline.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.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)") @@ -54,7 +56,8 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { } cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ - SessionID: sessionID, + SessionID: sessionID, + PreviousSessionID: previousSessionID, }) if err != nil { return fmt.Errorf("run-stage: %w", err) diff --git a/internal/app/session_cli_test.go b/internal/app/session_cli_test.go index 09f5f6d..796b3df 100644 --- a/internal/app/session_cli_test.go +++ b/internal/app/session_cli_test.go @@ -9,11 +9,12 @@ import ( "testing" ) -func TestPlanUsesDiscoveredSessionTemplateWithSessionID(t *testing.T) { +func TestPlanUsesDiscoveredSessionTemplateWithSessionIDs(t *testing.T) { workspaceRoot := t.TempDir() pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) sessionTemplate := `session_id: "{{ session_id }}" +previous_session_id: "{{ previous_session_id }}" campaign: sample-campaign inputs: audio_dir: ./audio @@ -36,7 +37,11 @@ inputs: t.Cleanup(func() { _ = os.Chdir(originalWD) }) 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) } 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) { workspaceRoot := t.TempDir() pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) diff --git a/internal/config/config.go b/internal/config/config.go index 31f1cc9..9869f05 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,11 +27,12 @@ type PipelineConfig struct { // SessionConfig contains per-session inputs and metadata. type SessionConfig struct { - SessionID string `yaml:"session_id"` - Campaign string `yaml:"campaign"` - Date string `yaml:"date"` - Title string `yaml:"title"` - Inputs SessionInputsConfig `yaml:"inputs"` + SessionID string `yaml:"session_id"` + PreviousSessionID string `yaml:"previous_session_id"` + Campaign string `yaml:"campaign"` + Date string `yaml:"date"` + Title string `yaml:"title"` + Inputs SessionInputsConfig `yaml:"inputs"` } // WorkspaceConfig configures local workspace behavior. diff --git a/internal/config/load.go b/internal/config/load.go index b50a4ce..5611daa 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "gopkg.in/yaml.v3" @@ -28,7 +29,8 @@ func LoadSession(path string) (*SessionConfig, error) { // SessionLoadOptions configures session template rendering behavior. type SessionLoadOptions struct { - SessionID string + SessionID string + PreviousSessionID string } // LoadSessionWithOptions loads session configuration from a YAML file with @@ -56,6 +58,16 @@ func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfi 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 } @@ -114,24 +126,36 @@ var sessionTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*) func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) { sessionID := strings.TrimSpace(opts.SessionID) + previousSessionID := strings.TrimSpace(opts.PreviousSessionID) rendered := content if sessionID != "" { - rendered = strings.ReplaceAll(rendered, "{{session_id}}", sessionID) - rendered = strings.ReplaceAll(rendered, "{{ session_id }}", sessionID) + rendered = replaceTemplateVariable(rendered, "session_id", sessionID) + } + if previousSessionID != "" { + rendered = replaceTemplateVariable(rendered, "previous_session_id", previousSessionID) } unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1) if len(unresolved) > 0 { + seenVars := map[string]struct{}{} vars := make([]string, 0, len(unresolved)) for _, m := range unresolved { 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 { + hints := unresolvedTemplateHints(vars) 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, ", "), + hints, ) } 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 } +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 { base := filepath.Base(path) if base == "." || base == string(filepath.Separator) { diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 93d1648..87f5de3 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -187,6 +187,43 @@ inputs: `, 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", pipelineYAML: `workspace: diff --git a/internal/config/session_template_test.go b/internal/config/session_template_test.go index e8a8638..cb490f5 100644 --- a/internal/config/session_template_test.go +++ b/internal/config/session_template_test.go @@ -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) { dir := t.TempDir() 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) { dir := t.TempDir() 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) { dir := t.TempDir() sessionPath := filepath.Join(dir, "session.yml") diff --git a/internal/config/validate.go b/internal/config/validate.go index a81f94e..b2fe2de 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -487,8 +487,14 @@ func validateScriptorium(cfg *ScriptoriumConfig) error { } func validateSession(cfg *SessionConfig) error { - if strings.TrimSpace(cfg.SessionID) == "" { - return fmt.Errorf("session.session_id is required") + if err := validateSessionIdentifier("session.session_id", cfg.SessionID, true); err != nil { + 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) == "" { return fmt.Errorf("session.campaign is required") @@ -525,6 +531,16 @@ func validateSession(cfg *SessionConfig) error { 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 { if pipeline == nil || session == nil { return nil