diff --git a/docs/cli.md b/docs/cli.md index 3b99f32..4c27461 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,6 +47,8 @@ Rules: - `--campaign` and `--campaign-file` are mutually exclusive. - `--session` is not used by `session init`. - if both positional `` and `--session-id` are provided, values must match. +- `--previous-session-id` is a strict expectation: the selected session file + must contain the same `previous_session_id`. - `clean --all` cannot be combined with campaign/session selectors. ## Session ID Input Rules diff --git a/docs/config.md b/docs/config.md index 86fde4f..a7aa622 100644 --- a/docs/config.md +++ b/docs/config.md @@ -49,7 +49,10 @@ remote state with an unsafe legacy identity must be migrated before use. ## Validation and Merge Rules -- YAML decode is strict (`KnownFields(true)`): unknown fields fail load. +- YAML decode is strict (`KnownFields(true)`) and accepts exactly one document: + unknown fields or trailing documents fail load. +- Configured timeout and retry-delay durations must be positive. An omitted + artifact timeout continues to inherit its configured Scriptorium timeout. - Session files must be concrete; unresolved `{{ ... }}` placeholders fail load. - Pipeline defaults are applied before validation. - Campaign and session identities must agree. @@ -150,8 +153,8 @@ Rules: | `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` | | `pipeline.campaigns.default_campaign_id` | string | No | empty | | `pipeline.secrets.env_dir` | string | No | empty | -| `pipeline.storage.backend` | string | No | empty | -| `pipeline.storage.s3.bucket` | string | Conditional | required for S3 session-audio and for publish upload when backend is `s3` | +| `pipeline.storage.backend` | string | No | `local`; supported values are `local` and `s3` (case-insensitive) | +| `pipeline.storage.s3.bucket` | string | Conditional | required when backend is `s3` and S3 session-audio or publish upload is enabled | | `pipeline.storage.s3.root_prefix` | string | No | `dnd` | | `pipeline.storage.s3.region` | string | No | empty | | `pipeline.storage.s3.endpoint` | string | No | empty | @@ -316,6 +319,20 @@ For each artifact input `pipeline.scriptorium.artifacts..inputs.= 0", - }, - { - name: "invalid concurrency fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe - concurrency: 0 -seriatim: - binary: seriatim -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.concurrency must be > 0", - }, - { - name: "unknown seriatim field fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim - bogus: true -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantLoadErr: "strict decode failed", - }, - { - name: "unknown seriatim env field fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim - env: - unknown_knob: 1.0 -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantLoadErr: "strict decode failed", - }, - { - name: "missing seriatim binary uses default", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - timeout: 10m -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - }, - { - name: "invalid seriatim timeout fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim - timeout: not-a-duration -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.timeout must be a valid duration", - }, - { - name: "invalid seriatim output schema fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim - output_schema: invalid-schema -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full", - }, - { - name: "negative seriatim coalesce gap fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim - coalesce_gap: -0.1 -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.coalesce_gap must be >= 0", - }, - { - name: "invalid seriatim env float fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim - env: - overlap_word_run_gap: 0 -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.env.overlap_word_run_gap must be > 0 when provided", - }, - { - name: "unknown audita field fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - bogus: true -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantLoadErr: "strict decode failed", - }, - { - name: "missing audita binary uses default", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - timeout: 3h -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - }, - { - name: "invalid audita timeout fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - timeout: bad-timeout -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.timeout must be a valid duration", - }, - { - name: "empty audita modules is valid override", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - modules: [] -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - }, - { - name: "empty audita module item fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - modules: - - glossary - - "" -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be non-empty", - }, - { - name: "invalid audita module item fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - modules: - - glossary - - bad_module -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be one of: glossary, homophones, spoken_word, grammar", - }, - { - name: "invalid audita base_url fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - base_url: :// -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.base_url must be a valid URL", - }, - { - name: "legacy audita llm_concurrency field fails strict decode", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - llm_concurrency: 1 -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantLoadErr: "strict decode failed", - }, - { - name: "invalid audita total_llm_concurrency fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - total_llm_concurrency: 0 -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.total_llm_concurrency must be > 0", - }, - { - name: "invalid audita proposal_llm_concurrency fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - proposal_llm_concurrency: 0 -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.proposal_llm_concurrency must be > 0", - }, - { - name: "invalid audita validation_llm_concurrency fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - validation_llm_concurrency: 0 -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.validation_llm_concurrency must be > 0", - }, - { - name: "invalid audita output_schema fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - output_schema: bad -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.output_schema must be one of: bare-segments, audita-v1", - }, - { - name: "invalid audita work_dir_retention fails", - pipelineYAML: `workspace: - root: /tmp/narratio -whisperx: - transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe -seriatim: - binary: seriatim -audita: - binary: audita - work_dir_retention: sometimes -`, - sessionYAML: `session_id: 2026-05-03 -inputs: - audio_dir: ./audio - speakers_file: ./speakers.yml - autocorrect_file: ./autocorrect.yml - glossary_file: ./glossary.yml - players_file: ./players.yml - party_file: ./party.yml -`, - wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.work_dir_retention must be one of: always, auto, never", - }, +`) + + cfg, err := Load(pipelinePath, sessionPath) + if err != nil { + t.Fatalf("Load() error = %v", err) } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - pipelinePath, sessionPath := writeConfigFiles(t, tt.pipelineYAML, tt.sessionYAML) - - cfg, err := Load(pipelinePath, sessionPath) - if tt.wantLoadErr != "" { - if err == nil { - t.Fatalf("expected load error containing %q, got nil", tt.wantLoadErr) - } - if !strings.Contains(err.Error(), tt.wantLoadErr) { - t.Fatalf("load error = %q, want to contain %q", err.Error(), tt.wantLoadErr) - } - if !strings.Contains(err.Error(), "strict decode failed") { - t.Fatalf("load error = %q, want strict decode context", err.Error()) - } - return - } - - if err != nil { - t.Fatalf("Load() error = %v", err) - } - if cfg.PipelinePath != pipelinePath { - t.Fatalf("PipelinePath = %q, want %q", cfg.PipelinePath, pipelinePath) - } - if cfg.SessionPath != sessionPath { - t.Fatalf("SessionPath = %q, want %q", cfg.SessionPath, sessionPath) - } - if tt.checkDefault { - if tt.wantRoot != "" && cfg.Pipeline.Workspace.Root != tt.wantRoot { - t.Fatalf("workspace.root = %q, want %q", cfg.Pipeline.Workspace.Root, tt.wantRoot) - } - if cfg.Pipeline.WhisperX.Language != "en" { - t.Fatalf("whisperx.language = %q, want %q", cfg.Pipeline.WhisperX.Language, "en") - } - if cfg.Pipeline.WhisperX.Timeout != "30m" { - t.Fatalf("whisperx.timeout = %q, want %q", cfg.Pipeline.WhisperX.Timeout, "30m") - } - if cfg.Pipeline.WhisperX.RetryDelay != "2s" { - t.Fatalf("whisperx.retry_delay = %q, want %q", cfg.Pipeline.WhisperX.RetryDelay, "2s") - } - if cfg.Pipeline.WhisperX.Retries == nil || *cfg.Pipeline.WhisperX.Retries != 3 { - t.Fatalf("whisperx.retries = %v, want 3", cfg.Pipeline.WhisperX.Retries) - } - if cfg.Pipeline.WhisperX.Concurrency == nil || *cfg.Pipeline.WhisperX.Concurrency != 2 { - t.Fatalf("whisperx.concurrency = %v, want 2", cfg.Pipeline.WhisperX.Concurrency) - } - if cfg.Pipeline.Seriatim.Timeout != "10m" { - t.Fatalf("seriatim.timeout = %q, want %q", cfg.Pipeline.Seriatim.Timeout, "10m") - } - if cfg.Pipeline.Seriatim.Binary != "seriatim" { - t.Fatalf("seriatim.binary = %q, want %q", cfg.Pipeline.Seriatim.Binary, "seriatim") - } - if cfg.Pipeline.Seriatim.OutputSchema != "seriatim-intermediate" { - t.Fatalf("seriatim.output_schema = %q, want %q", cfg.Pipeline.Seriatim.OutputSchema, "seriatim-intermediate") - } - if cfg.Pipeline.Seriatim.CoalesceGap == nil || *cfg.Pipeline.Seriatim.CoalesceGap != 3.0 { - t.Fatalf("seriatim.coalesce_gap = %v, want 3.0", cfg.Pipeline.Seriatim.CoalesceGap) - } - if cfg.Pipeline.Seriatim.Report == nil || *cfg.Pipeline.Seriatim.Report != true { - t.Fatalf("seriatim.report = %v, want true", cfg.Pipeline.Seriatim.Report) - } - if cfg.Pipeline.Audita.Timeout != "3h" { - t.Fatalf("audita.timeout = %q, want %q", cfg.Pipeline.Audita.Timeout, "3h") - } - if cfg.Pipeline.Audita.Binary != "audita" { - t.Fatalf("audita.binary = %q, want %q", cfg.Pipeline.Audita.Binary, "audita") - } - if cfg.Pipeline.Audita.LLMAPIKeyEnv != "" { - t.Fatalf("audita.llm_api_key_env = %q, want empty by default", cfg.Pipeline.Audita.LLMAPIKeyEnv) - } - if cfg.Pipeline.Audita.Modules != nil { - t.Fatalf("audita.modules = %#v, want nil default (optional override)", cfg.Pipeline.Audita.Modules) - } - if cfg.Pipeline.Audita.BaseURL != "" { - t.Fatalf("audita.base_url = %q, want empty default", cfg.Pipeline.Audita.BaseURL) - } - if cfg.Pipeline.Audita.Model != "" { - t.Fatalf("audita.model = %q, want empty default", cfg.Pipeline.Audita.Model) - } - if cfg.Pipeline.Audita.ValidationModel != "" { - t.Fatalf("audita.validation_model = %q, want empty default", cfg.Pipeline.Audita.ValidationModel) - } - if cfg.Pipeline.Audita.TotalLLMConcurrency != nil { - t.Fatalf("audita.total_llm_concurrency = %v, want nil default", cfg.Pipeline.Audita.TotalLLMConcurrency) - } - if cfg.Pipeline.Audita.ProposalLLMConcurrency != nil { - t.Fatalf("audita.proposal_llm_concurrency = %v, want nil default", cfg.Pipeline.Audita.ProposalLLMConcurrency) - } - if cfg.Pipeline.Audita.ValidationLLMConcurrency != nil { - t.Fatalf("audita.validation_llm_concurrency = %v, want nil default", cfg.Pipeline.Audita.ValidationLLMConcurrency) - } - if cfg.Pipeline.Audita.Report == nil || *cfg.Pipeline.Audita.Report != true { - t.Fatalf("audita.report = %v, want true", cfg.Pipeline.Audita.Report) - } - if cfg.Pipeline.Normalize == nil { - t.Fatal("normalize config should be present via defaults") - } - if cfg.Pipeline.Normalize.OutputPath != "transcripts/final.json" { - t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/final.json") - } - if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" { - t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate") - } - if cfg.Pipeline.Normalize.Report == nil || *cfg.Pipeline.Normalize.Report != true { - t.Fatalf("normalize.report = %v, want true", cfg.Pipeline.Normalize.Report) - } - } - - err = Validate(cfg) - if tt.wantValidate != "" { - if err == nil { - t.Fatalf("expected validation error containing %q, got nil", tt.wantValidate) - } - if !strings.Contains(err.Error(), tt.wantValidate) { - t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidate) - } - return - } - if err != nil { - t.Fatalf("Validate() error = %v", err) - } - }) + if cfg.Pipeline.Workspace.Root != DefaultWorkspaceRoot { + t.Fatalf("workspace.root = %q, want %q", cfg.Pipeline.Workspace.Root, DefaultWorkspaceRoot) + } + if cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil { + t.Fatalf("storage = %#v, want local backend without S3 configuration", cfg.Pipeline.Storage) + } + if cfg.Pipeline.WhisperX.Timeout != DefaultWhisperXTimeout || cfg.Pipeline.WhisperX.RetryDelay != DefaultWhisperXRetryDelay { + t.Fatalf("whisperx defaults = %#v, want timeout and retry-delay defaults", cfg.Pipeline.WhisperX) + } + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) } } func TestValidateMissingAudioSource(t *testing.T) { - retries := 3 - concurrency := 2 - cfg := &Config{ - Pipeline: &PipelineConfig{ - Workspace: WorkspaceConfig{Root: "/tmp/narratio"}, - WhisperX: WhisperXConfig{ - TranscribeURL: "https://transcription.ai.rakestrawhome.com/transcribe", - Language: "en", - Timeout: "30m", - Retries: &retries, - RetryDelay: "2s", - Concurrency: &concurrency, - }, - Seriatim: SeriatimConfig{ - Binary: "seriatim", - Timeout: "10m", - OutputSchema: "seriatim-intermediate", - CoalesceGap: float64Ptr(3.0), - Report: boolPtr(true), - }, - Audita: AuditaConfig{ - Binary: "audita", - Timeout: "3h", - LLMAPIKeyEnv: "AUDITA_LLM_API_KEY", - Modules: []string{"glossary", "homophones"}, - BaseURL: "https://openrouter.ai/api/v1", - Model: "openrouter/google/gemma-4-31b-it", - TotalLLMConcurrency: intPtr(1), - ProposalLLMConcurrency: intPtr(1), - ValidationModel: "", - ValidationLLMConcurrency: intPtr(1), - Report: boolPtr(true), - }, - }, - Campaign: &CampaignConfig{ - CampaignID: "sample-campaign", - Inputs: CampaignInputsConfig{ - SpeakersFile: "speakers.yml", - AutocorrectFile: "autocorrect.yml", - GlossaryFile: "glossary.yml", - PlayersFile: "players.yml", - PartyFile: "party.yml", - }, - }, - Session: &SessionConfig{ - SessionID: "2026-05-03", - Campaign: "sample-campaign", - Inputs: SessionInputsConfig{ - SpeakersFile: "speakers.yml", - AutocorrectFile: "autocorrect.yml", - GlossaryFile: "glossary.yml", - PlayersFile: "players.yml", - PartyFile: "party.yml", - }, - }, - } + cfg := loadedValidConfig(t) + cfg.Session.Inputs.AudioDir = "" + cfg.Session.Inputs.AudioFiles = nil + cfg.Session.Inputs.AudioS3 = nil err := Validate(cfg) if err == nil { diff --git a/internal/config/loader_strict_test.go b/internal/config/loader_strict_test.go new file mode 100644 index 0000000..3557e9d --- /dev/null +++ b/internal/config/loader_strict_test.go @@ -0,0 +1,91 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadersRejectTrailingYAMLDocuments(t *testing.T) { + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + campaignPath := filepath.Join(dir, "campaign.yml") + if err := os.WriteFile(pipelinePath, []byte("workspace:\n root: /tmp/narratio\n---\nworkspace:\n root: /other\n"), 0o644); err != nil { + t.Fatalf("write pipeline.yml: %v", err) + } + if err := os.WriteFile(campaignPath, []byte("campaign_id: sample-campaign\n---\nnull\n"), 0o644); err != nil { + t.Fatalf("write campaign.yml: %v", err) + } + + tests := []struct { + name string + load func() error + }{ + { + name: "pipeline", + load: func() error { + _, err := LoadPipeline(pipelinePath) + return err + }, + }, + { + name: "campaign", + load: func() error { + _, err := LoadCampaign(campaignPath) + return err + }, + }, + { + name: "remote session bytes", + load: func() error { + _, err := LoadSessionBytesWithOptions("s3://bucket/session.yml", []byte("session_id: 2026-05-03\n---\n# another document\nnull\n"), SessionLoadOptions{}) + return err + }, + }, + { + name: "publish lock store", + load: func() error { + _, err := LoadPublishLockStoreBytes("s3://bucket/locks.yml", []byte("locks: []\n---\n{}\n"), nil, nil) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.load() + if err == nil || !strings.Contains(err.Error(), "exactly one YAML document") { + t.Fatalf("load error = %v, want exactly-one-document error", err) + } + }) + } +} + +func TestStrictYAMLRejectsParserExposedEmptyTrailingDocument(t *testing.T) { + var target struct { + Name string `yaml:"name"` + } + err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("name: one\n---\n"), &target) + if err == nil { + t.Fatal("decodeStrictYAMLFromReader() error = nil, want trailing-document error") + } + if !strings.Contains(err.Error(), "exactly one YAML document") { + t.Fatalf("decodeStrictYAMLFromReader() error = %v, want exactly-one-document error", err) + } +} + +func TestStrictYAMLAcceptsSingleDocumentAndRejectsUnknownFields(t *testing.T) { + var target struct { + Name string `yaml:"name"` + } + if err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("name: one\n"), &target); err != nil { + t.Fatalf("decodeStrictYAMLFromReader(single document) error = %v", err) + } + if target.Name != "one" { + t.Fatalf("Name = %q, want one", target.Name) + } + if err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("unknown: one\n"), &target); err == nil || !strings.Contains(err.Error(), "strict decode failed") { + t.Fatalf("decodeStrictYAMLFromReader(unknown field) error = %v, want strict decode error", err) + } +} diff --git a/internal/config/session_template_test.go b/internal/config/session_template_test.go index 9aaf434..7bf6bf3 100644 --- a/internal/config/session_template_test.go +++ b/internal/config/session_template_test.go @@ -121,6 +121,24 @@ inputs: } } +func TestLoadSessionWithOptionsRequiresExpectedPreviousSession(t *testing.T) { + dir := t.TempDir() + sessionPath := filepath.Join(dir, "session.yml") + sessionYAML := `session_id: 2026-05-03 +campaign: sample-campaign +inputs: + audio_dir: ./audio +` + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session.yml: %v", err) + } + + _, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{PreviousSessionID: "2026-04-26"}) + if err == nil || !strings.Contains(err.Error(), "previous_session_id is required") { + t.Fatalf("LoadSessionWithOptions() error = %v, want missing previous-session expectation error", err) + } +} + func TestLoadSessionWithOptionsUnknownFieldStillRejected(t *testing.T) { dir := t.TempDir() sessionPath := filepath.Join(dir, "session.yml") @@ -217,6 +235,17 @@ func TestLoadSessionBytesWithOptionsMismatchFails(t *testing.T) { } } +func TestLoadSessionBytesWithOptionsRequiresExpectedPreviousSession(t *testing.T) { + _, err := LoadSessionBytesWithOptions( + "s3://bucket/session.yml", + []byte("session_id: 2026-05-03\n"), + SessionLoadOptions{PreviousSessionID: "2026-04-26"}, + ) + if err == nil || !strings.Contains(err.Error(), "previous_session_id is required") { + t.Fatalf("LoadSessionBytesWithOptions() error = %v, want missing previous-session expectation error", err) + } +} + func assertConcreteSessionTemplateError(t *testing.T, err error, vars ...string) { t.Helper() if !strings.Contains(err.Error(), "session.yml must be concrete") { diff --git a/internal/config/storage_publish_test.go b/internal/config/storage_publish_test.go index 4e2d3cc..4e0aef7 100644 --- a/internal/config/storage_publish_test.go +++ b/internal/config/storage_publish_test.go @@ -153,6 +153,108 @@ storage: } } +func TestStorageBackendSelectionValidation(t *testing.T) { + tests := []struct { + name string + storageYAML string + sessionYAML string + wantErr string + wantBackend string + }{ + { + name: "omitted storage defaults local", + wantBackend: StorageBackendLocal, + }, + { + name: "explicit local backend", + storageYAML: `storage: + backend: local +`, + wantBackend: StorageBackendLocal, + }, + { + name: "case insensitive s3 backend normalizes", + storageYAML: `storage: + backend: S3 + s3: + bucket: my-dnd-archive +`, + wantBackend: StorageBackendS3, + }, + { + name: "unknown backend", + storageYAML: `storage: + backend: s33 +`, + wantErr: "pipeline.storage.backend must be one of: local, s3", + }, + { + name: "s3 block requires s3 backend", + storageYAML: `storage: + backend: local + s3: + bucket: my-dnd-archive +`, + wantErr: "pipeline.storage.s3 is only supported when pipeline.storage.backend is s3", + }, + { + name: "s3 backend requires s3 block", + storageYAML: `storage: + backend: s3 +`, + wantErr: "pipeline.storage.s3 is required when pipeline.storage.backend is s3", + }, + { + name: "s3 backend requires bucket for publish", + storageYAML: `storage: + backend: s3 + s3: {} +`, + wantErr: "pipeline.storage.s3.bucket is required when S3 session audio or publish upload is enabled", + }, + { + name: "s3 audio requires s3 backend", + sessionYAML: `session_id: 2026-05-03 +inputs: + audio_s3: + prefix: audio/ +`, + wantErr: "pipeline.storage.backend must be s3 when session.inputs.audio_s3 is configured", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pipelineYAML := testPipelineBaseYAML + if tt.storageYAML != "" { + pipelineYAML += "\n" + tt.storageYAML + } + sessionYAML := testSessionBaseYAML + if tt.sessionYAML != "" { + sessionYAML = tt.sessionYAML + } + pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML) + cfg, err := Load(pipelinePath, sessionPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + err = Validate(cfg) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + if cfg.Pipeline.Storage.Backend != tt.wantBackend { + t.Fatalf("storage.backend = %q, want %q", cfg.Pipeline.Storage.Backend, tt.wantBackend) + } + }) + } +} + func TestSpoolAndPublishDefaults(t *testing.T) { pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML, testSessionBaseYAML) @@ -681,6 +783,7 @@ func TestStorageS3BucketRequiredWhenS3DependentFeatureEnabled(t *testing.T) { pipelineYAML := testPipelineBaseYAML + ` storage: backend: s3 + s3: {} ` sessionYAML := `session_id: 2026-05-03 campaign: forsaken diff --git a/internal/config/validate.go b/internal/config/validate.go index 4f0c7d6..1f77341 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -130,8 +130,19 @@ func validatePipeline(cfg *PipelineConfig) error { } func validateStorage(cfg StorageConfig) error { - if cfg.S3 == nil { + backend := strings.ToLower(strings.TrimSpace(cfg.Backend)) + switch backend { + case "", StorageBackendLocal: + if cfg.S3 != nil { + return fmt.Errorf("pipeline.storage.s3 is only supported when pipeline.storage.backend is s3") + } return nil + case StorageBackendS3: + if cfg.S3 == nil { + return fmt.Errorf("pipeline.storage.s3 is required when pipeline.storage.backend is s3") + } + default: + return fmt.Errorf("pipeline.storage.backend must be one of: local, s3") } if strings.TrimSpace(cfg.S3.RootPrefix) == "" { return fmt.Errorf("pipeline.storage.s3.root_prefix must be non-empty") @@ -783,13 +794,16 @@ func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error if pipeline == nil || session == nil { return nil } - if pipeline.Storage.S3 == nil { - return nil - } audioS3Enabled := session.Inputs.AudioS3 != nil publishUploadEnabled := publishUploadConfiguredForS3(pipeline) - if (audioS3Enabled || publishUploadEnabled) && strings.TrimSpace(pipeline.Storage.S3.Bucket) == "" { + if audioS3Enabled && !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), StorageBackendS3) { + return fmt.Errorf("pipeline.storage.backend must be s3 when session.inputs.audio_s3 is configured") + } + if !audioS3Enabled && !publishUploadEnabled { + return nil + } + if pipeline.Storage.S3 == nil || strings.TrimSpace(pipeline.Storage.S3.Bucket) == "" { return fmt.Errorf("pipeline.storage.s3.bucket is required when S3 session audio or publish upload is enabled") } return nil @@ -799,7 +813,7 @@ func publishUploadConfiguredForS3(pipeline *PipelineConfig) bool { if pipeline == nil || pipeline.Publish == nil { return false } - if !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), "s3") { + if !strings.EqualFold(strings.TrimSpace(pipeline.Storage.Backend), StorageBackendS3) { return false } enabled := true @@ -975,9 +989,13 @@ func validateDuration(fieldName, value string) error { return nil } - if _, err := time.ParseDuration(trimmed); err != nil { + duration, err := time.ParseDuration(trimmed) + if err != nil { return fmt.Errorf("%s must be a valid duration: %w", fieldName, err) } + if duration <= 0 { + return fmt.Errorf("%s must be positive", fieldName) + } return nil } diff --git a/internal/config/validation_duration_test.go b/internal/config/validation_duration_test.go new file mode 100644 index 0000000..a45a1b2 --- /dev/null +++ b/internal/config/validation_duration_test.go @@ -0,0 +1,114 @@ +package config + +import ( + "strings" + "testing" +) + +func TestValidateDurationsRequirePositiveValues(t *testing.T) { + tests := []struct { + name string + set func(*PipelineConfig, string) + want string + }{ + {name: "whisperx timeout", set: func(p *PipelineConfig, value string) { p.WhisperX.Timeout = value }, want: "pipeline.whisperx.timeout"}, + {name: "whisperx retry delay", set: func(p *PipelineConfig, value string) { p.WhisperX.RetryDelay = value }, want: "pipeline.whisperx.retry_delay"}, + {name: "seriatim timeout", set: func(p *PipelineConfig, value string) { p.Seriatim.Timeout = value }, want: "pipeline.seriatim.timeout"}, + {name: "audita timeout", set: func(p *PipelineConfig, value string) { p.Audita.Timeout = value }, want: "pipeline.audita.timeout"}, + {name: "scriptorium timeout", set: func(p *PipelineConfig, value string) { p.Scriptorium.Timeout = value }, want: "pipeline.scriptorium.timeout"}, + {name: "scriptorium artifact timeout", set: func(p *PipelineConfig, value string) { + if p.Scriptorium.Artifacts == nil { + p.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{} + } + p.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: value} + }, want: "pipeline.scriptorium.artifacts.session_recap.timeout"}, + {name: "trim bounds timeout", set: func(p *PipelineConfig, value string) { p.Trim.Bounds.Timeout = value }, want: "pipeline.trim.bounds.timeout"}, + {name: "notification timeout", set: func(p *PipelineConfig, value string) { p.Notification.Timeout = value }, want: "pipeline.notification.timeout"}, + } + + for _, value := range []string{"0s", "-1ms"} { + for _, tt := range tests { + t.Run(tt.name+"/"+value, func(t *testing.T) { + cfg := loadedValidConfig(t) + tt.set(cfg.Pipeline, value) + err := Validate(cfg) + if err == nil || !strings.Contains(err.Error(), tt.want+" must be positive") { + t.Fatalf("Validate() error = %v, want positive-value error for %s", err, tt.want) + } + }) + } + } +} + +func TestValidateNotariusTimeoutRequiresPositiveValue(t *testing.T) { + for _, value := range []string{"0s", "-1ms"} { + t.Run(value, func(t *testing.T) { + cfg := loadedValidConfig(t) + cfg.Pipeline.Notarius = &NotariusConfig{ + Enabled: true, + Binary: "notarius", + ConfigPath: "/tmp/notarius.yml", + PipelineID: "session", + Timeout: value, + WorkingDirectory: "/tmp", + Outputs: map[string]NotariusOutputConfig{ + "npc_registry": { + LaneID: "npc-registry", + MediaType: "application/json", + SchemaID: "notarius.dnd.npc_registry", + SchemaVersion: "v1", + }, + }, + } + err := Validate(cfg) + if err == nil || !strings.Contains(err.Error(), "pipeline.notarius.timeout must be positive") { + t.Fatalf("Validate() error = %v, want positive Notarius timeout error", err) + } + }) + } +} + +func TestValidateDurationRejectsOverflowAndAcceptsPositiveSubsecondValues(t *testing.T) { + cfg := loadedValidConfig(t) + cfg.Pipeline.WhisperX.Timeout = "999999999999999999999h" + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "pipeline.whisperx.timeout must be a valid duration") { + t.Fatalf("Validate() overflow error = %v, want duration parse error", err) + } + + cfg = loadedValidConfig(t) + cfg.Pipeline.WhisperX.Timeout = "1ms" + cfg.Pipeline.WhisperX.RetryDelay = "1ms" + cfg.Pipeline.Seriatim.Timeout = "1ms" + cfg.Pipeline.Audita.Timeout = "1ms" + cfg.Pipeline.Scriptorium.Timeout = "1ms" + cfg.Pipeline.Trim.Bounds.Timeout = "1ms" + cfg.Pipeline.Notification.Timeout = "1ms" + if cfg.Pipeline.Scriptorium.Artifacts == nil { + cfg.Pipeline.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{} + } + cfg.Pipeline.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: "1ms"} + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() positive subsecond durations error = %v", err) + } +} + +func TestValidateAllowsEmptyArtifactTimeoutFallback(t *testing.T) { + cfg := loadedValidConfig(t) + if cfg.Pipeline.Scriptorium.Artifacts == nil { + cfg.Pipeline.Scriptorium.Artifacts = map[string]ScriptoriumArtifactConfig{} + } + cfg.Pipeline.Scriptorium.Artifacts["session_recap"] = ScriptoriumArtifactConfig{Timeout: ""} + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() empty artifact timeout error = %v", err) + } +} + +func loadedValidConfig(t *testing.T) *Config { + t.Helper() + pipelinePath, sessionPath := writeConfigFiles(t, testPipelineBaseYAML, testSessionBaseYAML) + cfg, err := Load(pipelinePath, sessionPath) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + return cfg +}