From b29d8eeb505c745469c3f66abbe282a1fe20219f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 20 May 2026 20:41:28 -0500 Subject: [PATCH] Add campaign configuration support --- docs/cli.md | 21 ++- docs/config.md | 70 ++++++--- docs/internal/stage-prepare.md | 11 +- docs/operations.md | 3 +- examples/campaign.yml | 5 + examples/session.local-audio.yml | 4 - examples/session.s3-audio.yml | 4 - examples/session.template.yml | 4 - .../app/analyze_artifacts_commands_test.go | 22 +-- internal/app/campaign_config_path.go | 49 +++++++ internal/app/campaign_config_path_test.go | 49 +++++++ internal/app/commands_test.go | 89 ++++++++--- internal/app/plan.go | 8 +- internal/app/plan_test.go | 11 +- internal/app/restore.go | 10 +- internal/app/restore_execution_test.go | 52 +++---- internal/app/restore_test.go | 34 +++-- internal/app/restore_workflow_test.go | 12 +- internal/app/resume.go | 8 +- internal/app/resume_run_stage_test.go | 36 ++--- internal/app/run.go | 8 +- internal/app/run_stage.go | 8 +- internal/app/runner_test.go | 31 +++- internal/app/session_cli_test.go | 13 +- internal/config/campaign_config_test.go | 138 ++++++++++++++++++ internal/config/config.go | 35 ++++- internal/config/defaults.go | 14 ++ internal/config/load.go | 117 ++++++++++++++- internal/config/load_validate_test.go | 24 ++- internal/config/validate.go | 16 ++ internal/stage/placeholders_test.go | 26 +++- internal/stage/prepare.go | 70 +++++++-- internal/stage/prepare_test.go | 36 ++++- internal/stage/transcribe_test.go | 9 ++ 34 files changed, 865 insertions(+), 182 deletions(-) create mode 100644 examples/campaign.yml create mode 100644 internal/app/campaign_config_path.go create mode 100644 internal/app/campaign_config_path_test.go create mode 100644 internal/config/campaign_config_test.go diff --git a/docs/cli.md b/docs/cli.md index 8c0e5fc..f48a369 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,7 +6,7 @@ narratio run --session-id 2026-04-04 ``` -This command uses default discovery for `pipeline.yml` and `session.yml`; both files must be discoverable unless you pass explicit `--config` and `--session` paths. +This command uses default discovery for `pipeline.yml`, `campaign.yml`, and `session.yml`; all three files must be discoverable unless you pass explicit `--config`, `--campaign`, and `--session` paths. ## Command Overview @@ -28,6 +28,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle ### `run` - `--config `: optional explicit `pipeline.yml` path. +- `--campaign `: optional explicit `campaign.yml` path. - `--session `: optional explicit `session.yml` path. - `--session-id `: session template variable value. - `--previous-session-id `: previous-session template variable value. @@ -37,6 +38,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle ### `plan` - `--config ` +- `--campaign ` - `--session ` - `--session-id ` - `--previous-session-id ` @@ -45,6 +47,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle ### `resume` - `--config ` +- `--campaign ` - `--session ` - `--session-id ` - `--previous-session-id ` @@ -54,6 +57,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle ### `run-stage` - `--config ` +- `--campaign ` - `--session ` - `--session-id ` - `--previous-session-id ` @@ -76,6 +80,7 @@ Valid stage names: ### `restore` - `--config ` +- `--campaign ` - `--session ` - `--session-id ` - `--previous-session-id ` @@ -97,14 +102,14 @@ Purpose: Syntax: ```bash -narratio run [--config ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] +narratio run [--config ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] ``` Success output: - `narratio run: session ; executed= skipped=; manifest=` Common failure cases: -- missing default config/session paths when flags omitted. +- missing default config/campaign/session paths when flags omitted. - invalid template/rendered session mismatch. - unknown/invalid `--artifacts` value. - `--artifacts` with unknown configured artifact key. @@ -117,7 +122,7 @@ Purpose: Syntax: ```bash -narratio plan [--config ] [--session ] [--session-id ] [--previous-session-id ] [--force] +narratio plan [--config ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--force] ``` Success output includes: @@ -126,7 +131,7 @@ Success output includes: - `totals: run= skip=` Common failure cases: -- same config/session discovery and validation failures as `run`. +- same config/campaign/session discovery and validation failures as `run`. - secrets directory read failures when `pipeline.secrets.env_dir` is configured. ### `resume` @@ -137,7 +142,7 @@ Purpose: Syntax: ```bash -narratio resume [--config ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] +narratio resume [--config ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] ``` Success output: @@ -177,7 +182,7 @@ Purpose: Syntax: ```bash -narratio run-stage [--config ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] +narratio run-stage [--config ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] ``` Success output: @@ -201,7 +206,7 @@ Purpose: Syntax: ```bash -narratio restore [--config ] [--session ] [--session-id ] [--previous-session-id ] [--dry-run] [--force] [--include-audio] +narratio restore [--config ] [--campaign ] [--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 046d700..5af8fd5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2,12 +2,13 @@ ## 1. Overview -Narratio loads two YAML files: +Narratio loads three YAML files: - `pipeline.yml`: pipeline-level runtime configuration. +- `campaign.yml`: stable campaign identity and campaign-level input defaults. - `session.yml`: per-session metadata and input selection. -These commands load and validate both files before running: +These commands load and validate all three files before running: - `narratio run` - `narratio plan` @@ -20,6 +21,8 @@ Behavior: - strict YAML decode is enabled (`KnownFields(true)`): unknown fields fail. - session templates render before session YAML decode. - defaults are applied for optional pipeline fields. +- campaign-level stable input paths fill missing session input paths. +- session-level stable input paths override campaign-level input paths. - validation enforces required fields, value formats, and cross-field constraints. ## 2. Config file discovery @@ -32,6 +35,15 @@ Pipeline config lookup for `run`, `plan`, `resume`, `run-stage`, and `restore`: 2. `/etc/narratio/pipeline.yml` - first existing file wins. +Campaign config lookup for `run`, `plan`, `resume`, `run-stage`, and `restore`: + +- if `--campaign ` is provided, that path is used. +- if omitted, Narratio searches in order: + 1. `./campaign.yml` + 2. `/usr/local/etc/narratio/campaign.yml` + 3. `/etc/narratio/campaign.yml` +- first existing file wins. + ## 3. Session file discovery and templating Session config lookup for `run`, `plan`, `resume`, `run-stage`, and `restore`: @@ -71,20 +83,28 @@ Why this is sufficient: ## 5. Minimal session template +`campaign.yml`: + ```yaml -session_id: "{{ session_id }}" campaign: sample-campaign +inputs: + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +``` + +`session.yml`: + +```yaml +session_id: "{{ session_id }}" inputs: audio_dir: ./audio - speakers_file: ./examples/speakers.yml - autocorrect_file: ./examples/autocorrect.yml - glossary_file: ./examples/glossary.yml ``` Usage: ```bash -narratio run --config /path/to/pipeline.yml --session ./session.yml --session-id 2026-05-03 +narratio run --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml --session-id 2026-05-03 ``` Previous-session-enabled variant: @@ -92,16 +112,12 @@ Previous-session-enabled variant: ```yaml session_id: "{{ session_id }}" previous_session_id: "{{ previous_session_id }}" -campaign: sample-campaign inputs: audio_dir: ./audio - speakers_file: ./examples/speakers.yml - autocorrect_file: ./examples/autocorrect.yml - glossary_file: ./examples/glossary.yml ``` ```bash -narratio run --config /path/to/pipeline.yml --session ./session.yml --session-id 2026-05-03 --previous-session-id 2026-04-26 +narratio run --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml --session-id 2026-05-03 --previous-session-id 2026-04-26 ``` ## 6. Production-oriented config @@ -158,7 +174,7 @@ Operational notes: - archive promotion is explicit and source-based via `archive.promote_artifacts`. - `source` is required; `dest` is optional and derived when omitted. - Narratio does not auto-promote all generated analyze artifacts. -- `restore` reads the same config/session inputs and restore scope is bounded by committed archive current state. +- `restore` reads the same config/campaign/session inputs and restore scope is bounded by committed archive current state. ## 7. Full pipeline reference @@ -299,21 +315,34 @@ Restore-related implications: - restore remote identity requires archive S3 identity to resolve (`pipeline.storage.s3.bucket` and session prefix derivation inputs). - restore scope considers committed current state and durable paths (`manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`, optional `audio/**`). -## 8. Full session reference +## 8. Full campaign reference + +| Path | Type | Required | Default | +| --- | --- | --- | --- | +| `campaign.campaign` | string | Yes | none | +| `campaign.inputs.speakers_file` | string | Yes | none | +| `campaign.inputs.autocorrect_file` | string | Yes | none | +| `campaign.inputs.glossary_file` | string | Yes | none | + +Campaign input paths may be absolute or relative. Relative paths resolve from the directory containing `campaign.yml`. + +## 9. Full session reference | Path | Type | Required | Default | | --- | --- | --- | --- | | `session.session_id` | string | Yes | none | | `session.previous_session_id` | string | No | empty | -| `session.campaign` | string | Yes | none | +| `session.campaign` | string | No | `campaign.campaign` | | `session.date` | string | No | empty | | `session.title` | string | No | empty | | `session.inputs.audio_dir` | string | Conditional | empty | | `session.inputs.audio_files[]` | list[string] | Conditional | empty | | `session.inputs.audio_s3.prefix` | string | Conditional | none | -| `session.inputs.speakers_file` | string | Yes | none | -| `session.inputs.autocorrect_file` | string | Yes | none | -| `session.inputs.glossary_file` | string | Yes | none | +| `session.inputs.speakers_file` | string | No | `campaign.inputs.speakers_file` | +| `session.inputs.autocorrect_file` | string | No | `campaign.inputs.autocorrect_file` | +| `session.inputs.glossary_file` | string | No | `campaign.inputs.glossary_file` | + +Session input paths may be absolute or relative. Relative audio paths and session-level stable input overrides resolve from the directory containing `session.yml`. If both `campaign.yml` and `session.yml` specify campaign identity, the values must match. Audio-source rule: @@ -328,7 +357,7 @@ Previous-session rule: - if `session.previous_session_id` is set, it must not equal `session.session_id`. - canonical previous-session sources (`narratio.previous_session.artifact.`) are hydrated during `prepare` from archive current state when required by enabled configured artifacts. -## 9. Secrets +## 10. Secrets Narratio supports filesystem-based secret injection via `pipeline.secrets.env_dir`. @@ -347,13 +376,14 @@ Guidance: - do not put secret values directly in YAML. - configure env var names in config and provide values via env/secrets files. -## 10. Examples +## 11. Examples Maintained examples: - `examples/pipeline.minimal.yml` - `examples/pipeline.production.yml` - `examples/pipeline.full.annotated.yml` +- `examples/campaign.yml` - `examples/session.template.yml` - `examples/session.local-audio.yml` - `examples/session.s3-audio.yml` diff --git a/docs/internal/stage-prepare.md b/docs/internal/stage-prepare.md index 51effcb..3ee8823 100644 --- a/docs/internal/stage-prepare.md +++ b/docs/internal/stage-prepare.md @@ -10,8 +10,8 @@ Prepare owns: ## Inputs and outputs Inputs: -- resolved config/session (`pipeline.yml`, `session.yml`); -- session-local input files (`speakers`, `autocorrect`, `glossary`); +- resolved config/campaign/session (`pipeline.yml`, `campaign.yml`, `session.yml`); +- campaign or session input files (`speakers`, `autocorrect`, `glossary`); - audio source: - local: `session.inputs.audio_dir` or `session.inputs.audio_files`; - S3: `session.inputs.audio_s3.prefix`; @@ -19,6 +19,7 @@ Inputs: - remote previous-session current archive state when previous hydration is required. Outputs: +- `inputs/campaign.yml`; - `inputs/session.yml`; - `inputs/pipeline.resolved.yml`; - `inputs/speakers.yml`; @@ -58,6 +59,10 @@ Does not own: - `pipeline.scriptorium.artifacts..enabled` - `pipeline.scriptorium.artifacts..inputs..source` - `pipeline.scriptorium.artifacts..inputs..required` +- `campaign.campaign` +- `campaign.inputs.speakers_file` +- `campaign.inputs.autocorrect_file` +- `campaign.inputs.glossary_file` ## External adapters used - `storage.ObjectStore` for: @@ -67,6 +72,8 @@ Does not own: ## State and manifest behavior - Ensures workspace layout exists. - Materializes canonical input files and audio files. +- Resolves campaign-provided stable input paths relative to `campaign.yml`. +- Resolves session-provided stable input overrides relative to `session.yml`. - Scans enabled configured artifact inputs for canonical sources: - `narratio.previous_session.artifact.` - If one or more canonical previous-session requirements exist: diff --git a/docs/operations.md b/docs/operations.md index f461ee5..14aeb6c 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -18,7 +18,7 @@ narratio run --session-id 2026-04-04 - use `manifest=` with `status` for inspection. Notes: -- default config/session discovery applies unless `--config` and `--session` are passed. +- default config/campaign/session discovery applies unless `--config`, `--campaign`, and `--session` are passed. - S3 audio mode requires `session.inputs.audio_s3.prefix` and valid object-store access. ## Restore workflow @@ -61,6 +61,7 @@ Primary state: - `manifest.json`: session-level stage state. - `runs/{run_id}/manifest.json`: invocation-level state. - `.lock`: session lock while a modifying command is active. +- `inputs/campaign.yml`, `inputs/session.yml`, and `inputs/pipeline.resolved.yml`: materialized config inputs for the run. Canonical session directories: - `inputs/` diff --git a/examples/campaign.yml b/examples/campaign.yml new file mode 100644 index 0000000..226624e --- /dev/null +++ b/examples/campaign.yml @@ -0,0 +1,5 @@ +campaign: sample-campaign +inputs: + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml diff --git a/examples/session.local-audio.yml b/examples/session.local-audio.yml index 56d6c2a..623eb0e 100644 --- a/examples/session.local-audio.yml +++ b/examples/session.local-audio.yml @@ -1,9 +1,5 @@ session_id: 2026-05-03 -campaign: sample-campaign date: 2026-05-03 title: Sample Session inputs: audio_dir: ./audio - speakers_file: ./examples/speakers.yml - autocorrect_file: ./examples/autocorrect.yml - glossary_file: ./examples/glossary.yml diff --git a/examples/session.s3-audio.yml b/examples/session.s3-audio.yml index 88286e1..9741eb0 100644 --- a/examples/session.s3-audio.yml +++ b/examples/session.s3-audio.yml @@ -1,10 +1,6 @@ session_id: 2026-05-03 -campaign: sample-campaign date: 2026-05-03 title: Sample Session inputs: audio_s3: prefix: audio/ - speakers_file: ./examples/speakers.yml - autocorrect_file: ./examples/autocorrect.yml - glossary_file: ./examples/glossary.yml diff --git a/examples/session.template.yml b/examples/session.template.yml index cd19f81..140e414 100644 --- a/examples/session.template.yml +++ b/examples/session.template.yml @@ -1,7 +1,3 @@ session_id: "{{ session_id }}" -campaign: sample-campaign inputs: audio_dir: ./audio - speakers_file: ./examples/speakers.yml - autocorrect_file: ./examples/autocorrect.yml - glossary_file: ./examples/glossary.yml diff --git a/internal/app/analyze_artifacts_commands_test.go b/internal/app/analyze_artifacts_commands_test.go index fb78803..fa63afc 100644 --- a/internal/app/analyze_artifacts_commands_test.go +++ b/internal/app/analyze_artifacts_commands_test.go @@ -14,12 +14,12 @@ import ( func TestExecuteRunStageArtifactsNonAnalyzeFails(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( - []string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "--artifacts", "session_recap", "polish"}, + []string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap", "polish"}, &stdout, &stderr, ) @@ -33,12 +33,12 @@ func TestExecuteRunStageArtifactsNonAnalyzeFails(t *testing.T) { func TestExecuteUnknownArtifactsFailValidation(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( - []string{"run", "--config", pipelinePath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, + []string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, &stdout, &stderr, ) @@ -52,7 +52,7 @@ func TestExecuteUnknownArtifactsFailValidation(t *testing.T) { func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} @@ -65,7 +65,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) { var out bytes.Buffer err := RunStage( context.Background(), - []string{"--config", pipelinePath, "--session", sessionPath, "--artifacts", "session_recap,session_recap", "analyze"}, + []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap", "analyze"}, &out, ) if err != nil { @@ -78,7 +78,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) { func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} @@ -93,7 +93,7 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { var out bytes.Buffer err := Resume( context.Background(), - []string{"--config", pipelinePath, "--session", sessionPath, "--artifacts", "session_recap"}, + []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, &out, ) if err != nil { @@ -104,10 +104,10 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { } } -func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string) { +func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string, string) { t.Helper() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) f, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0) if err != nil { t.Fatalf("open pipeline config for append: %v", err) @@ -136,5 +136,5 @@ scriptorium: if _, err := f.WriteString(extra); err != nil { t.Fatalf("append scriptorium config: %v", err) } - return pipelinePath, sessionPath + return pipelinePath, campaignPath, sessionPath } diff --git a/internal/app/campaign_config_path.go b/internal/app/campaign_config_path.go new file mode 100644 index 0000000..a11199a --- /dev/null +++ b/internal/app/campaign_config_path.go @@ -0,0 +1,49 @@ +package app + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +func resolveCampaignConfigPath(flagValue string) (string, error) { + return resolveCampaignConfigPathWithCandidates(flagValue, config.DefaultCampaignConfigSearchPaths) +} + +func resolveCampaignConfigPathWithCandidates(flagValue string, candidates []string) (string, error) { + if explicit := strings.TrimSpace(flagValue); explicit != "" { + return explicit, nil + } + + ordered := make([]string, 0, len(candidates)) + for _, raw := range candidates { + path := strings.TrimSpace(raw) + if path == "" { + continue + } + ordered = append(ordered, path) + info, err := os.Stat(path) + if err == nil { + if info.IsDir() { + continue + } + return filepath.Clean(path), nil + } + if errors.Is(err, os.ErrNotExist) { + continue + } + return "", fmt.Errorf("check default campaign config %q: %w", path, err) + } + + if len(ordered) == 0 { + return "", fmt.Errorf("no campaign config path provided and no default locations configured") + } + return "", fmt.Errorf( + "no campaign config path provided and no default campaign config found; searched: %s; pass --campaign to use an explicit path", + strings.Join(ordered, ", "), + ) +} diff --git a/internal/app/campaign_config_path_test.go b/internal/app/campaign_config_path_test.go new file mode 100644 index 0000000..50cca1d --- /dev/null +++ b/internal/app/campaign_config_path_test.go @@ -0,0 +1,49 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveCampaignConfigPathExplicitWins(t *testing.T) { + explicit := filepath.Join(t.TempDir(), "custom-campaign.yml") + got, err := resolveCampaignConfigPathWithCandidates(explicit, []string{filepath.Join(t.TempDir(), "campaign.yml")}) + if err != nil { + t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err) + } + if got != explicit { + t.Fatalf("path = %q, want explicit path %q", got, explicit) + } +} + +func TestResolveCampaignConfigPathUsesFirstExistingDefault(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "missing.yml") + found := filepath.Join(dir, "campaign.yml") + if err := os.WriteFile(found, []byte("campaign: sample-campaign\n"), 0o644); err != nil { + t.Fatalf("write campaign.yml: %v", err) + } + + got, err := resolveCampaignConfigPathWithCandidates("", []string{missing, found}) + if err != nil { + t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err) + } + if got != filepath.Clean(found) { + t.Fatalf("path = %q, want %q", got, filepath.Clean(found)) + } +} + +func TestResolveCampaignConfigPathErrorIncludesSearchedPaths(t *testing.T) { + _, err := resolveCampaignConfigPathWithCandidates("", []string{"./campaign.yml", "/usr/local/etc/narratio/campaign.yml", "/etc/narratio/campaign.yml"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "searched") { + t.Fatalf("error = %q, want searched paths", err.Error()) + } + if !strings.Contains(err.Error(), "pass --campaign") { + t.Fatalf("error = %q, want explicit-campaign guidance", err.Error()) + } +} diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 5500dc8..019aa3a 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -24,7 +24,7 @@ func TestExecuteValidCommands(t *testing.T) { })) defer srv.Close() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) manifestPath := writeManifestPathForExecute(t) cases := []struct { @@ -32,11 +32,11 @@ func TestExecuteValidCommands(t *testing.T) { args []string wantOut string }{ - {name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="}, - {name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"}, + {name: "run", args: []string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="}, + {name: "plan", args: []string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"}, {name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"}, - {name: "resume", args: []string{"resume", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, - {name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, + {name: "resume", args: []string{"resume", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, + {name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, } for _, tc := range cases { @@ -94,12 +94,12 @@ func TestExecuteMissingRequiredFlags(t *testing.T) { func TestExecuteRunStageUnknownFails(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "unknown"}, &stdout, &stderr) + code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "unknown"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -110,14 +110,14 @@ func TestExecuteRunStageUnknownFails(t *testing.T) { func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1}]}`) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "normalize"}, &stdout, &stderr) + code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "normalize"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -136,19 +136,19 @@ func TestExecuteRunStageTranscribeUsesConfiguredWhisperXServer(t *testing.T) { })) defer srv.Close() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "prepare"}, &stdout, &stderr) + code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "prepare"}, &stdout, &stderr) if code != 0 { t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String()) } stdout.Reset() stderr.Reset() - code = Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "--force", "transcribe"}, &stdout, &stderr) + code = Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "transcribe"}, &stdout, &stderr) if code != 0 { t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -188,6 +188,7 @@ func TestExecuteRunStagePolishLoadsCredentialFromSecretsDir(t *testing.T) { t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1") pipelinePath := filepath.Join(configDir, "pipeline.yml") + campaignPath := writeAppTestCampaignConfig(t, configDir) sessionPath := filepath.Join(configDir, "session.yml") pipelineYAML := `workspace: root: ` + workspaceRoot + ` @@ -239,7 +240,7 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "--force", "polish"}, &stdout, &stderr) + code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -252,6 +253,7 @@ func TestExecuteRunFailsWhenConfiguredSecretsDirMissing(t *testing.T) { workspaceRoot := t.TempDir() configDir := t.TempDir() pipelinePath := filepath.Join(configDir, "pipeline.yml") + campaignPath := writeAppTestCampaignConfig(t, configDir) sessionPath := filepath.Join(configDir, "session.yml") pipelineYAML := `workspace: @@ -288,7 +290,7 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -305,11 +307,14 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T) })) defer srv.Close() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) + originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...) config.DefaultPipelineConfigSearchPaths = []string{pipelinePath} + config.DefaultCampaignConfigSearchPaths = []string{campaignPath} defer func() { config.DefaultPipelineConfigSearchPaths = originalDefaults + config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults }() var stdout bytes.Buffer @@ -323,6 +328,33 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T) } } +func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot) + missingCampaignPath := filepath.Join(t.TempDir(), "campaign.yml") + originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...) + config.DefaultCampaignConfigSearchPaths = []string{missingCampaignPath} + defer func() { + config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults + }() + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"run", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "no campaign config path provided and no default campaign config found; searched:") { + t.Fatalf("stderr = %q, want campaign discovery failure", stderr.String()) + } + if !strings.Contains(stderr.String(), "pass --campaign") { + t.Fatalf("stderr = %q, want explicit campaign guidance", stderr.String()) + } +} + func TestExecuteInvalidCommand(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -359,11 +391,12 @@ func TestExecuteMissingCommand(t *testing.T) { } } -func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...string) (string, string) { +func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...string) (string, string, string) { t.Helper() dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") + campaignPath := filepath.Join(dir, "campaign.yml") sessionPath := filepath.Join(dir, "session.yml") url := "https://example.com/transcribe" if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" { @@ -407,9 +440,11 @@ notification: ` sessionYAML := `session_id: 2026-05-03 -campaign: sample-campaign inputs: audio_dir: ./audio +` + campaignYAML := `campaign: sample-campaign +inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml glossary_file: ./glossary.yml @@ -418,6 +453,9 @@ inputs: if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline config: %v", err) } + if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { + t.Fatalf("write campaign config: %v", err) + } if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session config: %v", err) } @@ -427,7 +465,22 @@ inputs: mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") - return pipelinePath, sessionPath + return pipelinePath, campaignPath, sessionPath +} + +func writeAppTestCampaignConfig(t *testing.T, dir string) string { + t.Helper() + campaignPath := filepath.Join(dir, "campaign.yml") + campaignYAML := `campaign: sample-campaign +inputs: + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { + t.Fatalf("write campaign.yml: %v", err) + } + return campaignPath } func writeManifestPathForExecute(t *testing.T) string { diff --git a/internal/app/plan.go b/internal/app/plan.go index 3be1bcb..0b7f1e2 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -20,11 +20,13 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { fs.SetOutput(io.Discard) var pipelinePath string + var campaignPath 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(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") @@ -40,12 +42,16 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { if err != nil { return fmt.Errorf("plan: %w", err) } + resolvedCampaignPath, err := resolveCampaignConfigPath(campaignPath) + if err != nil { + return fmt.Errorf("plan: %w", err) + } resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) if err != nil { return fmt.Errorf("plan: %w", err) } - cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ + cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, resolvedSessionPath, config.SessionLoadOptions{ SessionID: sessionID, PreviousSessionID: previousSessionID, }) diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index a2afe54..0d781f8 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -15,10 +15,10 @@ import ( func TestPlanCreatesAndReusesWorkdir(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var out bytes.Buffer - args := []string{"--config", pipelinePath, "--session", sessionPath} + args := []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath} if err := Plan(context.Background(), args, &out); err != nil { t.Fatalf("first Plan() error = %v", err) @@ -62,7 +62,7 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) { func TestPlanShowsRunAndSkipFromManifest(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} @@ -74,7 +74,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) { } var out bytes.Buffer - if err := Plan(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out); err != nil { + if err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out); err != nil { t.Fatalf("Plan() error = %v", err) } got := out.String() @@ -93,6 +93,7 @@ func TestPlanFailsWhenConfiguredSecretsDirMissing(t *testing.T) { workspaceRoot := t.TempDir() configDir := t.TempDir() pipelinePath := filepath.Join(configDir, "pipeline.yml") + campaignPath := writeAppTestCampaignConfig(t, configDir) sessionPath := filepath.Join(configDir, "session.yml") pipelineYAML := `workspace: @@ -128,7 +129,7 @@ inputs: } var out bytes.Buffer - err := Plan(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out) + err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) if err == nil { t.Fatal("expected error, got nil") } diff --git a/internal/app/restore.go b/internal/app/restore.go index 3668edc..0d1bbb2 100644 --- a/internal/app/restore.go +++ b/internal/app/restore.go @@ -26,6 +26,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { fs.SetOutput(out) var pipelinePath string + var campaignPath string var sessionPath string var sessionID string var previousSessionID string @@ -33,6 +34,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { var force bool var includeAudio bool fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") + fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") @@ -40,7 +42,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { 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 ] [--previous-session-id ] [--dry-run] [--force] [--include-audio]") + _, _ = fmt.Fprintln(out, "Usage: narratio restore [--config ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--dry-run] [--force] [--include-audio]") _, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out, "Flags:") fs.PrintDefaults() @@ -59,12 +61,16 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { if err != nil { return fmt.Errorf("restore: %w", err) } + resolvedCampaignPath, err := resolveCampaignConfigPath(campaignPath) + if err != nil { + return fmt.Errorf("restore: %w", err) + } resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) if err != nil { return fmt.Errorf("restore: %w", err) } - cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ + cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, resolvedSessionPath, config.SessionLoadOptions{ SessionID: sessionID, PreviousSessionID: previousSessionID, }) diff --git a/internal/app/restore_execution_test.go b/internal/app/restore_execution_test.go index 9cca175..a51b5fd 100644 --- a/internal/app/restore_execution_test.go +++ b/internal/app/restore_execution_test.go @@ -18,10 +18,10 @@ import ( func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} - cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) + cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`)) seedRestoreObject(fake, sessionPrefix+"artifacts/session_recap.md", []byte("# recap\n")) seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio")) @@ -32,7 +32,7 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -64,17 +64,17 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) { func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} - cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) + cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio")) restoreWithStoreAndRealPhases(t, fake) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -89,10 +89,10 @@ func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) { func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} - cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) + cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) seedRestoreObject(fake, sessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`)) seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n")) @@ -100,7 +100,7 @@ func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -116,10 +116,10 @@ func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) { func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} - cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) + cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript")) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) @@ -129,7 +129,7 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -148,10 +148,10 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) { func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} - cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) + cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript")) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) @@ -161,7 +161,7 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -174,10 +174,10 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) { func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} - cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) + cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# remote previous recap\n")) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) @@ -187,7 +187,7 @@ func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -196,10 +196,10 @@ func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) { func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) fake := &storage.FakeBackend{} - cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) + cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript")) store := artifacts.NewLocalStore(workspaceRoot) @@ -213,7 +213,7 @@ func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -229,10 +229,10 @@ func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) { func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) base := &storage.FakeBackend{} - cfg, sessionPrefix, manifestKey, _ := seedRestoreCommittedState(t, base, pipelinePath, sessionPath) + cfg, sessionPrefix, manifestKey, _ := seedRestoreCommittedState(t, base, pipelinePath, campaignPath, sessionPath) seedRestoreObject(base, sessionPrefix+"transcripts/full.json", []byte("remote-transcript")) toggled := &stagedManifestDownloadStore{ @@ -260,7 +260,7 @@ func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing. var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -344,9 +344,9 @@ func restoreWithStoreAndRealPhases(t *testing.T, objectStore storage.ObjectStore executeRestorePlanFn = executeRestorePlan } -func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipelinePath, sessionPath string) (*config.Config, string, string, string) { +func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipelinePath, campaignPath, sessionPath string) (*config.Config, string, string, string) { t.Helper() - cfg, err := config.LoadWithSessionOptions(pipelinePath, sessionPath, config.SessionLoadOptions{}) + cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{}) if err != nil { t.Fatalf("LoadWithSessionOptions() error = %v", err) } diff --git a/internal/app/restore_test.go b/internal/app/restore_test.go index e06987d..969db67 100644 --- a/internal/app/restore_test.go +++ b/internal/app/restore_test.go @@ -32,6 +32,9 @@ func TestExecuteRestoreHelp(t *testing.T) { if !strings.Contains(out, "--include-audio") { t.Fatalf("stdout = %q, want --include-audio flag", out) } + if !strings.Contains(out, "--campaign") { + t.Fatalf("stdout = %q, want --campaign flag", out) + } } func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { @@ -70,7 +73,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { } workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer @@ -78,6 +81,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { []string{ "restore", "--config", pipelinePath, + "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--dry-run", @@ -116,11 +120,11 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "extra"}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -138,11 +142,11 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) { }) workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -166,11 +170,11 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) { } workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -208,7 +212,7 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) { restoreEnv(secretKeyEnv) workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) secretsDir := filepath.Join(t.TempDir(), "secrets") mustWriteTestFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-access-key-id\n") mustWriteTestFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret-key\n") @@ -261,6 +265,7 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) { []string{ "restore", "--config", pipelinePath, + "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--dry-run", @@ -307,10 +312,10 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) { } workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -359,10 +364,10 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) { } workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -374,11 +379,12 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) { } } -func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) { +func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string, string) { t.Helper() dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") + campaignPath := writeAppTestCampaignConfig(t, dir) sessionPath := filepath.Join(dir, "session.yml") pipelineYAML := `workspace: @@ -405,5 +411,5 @@ inputs: mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") - return pipelinePath, sessionPath + return pipelinePath, campaignPath, sessionPath } diff --git a/internal/app/restore_workflow_test.go b/internal/app/restore_workflow_test.go index 12ce4cc..500999e 100644 --- a/internal/app/restore_workflow_test.go +++ b/internal/app/restore_workflow_test.go @@ -19,10 +19,10 @@ import ( func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) fake := &storage.FakeBackend{} - cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) + cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n")) seedRestoreObject(fake, manifestKey, restoreWorkflowManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)) seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`+"\n")) @@ -48,6 +48,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) { []string{ "restore", "--config", pipelinePath, + "--campaign", campaignPath, "--session", sessionPath, "--session-id", cfg.Session.SessionID, }, @@ -86,6 +87,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) { []string{ "run-stage", "--config", pipelinePath, + "--campaign", campaignPath, "--session", sessionPath, "--session-id", cfg.Session.SessionID, "--force", @@ -157,7 +159,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) { func TestRestoreThenAnalyzeUsesRestoredPreviousCacheWithoutObjectStore(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) appendRestoreWorkflowScriptoriumConfig(t, pipelinePath, ` scriptorium: binary: scriptorium @@ -176,7 +178,7 @@ scriptorium: `) fakeStore := &storage.FakeBackend{} - cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fakeStore, pipelinePath, sessionPath) + cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fakeStore, pipelinePath, campaignPath, sessionPath) seedRestoreObject(fakeStore, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n")) seedRestoreObject(fakeStore, manifestKey, restoreWorkflowManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)) seedRestoreObject(fakeStore, sessionPrefix+"transcripts/trimmed.json", []byte(`{"segments":[]}`+"\n")) @@ -191,6 +193,7 @@ scriptorium: []string{ "restore", "--config", pipelinePath, + "--campaign", campaignPath, "--session", sessionPath, "--session-id", cfg.Session.SessionID, }, @@ -235,6 +238,7 @@ scriptorium: []string{ "run-stage", "--config", pipelinePath, + "--campaign", campaignPath, "--session", sessionPath, "--session-id", cfg.Session.SessionID, "--force", diff --git a/internal/app/resume.go b/internal/app/resume.go index 8e8c367..e137181 100644 --- a/internal/app/resume.go +++ b/internal/app/resume.go @@ -17,12 +17,14 @@ func Resume(ctx context.Context, args []string, out io.Writer) error { fs.SetOutput(io.Discard) var pipelinePath string + var campaignPath 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(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") @@ -39,12 +41,16 @@ func Resume(ctx context.Context, args []string, out io.Writer) error { if err != nil { return fmt.Errorf("resume: %w", err) } + resolvedCampaignPath, err := resolveCampaignConfigPath(campaignPath) + if err != nil { + return fmt.Errorf("resume: %w", err) + } resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) if err != nil { return fmt.Errorf("resume: %w", err) } - cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ + cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, resolvedSessionPath, config.SessionLoadOptions{ SessionID: sessionID, PreviousSessionID: previousSessionID, }) diff --git a/internal/app/resume_run_stage_test.go b/internal/app/resume_run_stage_test.go index 04d3bd1..336599c 100644 --- a/internal/app/resume_run_stage_test.go +++ b/internal/app/resume_run_stage_test.go @@ -15,7 +15,7 @@ import ( func TestResumeStartsAfterCompletedStages(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} @@ -32,7 +32,7 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) { mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") var out bytes.Buffer - err := Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out) + err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("Resume() error = %v", err) } @@ -51,7 +51,7 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) { func TestResumeNoRemainingStages(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} @@ -64,7 +64,7 @@ func TestResumeNoRemainingStages(t *testing.T) { } var out bytes.Buffer - err := Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out) + err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("Resume() error = %v", err) } @@ -80,7 +80,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) { _, _ = w.Write([]byte(`{"source":"resume-force-test","segments":[{"speaker":"alice"}]}`)) })) defer srv.Close() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") store := &manifest.LocalStore{} @@ -93,7 +93,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) { } var out bytes.Buffer - err := Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--force"}, &out) + err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out) if err != nil { t.Fatalf("Resume() error = %v", err) } @@ -104,14 +104,14 @@ func TestResumeForceRerunsSucceeded(t *testing.T) { func TestRunStageExecutesOnlySelectedStage(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") var out bytes.Buffer - err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "polish"}, &out) + err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, &out) if err != nil { t.Fatalf("RunStage() error = %v", err) } @@ -134,7 +134,7 @@ func TestRunStageExecutesOnlySelectedStage(t *testing.T) { func TestRunStageSkipAndForce(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`) @@ -148,7 +148,7 @@ func TestRunStageSkipAndForce(t *testing.T) { } var out bytes.Buffer - err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "polish"}, &out) + err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, &out) if err != nil { t.Fatalf("RunStage() error = %v", err) } @@ -157,7 +157,7 @@ func TestRunStageSkipAndForce(t *testing.T) { } out.Reset() - err = RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--force", "polish"}, &out) + err = RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &out) if err != nil { t.Fatalf("RunStage(force) error = %v", err) } @@ -168,7 +168,7 @@ func TestRunStageSkipAndForce(t *testing.T) { func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`) @@ -184,7 +184,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing } var out bytes.Buffer - err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--force", "polish"}, &out) + err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &out) if err != nil { t.Fatalf("RunStage(force) error = %v", err) } @@ -203,7 +203,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing } out.Reset() - err = Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out) + err = Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("Resume() error = %v", err) } @@ -214,13 +214,13 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing func TestRunStageTrimExecutes(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`) var out bytes.Buffer - err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "trim"}, &out) + err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "trim"}, &out) if err != nil { t.Fatalf("RunStage(trim) error = %v", err) } @@ -243,13 +243,13 @@ func TestRunStageTrimExecutes(t *testing.T) { func TestRunStageNormalizeExecutes(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1},{"id":2}]}`) var out bytes.Buffer - err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "normalize"}, &out) + err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "normalize"}, &out) if err != nil { t.Fatalf("RunStage(normalize) error = %v", err) } diff --git a/internal/app/run.go b/internal/app/run.go index 9350037..2e0d2b5 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -15,12 +15,14 @@ func Run(ctx context.Context, args []string, out io.Writer) error { fs.SetOutput(io.Discard) var pipelinePath string + var campaignPath 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(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") @@ -37,12 +39,16 @@ func Run(ctx context.Context, args []string, out io.Writer) error { if err != nil { return fmt.Errorf("run: %w", err) } + resolvedCampaignPath, err := resolveCampaignConfigPath(campaignPath) + if err != nil { + return fmt.Errorf("run: %w", err) + } resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) if err != nil { return fmt.Errorf("run: %w", err) } - cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ + cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, resolvedSessionPath, config.SessionLoadOptions{ SessionID: sessionID, PreviousSessionID: previousSessionID, }) diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index 082dc0f..0e1e400 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -15,12 +15,14 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { fs.SetOutput(io.Discard) var pipelinePath string + var campaignPath 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(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") @@ -50,12 +52,16 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { if err != nil { return fmt.Errorf("run-stage: %w", err) } + resolvedCampaignPath, err := resolveCampaignConfigPath(campaignPath) + if err != nil { + return fmt.Errorf("run-stage: %w", err) + } resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) if err != nil { return fmt.Errorf("run-stage: %w", err) } - cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ + cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, resolvedSessionPath, config.SessionLoadOptions{ SessionID: sessionID, PreviousSessionID: previousSessionID, }) diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 0278138..b36b6da 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -994,10 +994,12 @@ func testConfig(t *testing.T) *config.Config { workspace := t.TempDir() cfgDir := t.TempDir() sessionPath := filepath.Join(cfgDir, "session.yml") + campaignPath := filepath.Join(cfgDir, "campaign.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml") mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") - mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n") + mustWriteFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n") + mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n") mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") @@ -1005,8 +1007,27 @@ func testConfig(t *testing.T) *config.Config { return &config.Config{ Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, + Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, PipelinePath: pipelinePath, + CampaignPath: campaignPath, SessionPath: sessionPath, + StableInputs: config.ResolvedStableInputs{ + SpeakersFile: config.ResolvedInputFile{ + Path: "./speakers.yml", + ConfigPath: campaignPath, + Source: "campaign_config", + }, + AutocorrectFile: config.ResolvedInputFile{ + Path: "./autocorrect.yml", + ConfigPath: campaignPath, + Source: "campaign_config", + }, + GlossaryFile: config.ResolvedInputFile{ + Path: "./glossary.yml", + ConfigPath: campaignPath, + Source: "campaign_config", + }, + }, Session: &config.SessionConfig{ SessionID: "2026-05-03", Campaign: "sample-campaign", @@ -1023,6 +1044,7 @@ func testConfig(t *testing.T) *config.Config { func TestBuildDefaultRunnersWithOmittedToolSections(t *testing.T) { dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") + campaignPath := filepath.Join(dir, "campaign.yml") sessionPath := filepath.Join(dir, "session.yml") pipelineYAML := `workspace: root: ` + t.TempDir() + ` @@ -1032,6 +1054,12 @@ analyzer: timeout: 20m notification: timeout: 10s +` + campaignYAML := `campaign: sample-campaign +inputs: + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml ` sessionYAML := `session_id: 2026-05-03 campaign: sample-campaign @@ -1042,6 +1070,7 @@ inputs: glossary_file: ./glossary.yml ` mustWriteFile(t, pipelinePath, pipelineYAML) + mustWriteFile(t, campaignPath, campaignYAML) mustWriteFile(t, sessionPath, sessionYAML) cfg, err := config.Load(pipelinePath, sessionPath) diff --git a/internal/app/session_cli_test.go b/internal/app/session_cli_test.go index 796b3df..b512e69 100644 --- a/internal/app/session_cli_test.go +++ b/internal/app/session_cli_test.go @@ -11,7 +11,7 @@ import ( func TestPlanUsesDiscoveredSessionTemplateWithSessionIDs(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot) sessionTemplate := `session_id: "{{ session_id }}" previous_session_id: "{{ previous_session_id }}" @@ -51,10 +51,10 @@ inputs: func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var out bytes.Buffer - err := Plan(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--session-id", "2026-04-04"}, &out) + err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-04-04"}, &out) if err == nil { t.Fatal("expected error, got nil") } @@ -65,7 +65,7 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) { func TestPlanFailsWhenPreviousSessionIDMismatchesConcreteSession(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) sessionYAML := `session_id: 2026-05-03 previous_session_id: 2026-04-26 @@ -83,6 +83,7 @@ inputs: var out bytes.Buffer err := Plan(context.Background(), []string{ "--config", pipelinePath, + "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--previous-session-id", "2026-04-25", @@ -97,10 +98,10 @@ inputs: func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) { workspaceRoot := t.TempDir() - pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var out bytes.Buffer - err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--session-id", "2026-05-03", "prepare"}, &out) + err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "prepare"}, &out) if err != nil { t.Fatalf("RunStage() error = %v", err) } diff --git a/internal/config/campaign_config_test.go b/internal/config/campaign_config_test.go new file mode 100644 index 0000000..1dfaac4 --- /dev/null +++ b/internal/config/campaign_config_test.go @@ -0,0 +1,138 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCampaignConfigDefaultSearchOrder(t *testing.T) { + want := []string{ + "./campaign.yml", + "/usr/local/etc/narratio/campaign.yml", + "/etc/narratio/campaign.yml", + } + if len(DefaultCampaignConfigSearchPaths) != len(want) { + t.Fatalf("DefaultCampaignConfigSearchPaths = %#v, want %#v", DefaultCampaignConfigSearchPaths, want) + } + for i := range want { + if DefaultCampaignConfigSearchPaths[i] != want[i] { + t.Fatalf("DefaultCampaignConfigSearchPaths[%d] = %q, want %q", i, DefaultCampaignConfigSearchPaths[i], want[i]) + } + } +} + +func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) { + pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, + "campaign: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", + "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", + ) + + _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err == nil { + t.Fatal("expected load error, got nil") + } + if !strings.Contains(err.Error(), "campaign file") || !strings.Contains(err.Error(), "strict decode failed") { + t.Fatalf("error = %q, want campaign strict decode context", err.Error()) + } +} + +func TestCampaignSessionMergeFillsStableInputs(t *testing.T) { + pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, + "campaign: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n", + "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", + ) + + cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err != nil { + t.Fatalf("LoadWithSessionOptions() error = %v", err) + } + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) + } + if cfg.Session.Campaign != "sample-campaign" { + t.Fatalf("session campaign = %q, want campaign config value", cfg.Session.Campaign) + } + assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config") + assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config") + assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config") +} + +func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) { + pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, + "campaign: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n", + "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n", + ) + + cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err != nil { + t.Fatalf("LoadWithSessionOptions() error = %v", err) + } + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() error = %v", err) + } + assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config") + assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config") + assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config") +} + +func TestCampaignSessionMismatchFails(t *testing.T) { + pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, + "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", + "session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n", + ) + + _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err == nil { + t.Fatal("expected load error, got nil") + } + if !strings.Contains(err.Error(), "does not match campaign config") { + t.Fatalf("error = %q, want campaign mismatch context", err.Error()) + } +} + +func TestLoadMissingCampaignFileFails(t *testing.T) { + pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t, + "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", + "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", + ) + missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml") + + _, err := LoadWithSessionOptions(pipelinePath, missingCampaignPath, sessionPath, SessionLoadOptions{}) + if err == nil { + t.Fatal("expected load error, got nil") + } + if !strings.Contains(err.Error(), "load campaign config") { + t.Fatalf("error = %q, want campaign load context", err.Error()) + } +} + +func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string) (string, string, string) { + t.Helper() + + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + campaignPath := filepath.Join(dir, "campaign.yml") + sessionPath := filepath.Join(dir, "session.yml") + + pipelineYAML := "workspace:\n root: " + filepath.ToSlash(filepath.Join(dir, "work")) + "\nwhisperx:\n transcribe_url: https://example.com/transcribe\nanalyzer:\n timeout: 20m\nnotification:\n timeout: 10s\n" + if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { + t.Fatalf("write pipeline.yml: %v", err) + } + if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { + t.Fatalf("write campaign.yml: %v", err) + } + if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { + t.Fatalf("write session.yml: %v", err) + } + + return pipelinePath, campaignPath, sessionPath +} + +func assertResolvedStableInput(t *testing.T, got ResolvedInputFile, wantPath, wantConfigPath, wantSource string) { + t.Helper() + if got.Path != wantPath || got.ConfigPath != wantConfigPath || got.Source != wantSource { + t.Fatalf("resolved input = %#v, want path=%q config_path=%q source=%q", got, wantPath, wantConfigPath, wantSource) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 9869f05..29586cb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,11 +1,16 @@ package config -// Config is the resolved combined configuration from pipeline.yml and session.yml. +// Config is the resolved combined configuration from pipeline.yml, +// campaign.yml, and session.yml. type Config struct { Pipeline *PipelineConfig + Campaign *CampaignConfig Session *SessionConfig PipelinePath string + CampaignPath string SessionPath string + + StableInputs ResolvedStableInputs } // PipelineConfig contains durable pipeline-level settings. @@ -25,6 +30,19 @@ type PipelineConfig struct { Notification NotificationConfig `yaml:"notification"` } +// CampaignConfig contains stable campaign-level identity and input defaults. +type CampaignConfig struct { + Campaign string `yaml:"campaign"` + Inputs CampaignInputsConfig `yaml:"inputs"` +} + +// CampaignInputsConfig contains stable campaign-level input file references. +type CampaignInputsConfig struct { + SpeakersFile string `yaml:"speakers_file"` + AutocorrectFile string `yaml:"autocorrect_file"` + GlossaryFile string `yaml:"glossary_file"` +} + // SessionConfig contains per-session inputs and metadata. type SessionConfig struct { SessionID string `yaml:"session_id"` @@ -229,3 +247,18 @@ type SessionInputsConfig struct { type SessionAudioS3Input struct { Prefix string `yaml:"prefix"` } + +// ResolvedStableInputs records where stable input file paths came from after +// campaign/session merge. +type ResolvedStableInputs struct { + SpeakersFile ResolvedInputFile + AutocorrectFile ResolvedInputFile + GlossaryFile ResolvedInputFile +} + +// ResolvedInputFile records one merged config path and its source config file. +type ResolvedInputFile struct { + Path string + ConfigPath string + Source string +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 51ff93c..86b413e 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -5,6 +5,9 @@ package config const ( DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml" DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml" + DefaultCampaignConfigPathLocal = "./campaign.yml" + DefaultCampaignConfigPathUsrLocal = "/usr/local/etc/narratio/campaign.yml" + DefaultCampaignConfigPathEtc = "/etc/narratio/campaign.yml" DefaultSessionConfigPathLocal = "./session.yml" DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml" DefaultSessionConfigPathEtc = "/etc/narratio/session.yml" @@ -88,6 +91,17 @@ var DefaultPipelineConfigSearchPaths = []string{ DefaultPipelineConfigPathEtc, } +// DefaultCampaignConfigSearchPaths defines the default search order for +// campaign.yml when callers do not provide an explicit path. +// +// Keep this in a variable so future defaults can be extended without changing +// call sites. +var DefaultCampaignConfigSearchPaths = []string{ + DefaultCampaignConfigPathLocal, + DefaultCampaignConfigPathUsrLocal, + DefaultCampaignConfigPathEtc, +} + // DefaultSessionConfigSearchPaths defines the default search order for // session.yml when callers do not provide an explicit path. // diff --git a/internal/config/load.go b/internal/config/load.go index 5611daa..20303b8 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -22,6 +22,15 @@ func LoadPipeline(path string) (*PipelineConfig, error) { return &cfg, nil } +// LoadCampaign loads campaign configuration from a YAML file with strict field checking. +func LoadCampaign(path string) (*CampaignConfig, error) { + var cfg CampaignConfig + if err := decodeStrictYAML("campaign", path, &cfg); err != nil { + return nil, fmt.Errorf("load campaign config: %w", err) + } + return &cfg, nil +} + // LoadSession loads session configuration from a YAML file with strict field checking. func LoadSession(path string) (*SessionConfig, error) { return LoadSessionWithOptions(path, SessionLoadOptions{}) @@ -71,32 +80,128 @@ func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfi return &cfg, nil } -// Load loads and resolves combined pipeline and session configuration. -func Load(pipelinePath, sessionPath string) (*Config, error) { - return LoadWithSessionOptions(pipelinePath, sessionPath, SessionLoadOptions{}) +// Load loads and resolves combined pipeline, campaign, and session configuration. +// Passing only a session path is supported for package-internal compatibility; +// in that form campaign.yml is expected next to the session file. +func Load(pipelinePath string, paths ...string) (*Config, error) { + campaignPath, sessionPath, err := campaignSessionPaths(paths...) + if err != nil { + return nil, err + } + return LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) } -// LoadWithSessionOptions loads and resolves combined pipeline and session -// configuration with session template options. -func LoadWithSessionOptions(pipelinePath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) { +// LoadWithSessionOptions loads and resolves combined pipeline, campaign, and +// session configuration with session template options. +func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) { pipelineCfg, err := LoadPipeline(pipelinePath) if err != nil { return nil, err } + campaignCfg, err := LoadCampaign(campaignPath) + if err != nil { + return nil, err + } + sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts) if err != nil { return nil, err } + stableInputs, err := mergeCampaignSession(campaignCfg, sessionCfg, campaignPath, sessionPath) + if err != nil { + return nil, err + } + return &Config{ Pipeline: pipelineCfg, + Campaign: campaignCfg, Session: sessionCfg, PipelinePath: pipelinePath, + CampaignPath: campaignPath, SessionPath: sessionPath, + StableInputs: stableInputs, }, nil } +func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) { + switch len(paths) { + case 1: + sessionPath = paths[0] + campaignPath = filepath.Join(filepath.Dir(sessionPath), "campaign.yml") + case 2: + campaignPath = paths[0] + sessionPath = paths[1] + default: + return "", "", fmt.Errorf("load config: expected session path or campaign and session paths") + } + return campaignPath, sessionPath, nil +} + +func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig, campaignPath, sessionPath string) (ResolvedStableInputs, error) { + if campaignCfg == nil { + return ResolvedStableInputs{}, fmt.Errorf("campaign config is required") + } + if sessionCfg == nil { + return ResolvedStableInputs{}, fmt.Errorf("session config is required") + } + + campaignName := strings.TrimSpace(campaignCfg.Campaign) + sessionCampaign := strings.TrimSpace(sessionCfg.Campaign) + if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName { + return ResolvedStableInputs{}, fmt.Errorf( + "campaign/session config invalid: session campaign %q does not match campaign config %q", + sessionCampaign, + campaignName, + ) + } + if sessionCampaign == "" { + sessionCfg.Campaign = campaignName + } + + stable := ResolvedStableInputs{ + SpeakersFile: selectStableInput( + campaignCfg.Inputs.SpeakersFile, + sessionCfg.Inputs.SpeakersFile, + campaignPath, + sessionPath, + ), + AutocorrectFile: selectStableInput( + campaignCfg.Inputs.AutocorrectFile, + sessionCfg.Inputs.AutocorrectFile, + campaignPath, + sessionPath, + ), + GlossaryFile: selectStableInput( + campaignCfg.Inputs.GlossaryFile, + sessionCfg.Inputs.GlossaryFile, + campaignPath, + sessionPath, + ), + } + + sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path + sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path + sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path + return stable, nil +} + +func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath string) ResolvedInputFile { + if strings.TrimSpace(sessionValue) != "" { + return ResolvedInputFile{ + Path: sessionValue, + ConfigPath: sessionPath, + Source: "session_config", + } + } + return ResolvedInputFile{ + Path: campaignValue, + ConfigPath: campaignPath, + Source: "campaign_config", + } +} + func decodeStrictYAML(kind, path string, out any) error { f, err := os.Open(path) if err != nil { diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 87f5de3..daed348 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -904,6 +904,7 @@ func TestValidateMissingAudioSource(t *testing.T) { Report: boolPtr(true), }, }, + Campaign: &CampaignConfig{Campaign: "sample-campaign"}, Session: &SessionConfig{ SessionID: "2026-05-03", Campaign: "sample-campaign", @@ -963,6 +964,7 @@ func TestExamplesLoadAndValidate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { pipelinePath := filepath.Join(examplesDir, tt.pipelineFile) + campaignPath := filepath.Join(examplesDir, "campaign.yml") sessionPath := filepath.Join(examplesDir, tt.sessionFile) var ( @@ -972,7 +974,7 @@ func TestExamplesLoadAndValidate(t *testing.T) { if strings.TrimSpace(tt.sessionOpts.SessionID) == "" { cfg, err = Load(pipelinePath, sessionPath) } else { - cfg, err = LoadWithSessionOptions(pipelinePath, sessionPath, tt.sessionOpts) + cfg, err = LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, tt.sessionOpts) } if err != nil { t.Fatalf("load example config error = %v", err) @@ -1001,14 +1003,34 @@ func writeConfigFiles(t *testing.T, pipelineYAML, sessionYAML string) (string, s dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") + campaignPath := filepath.Join(dir, "campaign.yml") sessionPath := filepath.Join(dir, "session.yml") if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) } + campaignYAML := `campaign: ` + campaignNameFromSessionYAML(sessionYAML) + ` +inputs: + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +` + if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { + t.Fatalf("write campaign.yml: %v", err) + } if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { t.Fatalf("write session.yml: %v", err) } return pipelinePath, sessionPath } + +func campaignNameFromSessionYAML(sessionYAML string) string { + for _, line := range strings.Split(sessionYAML, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "campaign:") { + return strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "campaign:")), `"'`) + } + } + return "sample-campaign" +} diff --git a/internal/config/validate.go b/internal/config/validate.go index 4bb5812..6aa1f9d 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -17,6 +17,9 @@ func Validate(cfg *Config) error { if cfg.Pipeline == nil { return fmt.Errorf("pipeline config is required") } + if cfg.Campaign == nil { + return fmt.Errorf("campaign config is required") + } if cfg.Session == nil { return fmt.Errorf("session config is required") } @@ -24,6 +27,9 @@ func Validate(cfg *Config) error { if err := validatePipeline(cfg.Pipeline); err != nil { return fmt.Errorf("pipeline config %q invalid: %w", shortName(cfg.PipelinePath, "pipeline.yml"), err) } + if err := validateCampaign(cfg.Campaign); err != nil { + return fmt.Errorf("campaign config %q invalid: %w", shortName(cfg.CampaignPath, "campaign.yml"), err) + } if err := validateSession(cfg.Session); err != nil { return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err) } @@ -34,6 +40,16 @@ func Validate(cfg *Config) error { return nil } +func validateCampaign(cfg *CampaignConfig) error { + if cfg == nil { + return fmt.Errorf("campaign config is required") + } + if strings.TrimSpace(cfg.Campaign) == "" { + return fmt.Errorf("campaign.campaign is required") + } + return nil +} + func validatePipeline(cfg *PipelineConfig) error { if strings.TrimSpace(cfg.Workspace.Root) == "" { return fmt.Errorf("pipeline.workspace.root is required") diff --git a/internal/stage/placeholders_test.go b/internal/stage/placeholders_test.go index 3908233..c9c7be6 100644 --- a/internal/stage/placeholders_test.go +++ b/internal/stage/placeholders_test.go @@ -30,8 +30,10 @@ func TestStagesReturnExpectedMetadata(t *testing.T) { store := artifacts.NewLocalStore(root) cfgDir := t.TempDir() sessionPath := filepath.Join(cfgDir, "session.yml") + campaignPath := filepath.Join(cfgDir, "campaign.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml") writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n") + writeStageTestFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n") writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n") writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") @@ -48,7 +50,9 @@ func TestStagesReturnExpectedMetadata(t *testing.T) { env := &Env{ Config: &config.Config{ SessionPath: sessionPath, + CampaignPath: campaignPath, PipelinePath: pipelinePath, + Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, Pipeline: &config.PipelineConfig{ Workspace: config.WorkspaceConfig{Root: root}, Storage: config.StorageConfig{ @@ -62,14 +66,28 @@ func TestStagesReturnExpectedMetadata(t *testing.T) { UploadRun: boolPtr(true), }, }, + StableInputs: config.ResolvedStableInputs{ + SpeakersFile: config.ResolvedInputFile{ + Path: "./speakers.yml", + ConfigPath: campaignPath, + Source: "campaign_config", + }, + AutocorrectFile: config.ResolvedInputFile{ + Path: "./autocorrect.yml", + ConfigPath: campaignPath, + Source: "campaign_config", + }, + GlossaryFile: config.ResolvedInputFile{ + Path: "./glossary.yml", + ConfigPath: campaignPath, + Source: "campaign_config", + }, + }, Session: &config.SessionConfig{ SessionID: "2026-05-03", Campaign: "sample-campaign", Inputs: config.SessionInputsConfig{ - AudioDir: "./audio", - SpeakersFile: "./speakers.yml", - AutocorrectFile: "./autocorrect.yml", - GlossaryFile: "./glossary.yml", + AudioDir: "./audio", }, }, }, diff --git a/internal/stage/prepare.go b/internal/stage/prepare.go index 45a4a8d..e08556d 100644 --- a/internal/stage/prepare.go +++ b/internal/stage/prepare.go @@ -25,6 +25,7 @@ func (prepareStage) Name() string { return "prepare" } func (prepareStage) Declares() IODecl { return IODecl{ Inputs: []artifacts.Ref{ + {Kind: "config", Category: "inputs", RelativePath: "campaign.yml"}, {Kind: "config", Category: "inputs", RelativePath: "session.yml"}, {Kind: "config", Category: "inputs", RelativePath: "pipeline.resolved.yml"}, {Kind: "config", Category: "inputs", RelativePath: "speakers.yml"}, @@ -59,21 +60,30 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S return nil, fmt.Errorf("prepare: ensure workdir layout: %w", err) } + campaignSrc := env.Config.CampaignPath + if err := requireFile(campaignSrc, "campaign.yml"); err != nil { + return nil, fmt.Errorf("prepare: %w", err) + } + sessionSrc := env.Config.SessionPath if err := requireFile(sessionSrc, "session.yml"); err != nil { return nil, fmt.Errorf("prepare: %w", err) } sessionDir := filepath.Dir(sessionSrc) - speakersSrc, err := resolvePath(sessionDir, env.Config.Session.Inputs.SpeakersFile) + speakersInput := stableInputSource(env.Config.StableInputs.SpeakersFile, env.Config.Session.Inputs.SpeakersFile, sessionSrc) + autocorrectInput := stableInputSource(env.Config.StableInputs.AutocorrectFile, env.Config.Session.Inputs.AutocorrectFile, sessionSrc) + glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc) + + speakersSrc, err := resolveConfigRelativePath(speakersInput) if err != nil { return nil, fmt.Errorf("prepare: speakers path: %w", err) } - autocorrectSrc, err := resolvePath(sessionDir, env.Config.Session.Inputs.AutocorrectFile) + autocorrectSrc, err := resolveConfigRelativePath(autocorrectInput) if err != nil { return nil, fmt.Errorf("prepare: autocorrect path: %w", err) } - glossarySrc, err := resolvePath(sessionDir, env.Config.Session.Inputs.GlossaryFile) + glossarySrc, err := resolveConfigRelativePath(glossaryInput) if err != nil { return nil, fmt.Errorf("prepare: glossary path: %w", err) } @@ -96,17 +106,27 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err) } - inputs := make([]manifest.InputRecord, 0, 5+len(resolvedLocalAudio)) + inputs := make([]manifest.InputRecord, 0, 6+len(resolvedLocalAudio)) registerInput := func(kind, path, checksum string) { inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum}) } + registerConfigInput := func(kind, path, checksum, source string) { + inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum, Source: source}) + } + + campaignDst := filepath.Join(paths.InputsDir, "campaign.yml") + campaignChecksum, err := copyFileIfChanged(env.ArtifactStore, campaignSrc, campaignDst) + if err != nil { + return nil, fmt.Errorf("prepare: materialize campaign.yml: %w", err) + } + registerConfigInput("campaign_config", campaignDst, campaignChecksum, "campaign_config") sessionDst := filepath.Join(paths.InputsDir, "session.yml") sessionChecksum, err := copyFileIfChanged(env.ArtifactStore, sessionSrc, sessionDst) if err != nil { return nil, fmt.Errorf("prepare: materialize session.yml: %w", err) } - registerInput("session_config", sessionDst, sessionChecksum) + registerConfigInput("session_config", sessionDst, sessionChecksum, "session_config") pipelineResolvedBytes, err := renderResolvedPipeline(env.Config.Pipeline) if err != nil { @@ -120,19 +140,20 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S registerInput("pipeline_resolved", pipelineDst, pipelineChecksum) for _, cfgFile := range []struct { - kind string - src string - dst string + kind string + src string + dst string + source string }{ - {kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml")}, - {kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml")}, - {kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml")}, + {kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source}, + {kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source}, + {kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source}, } { checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst) if err != nil { return nil, fmt.Errorf("prepare: materialize %s: %w", cfgFile.kind, err) } - registerInput(cfgFile.kind, cfgFile.dst, checksum) + registerConfigInput(cfgFile.kind, cfgFile.dst, checksum, cfgFile.source) } if useS3Audio { @@ -195,6 +216,31 @@ func renderResolvedPipeline(cfg *config.PipelineConfig) ([]byte, error) { return yaml.Marshal(cfg) } +func stableInputSource(resolved config.ResolvedInputFile, fallbackPath, fallbackConfigPath string) config.ResolvedInputFile { + if strings.TrimSpace(resolved.Path) != "" || strings.TrimSpace(resolved.ConfigPath) != "" || strings.TrimSpace(resolved.Source) != "" { + if strings.TrimSpace(resolved.ConfigPath) == "" { + resolved.ConfigPath = fallbackConfigPath + } + if strings.TrimSpace(resolved.Source) == "" { + resolved.Source = "session_config" + } + return resolved + } + return config.ResolvedInputFile{ + Path: fallbackPath, + ConfigPath: fallbackConfigPath, + Source: "session_config", + } +} + +func resolveConfigRelativePath(input config.ResolvedInputFile) (string, error) { + basePath := strings.TrimSpace(input.ConfigPath) + if basePath == "" { + return "", fmt.Errorf("source config path is required") + } + return resolvePath(filepath.Dir(basePath), input.Path) +} + func resolveAudioInputs(sessionDir string, inputs config.SessionInputsConfig) ([]string, bool, error) { hasLocal := strings.TrimSpace(inputs.AudioDir) != "" || len(inputs.AudioFiles) > 0 if inputs.AudioS3 != nil { diff --git a/internal/stage/prepare_test.go b/internal/stage/prepare_test.go index f5cef4b..f8a8114 100644 --- a/internal/stage/prepare_test.go +++ b/internal/stage/prepare_test.go @@ -35,6 +35,7 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) { paths := sessionPathsForEnv(env, m.SessionID) for _, p := range []string{ + filepath.Join(paths.InputsDir, "campaign.yml"), filepath.Join(paths.InputsDir, "session.yml"), filepath.Join(paths.InputsDir, "pipeline.resolved.yml"), filepath.Join(paths.InputsDir, "speakers.yml"), @@ -48,8 +49,8 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) { } } - if len(m.Inputs) != 7 { - t.Fatalf("manifest inputs len = %d, want 7", len(m.Inputs)) + if len(m.Inputs) != 8 { + t.Fatalf("manifest inputs len = %d, want 8", len(m.Inputs)) } for _, in := range m.Inputs { if in.Checksum == "" { @@ -473,8 +474,15 @@ func setupPrepareEnv(t *testing.T) (*Env, *manifest.Manifest) { sessionPath := filepath.Join(cfgDir, "session.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml") + campaignPath := filepath.Join(cfgDir, "campaign.yml") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") + writeFile(t, campaignPath, `campaign: sample-campaign +inputs: + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`) writeFile(t, sessionPath, "session_id: 2026-05-03\n") writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") @@ -482,16 +490,32 @@ func setupPrepareEnv(t *testing.T) (*Env, *manifest.Manifest) { cfg := &config.Config{ Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, + Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, SessionPath: sessionPath, + CampaignPath: campaignPath, PipelinePath: pipelinePath, Session: &config.SessionConfig{ SessionID: "2026-05-03", Campaign: "sample-campaign", Inputs: config.SessionInputsConfig{ - AudioDir: "./audio", - SpeakersFile: "./speakers.yml", - AutocorrectFile: "./autocorrect.yml", - GlossaryFile: "./glossary.yml", + AudioDir: "./audio", + }, + }, + StableInputs: config.ResolvedStableInputs{ + SpeakersFile: config.ResolvedInputFile{ + Path: "./speakers.yml", + ConfigPath: campaignPath, + Source: "campaign_config", + }, + AutocorrectFile: config.ResolvedInputFile{ + Path: "./autocorrect.yml", + ConfigPath: campaignPath, + Source: "campaign_config", + }, + GlossaryFile: config.ResolvedInputFile{ + Path: "./glossary.yml", + ConfigPath: campaignPath, + Source: "campaign_config", }, }, } diff --git a/internal/stage/transcribe_test.go b/internal/stage/transcribe_test.go index 50ed655..6f249c5 100644 --- a/internal/stage/transcribe_test.go +++ b/internal/stage/transcribe_test.go @@ -233,8 +233,10 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani sessionPath := filepath.Join(cfgDir, "session.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml") + campaignPath := filepath.Join(cfgDir, "campaign.yml") writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") + writeFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n") writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") @@ -243,7 +245,9 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani concurrency := 2 cfg := &config.Config{ PipelinePath: pipelinePath, + CampaignPath: campaignPath, SessionPath: sessionPath, + Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, Pipeline: &config.PipelineConfig{ Workspace: config.WorkspaceConfig{Root: workspace}, WhisperX: config.WhisperXConfig{ @@ -265,6 +269,11 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani GlossaryFile: "./glossary.yml", }, }, + StableInputs: config.ResolvedStableInputs{ + SpeakersFile: config.ResolvedInputFile{Path: "./speakers.yml", ConfigPath: campaignPath, Source: "campaign_config"}, + AutocorrectFile: config.ResolvedInputFile{Path: "./autocorrect.yml", ConfigPath: campaignPath, Source: "campaign_config"}, + GlossaryFile: config.ResolvedInputFile{Path: "./glossary.yml", ConfigPath: campaignPath, Source: "campaign_config"}, + }, } env := &Env{