diff --git a/docs/cli.md b/docs/cli.md index 624852d..09fc742 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,9 +6,9 @@ narratio run 2026-04-04 ``` -This command uses default system discovery for `pipeline.yml`, `campaign.yml`, and local `session.yml`. If local session discovery misses and S3 storage is configured, the positional session ID loads remote `session.yml` from the canonical session prefix. +This command uses default system discovery for `pipeline.yml`, the pipeline default campaign ID, and local `session.yml`. If local session discovery misses and S3 storage is configured, the positional session ID loads remote `session.yml` from the canonical session prefix. -Default discovery checks system config locations only. Pass `--config`, `--campaign`, and `--session` to use files from the current working directory. +Default pipeline and session discovery checks system config locations only. Pass `--config`, `--campaign-file`, and `--session` to use files from the current working directory. Pass `--campaign ` to select a campaign from `pipeline.campaigns.root`. Ordinary local and remote `session.yml` files must be concrete YAML. Templates belong to `narratio session init`, which renders a configured campaign template before writing the concrete file. @@ -45,7 +45,8 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle Most session-aware commands accept: - `--config `: optional explicit `pipeline.yml` path. -- `--campaign `: optional explicit `campaign.yml` path. +- `--campaign `: optional campaign ID selector. +- `--campaign-file `: optional explicit `campaign.yml` path. - `--session `: optional explicit concrete `session.yml` path. - `--previous-session-id `: expected previous session identifier. @@ -56,7 +57,7 @@ The positional `` is required even when `--session` is provided. It ### `run` ```bash -narratio run [--config ] [--campaign ] [--session ] [--previous-session-id ] [--force] [--artifacts ] +narratio run [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--force] [--artifacts ] ``` Purpose: @@ -66,7 +67,8 @@ Success output: - `narratio run: session ; executed= skipped=; manifest=` Common failure cases: -- missing system default config/campaign/session paths when flags are omitted. +- missing system default config/session paths when flags are omitted. +- missing selected campaign under `pipeline.campaigns.root`. - missing local session plus missing/unavailable remote `session.yml`. - templated `session.yml`; run `narratio session init` to generate concrete YAML. - concrete session identity mismatch. @@ -75,7 +77,7 @@ Common failure cases: ### `resume` ```bash -narratio resume [--config ] [--campaign ] [--session ] [--previous-session-id ] [--force] [--artifacts ] +narratio resume [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--force] [--artifacts ] ``` Purpose: @@ -88,7 +90,7 @@ Success output: ### `run-stage` ```bash -narratio run-stage [--config ] [--campaign ] [--session ] [--previous-session-id ] [--force] [--artifacts ] +narratio run-stage [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--force] [--artifacts ] ``` Valid stage names: @@ -111,7 +113,7 @@ Success output: ### `analyze` ```bash -narratio analyze [--config ] [--campaign ] [--session ] [--previous-session-id ] [--artifacts ] +narratio analyze [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--artifacts ] ``` Purpose: @@ -123,7 +125,7 @@ Purpose: ### `publish` ```bash -narratio publish [--config ] [--campaign ] [--session ] [--previous-session-id ] [--artifacts ] +narratio publish [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--artifacts ] ``` Purpose: @@ -135,7 +137,7 @@ Purpose: ### `clean` ```bash -narratio clean [--config ] [--campaign ] [--session ] [--previous-session-id ] [--dry-run] [--clear-cache] +narratio clean [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--dry-run] [--clear-cache] narratio clean --all [--config ] [--dry-run] [--clear-cache] ``` @@ -156,7 +158,7 @@ Cache behavior: ### `session plan` ```bash -narratio session plan [--config ] [--campaign ] [--session ] [--previous-session-id ] [--force] +narratio session plan [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--force] ``` Purpose: @@ -170,7 +172,7 @@ Success output includes: ### `session status` ```bash -narratio session status [--config ] [--campaign ] [--session ] [--previous-session-id ] +narratio session status [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] ``` Output includes: @@ -183,7 +185,7 @@ Output includes: ### `session validate` ```bash -narratio session validate [--config ] [--campaign ] [--session ] [--previous-session-id ] +narratio session validate [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] ``` Checks include: @@ -200,7 +202,8 @@ Warnings do not fail the command. Any `ERROR` finding exits non-zero. ```bash narratio session init --output ./session.yml narratio session init --remote -narratio session init --config --campaign --remote +narratio session init --config --campaign icewind --remote +narratio session init --config --campaign-file ./campaign.yml --remote ``` Additional flags: @@ -214,7 +217,9 @@ Additional flags: Behavior: - exactly one of `--output` or `--remote` is required. -- `--config` and `--campaign` are optional overrides; omitted values use normal default config discovery. +- `--config`, `--campaign`, and `--campaign-file` are optional overrides; omitted campaign selection uses `pipeline.campaigns.default_campaign_id`. +- `--campaign ` selects a campaign under `pipeline.campaigns.root`. +- `--campaign-file ` loads an explicit campaign file. - if `campaign.yml` sets `session_template_file`, the template path is resolved relative to `campaign.yml` and rendered from init flags. - if no session template is configured, a minimal concrete session file is generated directly. - template variables must be supplied by matching flags, and supplied template-related flags must be used by the template. @@ -225,7 +230,7 @@ Behavior: ### `session restore` ```bash -narratio session restore [--config ] [--campaign ] [--session ] [--previous-session-id ] [--dry-run] [--force] [--include-audio] +narratio session restore [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--dry-run] [--force] [--include-audio] ``` Purpose: @@ -241,7 +246,7 @@ When `--include-audio` is set, S3 audio files are restored through the shared au ### `session artifacts` ```bash -narratio session artifacts [--config ] [--campaign ] [--session ] [--previous-session-id ] [--remote] +narratio session artifacts [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--remote] ``` Purpose: @@ -252,9 +257,9 @@ Purpose: ### `session locks` ```bash -narratio session locks [--config ] [--campaign ] [--session ] [--previous-session-id ] -narratio session locks add [--config ] [--campaign ] [--session ] [--previous-session-id ] [--reason ] [--force] -narratio session locks remove [--config ] [--campaign ] [--session ] [--previous-session-id ] +narratio session locks [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] +narratio session locks add [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--reason ] [--force] +narratio session locks remove [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] ``` Behavior: diff --git a/docs/config.md b/docs/config.md index 495b39e..f0ad026 100644 --- a/docs/config.md +++ b/docs/config.md @@ -28,6 +28,7 @@ Behavior: - strict YAML decode is enabled (`KnownFields(true)`): unknown fields fail. - ordinary local and remote `session.yml` files must be concrete YAML; template placeholders are rejected. - defaults are applied for optional pipeline fields. +- campaign identity is selected by ID from the pipeline campaign registry unless `--campaign-file` is used. - campaign-level stable input paths fill missing session input paths. - session-level stable input paths override campaign-level input paths. - campaign config may point `session init` to a session template. @@ -60,11 +61,14 @@ Pipeline config lookup: Campaign config lookup: -- if `--campaign ` is provided, that path is used. -- if omitted, Narratio searches in order: - 1. `/usr/local/etc/narratio/campaign.yml` - 2. `/etc/narratio/campaign.yml` -- first existing file wins. +- pipeline config is loaded first. +- if `--campaign-file ` is provided, that path is used. +- otherwise, if `--campaign ` is provided, Narratio loads: + - `{pipeline.campaigns.root}/{id}/campaign.yml` +- otherwise, Narratio uses `pipeline.campaigns.default_campaign_id` and loads: + - `{pipeline.campaigns.root}/{default_campaign_id}/campaign.yml` +- `--campaign` and `--campaign-file` are mutually exclusive. +- campaign IDs must be single path segments, not paths. Session config lookup: @@ -76,7 +80,7 @@ Session config lookup: - if no local session file is found, a positional `` is present, storage is configured, and campaign identity is resolved, Narratio loads remote `session.yml` from: - `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml` - local discovery always runs before remote fallback. -- local files in the current working directory are used only when passed explicitly, for example `--config ./pipeline.yml --campaign ./campaign.yml --session ./session.yml`. +- local files in the current working directory are used only when passed explicitly, for example `--config ./pipeline.yml --campaign-file ./campaign.yml --session ./session.yml`. ## 3. Session templating @@ -109,6 +113,9 @@ Template behavior for `narratio session init`: ### `pipeline.yml` ```yaml +campaigns: + root: /usr/local/share/narratio/campaigns + default_campaign_id: sample-campaign whisperx: transcribe_url: "https://transcription.example.com/transcribe" ``` @@ -116,13 +123,14 @@ whisperx: Why this is sufficient: - `whisperx.transcribe_url` is required. +- `campaigns.default_campaign_id` selects the default campaign when `--campaign` is omitted. - `workspace.root` defaults to `/var/lib/narratio`. - optional sections (`seriatim`, `audita`, `archive`, `scriptorium`, `trim`, `normalize`, etc.) receive defaults or stay inactive. ### `campaign.yml` ```yaml -campaign: sample-campaign +campaign_id: sample-campaign session_template_file: ./session.template.yml inputs: speakers_file: ./speakers.yml @@ -132,7 +140,7 @@ inputs: Why this is sufficient: -- `campaign` supplies the stable campaign identity. +- `campaign_id` supplies the stable campaign identity. - stable input files are required and resolve relative to `campaign.yml` when copied during `prepare`. ### `session.yml` @@ -153,7 +161,8 @@ Why this is sufficient: Minimal local-file usage: ```bash -narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml +narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign sample-campaign --session ./session.yml +narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign-file ./campaign.yml --session ./session.yml ``` Previous-session-enabled variant: @@ -166,7 +175,7 @@ inputs: ``` ```bash -narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml --previous-session-id 2026-04-26 +narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign sample-campaign --session ./session.yml --previous-session-id 2026-04-26 ``` ## 5. Production-oriented config set @@ -187,6 +196,10 @@ storage: access_key_id_env: OBJECT_STORAGE_KEY_ID secret_access_key_env: OBJECT_STORAGE_KEY +campaigns: + root: /srv/narratio/campaigns + default_campaign_id: forsaken + spool: root: /var/spool/narratio delete_audio_after_archive: true @@ -230,7 +243,7 @@ scriptorium: ### `campaign.yml` ```yaml -campaign: forsaken +campaign_id: forsaken inputs: speakers_file: /srv/narratio/campaigns/forsaken/speakers.yml autocorrect_file: /srv/narratio/campaigns/forsaken/autocorrect.yml @@ -260,7 +273,7 @@ For S3-first operation, upload the same `session.yml` content to: Then run with explicit or discovered pipeline/campaign config and no `--session`: ```bash -narratio run 2026-05-03 --config /usr/local/etc/narratio/pipeline.yml --campaign /usr/local/etc/narratio/campaign.yml --previous-session-id 2026-04-26 +narratio run 2026-05-03 --config /usr/local/etc/narratio/pipeline.yml --campaign forsaken --previous-session-id 2026-04-26 ``` Operational notes: @@ -279,6 +292,8 @@ Operational notes: | --- | --- | --- | --- | | `pipeline.workspace.root` | string | No | `/var/lib/narratio` | | `pipeline.workspace.cleanup_after_archive` | bool | No | `false` | +| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` | +| `pipeline.campaigns.default_campaign_id` | string | No | empty | | `pipeline.secrets.env_dir` | string | Conditional | none | | `pipeline.storage.backend` | string | No | empty | | `pipeline.storage.s3.bucket` | string | Conditional | empty | @@ -434,7 +449,7 @@ Restore-related implications: | Path | Type | Required | Default | | --- | --- | --- | --- | -| `campaign.campaign` | string | Yes | none | +| `campaign.campaign_id` | string | Yes | none | | `campaign.session_template_file` | string | No | none | | `campaign.inputs.speakers_file` | string | Yes | none | | `campaign.inputs.autocorrect_file` | string | Yes | none | @@ -448,7 +463,7 @@ Campaign input paths and `campaign.session_template_file` may be absolute or rel | --- | --- | --- | --- | | `session.session_id` | string | Yes | none | | `session.previous_session_id` | string | No | empty | -| `session.campaign` | string | No | `campaign.campaign` | +| `session.campaign` | string | No | `campaign.campaign_id` | | `session.date` | string | No | empty | | `session.title` | string | No | empty | | `session.inputs.audio_dir` | string | Conditional | empty | @@ -499,8 +514,11 @@ Maintained examples: - `examples/pipeline.minimal.yml` - `examples/pipeline.production.yml` - `examples/pipeline.full.annotated.yml` -- `examples/campaign.yml` -- `examples/session.template.yml` +- `examples/campaigns/sample-campaign/campaign.yml` +- `examples/campaigns/sample-campaign/speakers.yml` +- `examples/campaigns/sample-campaign/autocorrect.yml` +- `examples/campaigns/sample-campaign/glossary.yml` +- `examples/campaigns/sample-campaign/session.template.yml` - `examples/session.local-audio.yml` - `examples/session.s3-audio.yml` diff --git a/docs/internal/command-restore.md b/docs/internal/command-restore.md index 364d105..2799da5 100644 --- a/docs/internal/command-restore.md +++ b/docs/internal/command-restore.md @@ -6,7 +6,7 @@ Define the implemented `narratio session restore` contract: committed remote-sta ## Inputs and outputs Inputs: - CLI syntax: `narratio session restore `. -- CLI flags: `--config`, `--campaign`, `--session`, `--previous-session-id`, `--dry-run`, `--force`, `--include-audio`. +- CLI flags: `--config`, `--campaign`, `--campaign-file`, `--session`, `--previous-session-id`, `--dry-run`, `--force`, `--include-audio`. - Resolved/validated `pipeline.yml` and `session.yml`. - Configured remote object store. - Remote committed current-state markers (`current/run_id.txt`, `current/manifest.json`). diff --git a/docs/internal/stage-prepare.md b/docs/internal/stage-prepare.md index 37940b8..4ba1b1e 100644 --- a/docs/internal/stage-prepare.md +++ b/docs/internal/stage-prepare.md @@ -62,7 +62,7 @@ Does not own: - `pipeline.scriptorium.artifacts..enabled` - `pipeline.scriptorium.artifacts..inputs..source` - `pipeline.scriptorium.artifacts..inputs..required` -- `campaign.campaign` +- `campaign.campaign_id` - `campaign.inputs.speakers_file` - `campaign.inputs.autocorrect_file` - `campaign.inputs.glossary_file` diff --git a/docs/operations.md b/docs/operations.md index 3e46f5e..2c4306d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -19,7 +19,7 @@ narratio run 2026-04-04 - use `narratio session status ` for inspection. Notes: -- default config/campaign/session discovery checks system config locations unless `--config`, `--campaign`, and `--session` are passed. +- default pipeline/session discovery checks system config locations; campaign selection uses `pipeline.campaigns.default_campaign_id` unless `--campaign ` or `--campaign-file ` is passed. - when local `session.yml` discovery misses, positional `` loads remote `session.yml` from `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. - S3 audio mode requires `session.inputs.audio_s3.prefix` and valid object-store access. @@ -29,7 +29,7 @@ Initialize a remote session skeleton: narratio session init 2026-04-04 --remote ``` -Remote init uses normal default config discovery and writes `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. If `campaign.yml` sets `session_template_file`, init renders that template from the supplied flags and writes concrete YAML. Pass `--config` and `--campaign` when testing non-system config files. It fails if the object already exists unless `--force` is passed. +Remote init uses normal default config discovery and writes `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. If `campaign.yml` sets `session_template_file`, init renders that template from the supplied flags and writes concrete YAML. Pass `--config`, `--campaign `, or `--campaign-file ` when testing non-system config files. It fails if the object already exists unless `--force` is passed. Validate before running: diff --git a/docs/roadmap/campaign.md b/docs/roadmap/campaign.md new file mode 100644 index 0000000..eb94f9f --- /dev/null +++ b/docs/roadmap/campaign.md @@ -0,0 +1,231 @@ +# Roadmap: Campaign Registry + +Status: Implemented + +## Problem + +Narratio currently treats campaign configuration as one selected +`campaign.yml` file: + +- command flags use `--campaign `; +- default discovery searches fixed system file locations; +- `campaign.yml` uses `campaign:` as the identity field. + +That model works for a single campaign, but it is awkward for installations +that manage multiple campaigns. Operators need to pass file paths or maintain a +single global campaign config, while the newer session-oriented CLI already +uses concise positional session IDs and remote session lookup. + +The campaign selection model should become ID-based and pipeline-owned. +Pipeline config should describe where campaigns live, commands should select a +campaign by ID, and each campaign directory should contain its stable campaign +materials. + +## Target Model + +`pipeline.yml` owns the campaign registry: + + campaigns: + root: /usr/local/share/narratio/campaigns + default_campaign_id: dilfs + +Campaign files live at the conventional path: + + {campaigns.root}/{campaign_id}/campaign.yml + +The first implementation should use only the conventional path. Recursive +discovery of every `campaign.yml` under `campaigns.root` is deferred to a +future stage. + +Each campaign file uses `campaign_id` as the canonical identity field: + + campaign_id: dilfs + session_template_file: ./session.template.yml + inputs: + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml + +Campaign-relative files continue to resolve relative to the selected +`campaign.yml`, including stable input files and `session_template_file`. + +The public CLI changes from path-based campaign selection to ID-based campaign +selection: + +- `--campaign ` selects a campaign ID. +- `--campaign-file ` explicitly loads one campaign file for + development, tests, and unusual local workflows. +- `--campaign` and `--campaign-file` are mutually exclusive. + +If neither `--campaign` nor `--campaign-file` is passed, Narratio uses +`pipeline.campaigns.default_campaign_id`. If no campaign can be selected, +commands fail clearly before session loading or stage execution. + +Resolved campaign ID remains the campaign segment used for: + +- workspace paths; +- spool paths; +- S3 session prefixes; +- remote `session.yml` lookup; +- archive locks and promoted output keys; +- session/campaign mismatch validation; +- status, plan, restore, and helper output. + +## Compatibility Policy + +This is a breaking public/config contract change. + +After the cutover: + +- `--campaign` no longer accepts a filesystem path; +- default fixed campaign file discovery is removed; +- `campaign:` is no longer accepted in `campaign.yml`; +- `campaign_id:` is required. + +Keep `--campaign-file` as the only explicit file override. Do not retain hidden +aliases for the old `--campaign ` behavior. + +## Implementation Stages + +### Stage 1: Add Campaign Registry Selection + +Status: Implemented + +Add the registry model and switch command loading to resolve campaigns through +pipeline config. + +Implementation requirements: + +- Add `pipeline.campaigns.root`. +- Add `pipeline.campaigns.default_campaign_id`. +- Add `campaign_id` to campaign config and make it the canonical identity. +- Resolve pipeline config first, then campaign selection. +- Use this selection order: + 1. explicit `--campaign-file `; + 2. explicit `--campaign `; + 3. `pipeline.campaigns.default_campaign_id`; + 4. fail clearly. +- For ID selection, load `{campaigns.root}/{campaign_id}/campaign.yml`. +- Validate that the loaded `campaign_id` matches the selected ID. +- Reject `--campaign` with `--campaign-file`. +- Preserve strict YAML decoding. +- Preserve campaign-relative stable input and session template resolution. +- Keep storage details behind the existing storage adapter and object-store + helper. +- Keep remote session lookup and archive key construction based on the + resolved campaign ID. + +Acceptance criteria: + +- Commands can run with only a pipeline config and the pipeline default + campaign ID. +- Commands can select another campaign with `--campaign `. +- Commands can load a specific file with `--campaign-file `. +- Existing session loading, remote session fallback, prepare materialization, + restore, archive, locks, clean, analyze, and publish behavior continue to use + the same resolved campaign identity. +- No generic config registry framework is introduced. + +### Stage 2: Remove Old Single-File Campaign Behavior + +Status: Implemented + +Remove the old public campaign file model after registry selection is in +place. + +Implementation requirements: + +- Remove fixed default campaign config discovery from command loading. +- Remove `DefaultCampaignConfigSearchPaths` and related path-only resolution if + no current tests or helpers still need them. +- Remove support for `campaign:` from `campaign.yml`. +- Update validation errors to refer to `campaign_id`. +- Update examples to use campaign directories and `campaign_id`. +- Update current-behavior docs to document: + - `pipeline.campaigns.root`; + - `pipeline.campaigns.default_campaign_id`; + - `campaign_id`; + - `--campaign `; + - `--campaign-file `. +- Update troubleshooting examples that currently pass `--campaign `. + +Acceptance criteria: + +- `campaign.yml` files with `campaign:` fail strict decoding. +- `--campaign /path/to/campaign.yml` is treated as a campaign ID and fails + unless that ID exists under `campaigns.root`. +- `--campaign-file /path/to/campaign.yml` is the supported file override. +- User-facing docs no longer describe fixed campaign config discovery. + +## Test Guidance + +Focused tests: + +- `go test ./internal/config -v` +- `go test ./internal/app -v` +- `go test ./internal/stage -run Prepare -v` + +Full validation: + +- `go test ./...` + +Config tests to add or update: + +- strict decode accepts `pipeline.campaigns.root`; +- strict decode accepts `pipeline.campaigns.default_campaign_id`; +- strict decode accepts `campaign_id`; +- selected campaign ID mismatch fails; +- missing campaign root fails when ID selection is needed; +- missing default campaign ID fails when no explicit campaign selector is + passed; +- old `campaign:` fails after Stage 2. + +App tests to add or update: + +- `--campaign ` resolves `{campaigns.root}/{id}/campaign.yml`; +- omitted `--campaign` uses `pipeline.campaigns.default_campaign_id`; +- `--campaign-file` loads an explicit campaign file; +- `--campaign` plus `--campaign-file` fails; +- remote session fallback uses the resolved campaign ID; +- `session init`, `run`, `run-stage`, `resume`, `analyze`, `publish`, `clean`, + and `session` subcommands all use the same campaign selection path; +- path-based `--campaign` examples and tests are removed after Stage 2. + +## Documentation Guidance + +Update current-behavior docs only after implementation lands: + +- `docs/config.md` +- `docs/cli.md` +- `docs/operations.md` +- `docs/troubleshooting.md` +- relevant files under `docs/internal/` +- `examples/` + +Planned campaign registry behavior belongs only in this roadmap until the code, +tests, examples, and current-behavior docs are updated. + +## Architecture Guardrails + +- Keep Narratio explicit and stage-driven. +- Do not introduce a generic configuration registry or workflow framework. +- Keep YAML decoding strict. +- Keep defaults centralized and testable. +- Keep campaign-relative path resolution centralized. +- Use centralized S3 and workspace path helpers. +- Keep storage details behind `storage.ObjectStore`. +- Keep secret-backed object-store construction in `internal/app`. +- Preserve manifest-driven resume and restore behavior. +- Do not store raw secrets in campaign configs, manifests, logs, generated + configs, or archive metadata. + +## Assumptions + +- The canonical pipeline schema is grouped under `campaigns`. +- The canonical campaign identity field is `campaign_id`. +- `--campaign` means campaign ID. +- `--campaign-file` is retained as an explicit override. +- Recursive discovery is planned but not part of the first implementation. +- Existing production configs can be migrated from `campaign:` to + `campaign_id:` and from `--campaign ` to `--campaign ` or + `--campaign-file `. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 953b9e9..4e4de1f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,20 +9,20 @@ Symptom: - `run`, `resume`, `run-stage`, `session plan`, or `session restore` fails with config/session not found. Likely Cause: -- `pipeline.yml`, `campaign.yml`, or `session.yml` is missing from system discovery paths. +- `pipeline.yml` or `session.yml` is missing from system discovery paths. +- the selected campaign ID does not exist under `pipeline.campaigns.root`. - a local working-directory config file was not passed explicitly. Diagnostics: ```bash ls -l /usr/local/etc/narratio/pipeline.yml /etc/narratio/pipeline.yml -ls -l /usr/local/etc/narratio/campaign.yml /etc/narratio/campaign.yml ls -l /usr/local/etc/narratio/session.yml /etc/narratio/session.yml ``` Safe Fix: -- pass explicit `--config`, `--campaign`, and `--session`. -- or place files in documented discovery paths. +- pass explicit `--config`, `--campaign `, `--campaign-file `, and `--session` as appropriate. +- or place files in documented discovery paths and set `pipeline.campaigns.default_campaign_id`. Links: - [docs/config.md](./config.md) @@ -40,7 +40,7 @@ Likely Cause: Diagnostics: ```bash -narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session ./session.yml +narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session ./session.yml ``` Safe Fix: @@ -62,7 +62,7 @@ Likely Cause: Diagnostics: ```bash -narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml +narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml ``` Safe Fix: @@ -84,7 +84,7 @@ Likely Cause: Diagnostics: ```bash -narratio run 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout +narratio run 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout ``` Safe Fix: @@ -106,7 +106,7 @@ Likely Cause: Diagnostics: ```bash -narratio run-stage polish 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --artifacts session_recap +narratio run-stage polish 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --artifacts session_recap ``` Safe Fix: @@ -129,7 +129,7 @@ Likely Cause: Diagnostics: ```bash -narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml +narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml ``` Safe Fix: @@ -154,7 +154,7 @@ Diagnostics: ```bash narratio session status 2026-04-04 -narratio run-stage analyze 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout +narratio run-stage analyze 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout ``` Safe Fix: @@ -226,7 +226,7 @@ Likely Cause: Diagnostics: ```bash -narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --dry-run +narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --dry-run ``` Safe Fix: @@ -249,7 +249,7 @@ Likely Cause: Diagnostics: ```bash -narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --dry-run +narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --dry-run ``` Safe Fix: @@ -271,7 +271,7 @@ Likely Cause: Diagnostics: ```bash -narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --dry-run +narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --dry-run ``` Safe Fix: @@ -341,7 +341,7 @@ Likely Cause: Diagnostics: ```bash -narratio run-stage prepare 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml +narratio run-stage prepare 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml ``` Safe Fix: @@ -365,7 +365,7 @@ Diagnostics: ```bash narratio session status 2026-04-04 -narratio run-stage archive 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml +narratio run-stage archive 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml ``` Safe Fix: diff --git a/examples/campaigns/sample-campaign/autocorrect.yml b/examples/campaigns/sample-campaign/autocorrect.yml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/examples/campaigns/sample-campaign/autocorrect.yml @@ -0,0 +1 @@ +[] diff --git a/examples/campaign.yml b/examples/campaigns/sample-campaign/campaign.yml similarity index 84% rename from examples/campaign.yml rename to examples/campaigns/sample-campaign/campaign.yml index 148c663..58725d2 100644 --- a/examples/campaign.yml +++ b/examples/campaigns/sample-campaign/campaign.yml @@ -1,4 +1,4 @@ -campaign: sample-campaign +campaign_id: sample-campaign session_template_file: ./session.template.yml inputs: speakers_file: ./speakers.yml diff --git a/examples/campaigns/sample-campaign/glossary.yml b/examples/campaigns/sample-campaign/glossary.yml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/examples/campaigns/sample-campaign/glossary.yml @@ -0,0 +1 @@ +[] diff --git a/examples/campaigns/sample-campaign/session.template.yml b/examples/campaigns/sample-campaign/session.template.yml new file mode 100644 index 0000000..140e414 --- /dev/null +++ b/examples/campaigns/sample-campaign/session.template.yml @@ -0,0 +1,3 @@ +session_id: "{{ session_id }}" +inputs: + audio_dir: ./audio diff --git a/examples/campaigns/sample-campaign/speakers.yml b/examples/campaigns/sample-campaign/speakers.yml new file mode 100644 index 0000000..7357b02 --- /dev/null +++ b/examples/campaigns/sample-campaign/speakers.yml @@ -0,0 +1,5 @@ +match: + - speaker: "Eric Rakestraw" + match: + - "Eric_Rakestraw" + - "Eric" diff --git a/examples/pipeline.full.annotated.yml b/examples/pipeline.full.annotated.yml index e3f2827..7b89d7b 100644 --- a/examples/pipeline.full.annotated.yml +++ b/examples/pipeline.full.annotated.yml @@ -27,6 +27,12 @@ storage: access_key_id_env: OBJECT_STORAGE_KEY_ID secret_access_key_env: OBJECT_STORAGE_KEY +campaigns: + # Optional; defaults to /usr/local/share/narratio/campaigns. + root: /usr/local/share/narratio/campaigns + # Optional command default when --campaign is omitted. + default_campaign_id: sample-campaign + spool: # Optional; defaults to /var/spool/narratio. root: /var/spool/narratio diff --git a/examples/pipeline.minimal.yml b/examples/pipeline.minimal.yml index e9a7e17..fc47e33 100644 --- a/examples/pipeline.minimal.yml +++ b/examples/pipeline.minimal.yml @@ -1,2 +1,6 @@ +campaigns: + root: /usr/local/share/narratio/campaigns + default_campaign_id: sample-campaign + whisperx: transcribe_url: "https://transcription.example.com/transcribe" diff --git a/examples/pipeline.production.yml b/examples/pipeline.production.yml index b6bc4f4..4a8e6d5 100644 --- a/examples/pipeline.production.yml +++ b/examples/pipeline.production.yml @@ -11,6 +11,10 @@ storage: access_key_id_env: OBJECT_STORAGE_KEY_ID secret_access_key_env: OBJECT_STORAGE_KEY +campaigns: + root: /usr/local/share/narratio/campaigns + default_campaign_id: sample-campaign + spool: root: /var/spool/narratio delete_audio_after_archive: true diff --git a/internal/app/analyze_artifacts_commands_test.go b/internal/app/analyze_artifacts_commands_test.go index 3aef53b..07480d8 100644 --- a/internal/app/analyze_artifacts_commands_test.go +++ b/internal/app/analyze_artifacts_commands_test.go @@ -21,7 +21,7 @@ func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( - []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, + []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, &stdout, &stderr, ) @@ -57,7 +57,7 @@ func TestExecuteRunStageArchivePropagatesSelectedArtifacts(t *testing.T) { []string{ "run-stage", "archive", "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap", }, @@ -82,7 +82,7 @@ func TestExecuteUnknownArtifactsFailValidation(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( - []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, + []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, &stdout, &stderr, ) @@ -109,7 +109,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) { var out bytes.Buffer err := RunStage( context.Background(), - []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap"}, + []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap"}, &out, ) if err != nil { @@ -137,7 +137,7 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { var out bytes.Buffer err := Resume( context.Background(), - []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, + []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, &out, ) if err != nil { @@ -172,7 +172,7 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( - []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, + []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr, ) @@ -211,7 +211,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) { "analyze", "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "player_handout,session_recap", }, @@ -233,7 +233,7 @@ func TestExecuteAnalyzeUnknownArtifactFailsValidation(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( - []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, + []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, &stdout, &stderr, ) @@ -307,7 +307,7 @@ func TestExecutePublishForceRunsArchive(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( - []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, + []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, &stdout, &stderr, ) @@ -359,7 +359,7 @@ func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer code := Execute( - []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, + []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, &stdout, &stderr, ) diff --git a/internal/app/campaign_config_path.go b/internal/app/campaign_config_path.go index a11199a..d5b0278 100644 --- a/internal/app/campaign_config_path.go +++ b/internal/app/campaign_config_path.go @@ -1,49 +1,44 @@ 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 resolveCampaignConfigPath(pipelineCfg *config.PipelineConfig, campaignIDFlag, campaignFileFlag string) (string, error) { + campaignID := strings.TrimSpace(campaignIDFlag) + campaignFile := strings.TrimSpace(campaignFileFlag) + if campaignID != "" && campaignFile != "" { + return "", fmt.Errorf("--campaign and --campaign-file are mutually exclusive") + } + if campaignFile != "" { + return filepath.Clean(campaignFile), nil + } + if campaignID == "" && pipelineCfg != nil { + campaignID = strings.TrimSpace(pipelineCfg.Campaigns.DefaultCampaignID) + } + if campaignID == "" { + return "", fmt.Errorf("no campaign selected; pass --campaign or set pipeline.campaigns.default_campaign_id") + } + if err := validateCampaignIDToken(campaignID); err != nil { + return "", err + } + if pipelineCfg == nil || strings.TrimSpace(pipelineCfg.Campaigns.Root) == "" { + return "", fmt.Errorf("pipeline.campaigns.root is required to select campaign %q", campaignID) + } + return filepath.Clean(filepath.Join(pipelineCfg.Campaigns.Root, campaignID, "campaign.yml")), nil } -func resolveCampaignConfigPathWithCandidates(flagValue string, candidates []string) (string, error) { - if explicit := strings.TrimSpace(flagValue); explicit != "" { - return explicit, nil +func validateCampaignIDToken(campaignID string) error { + if filepath.IsAbs(campaignID) || + strings.Contains(campaignID, "/") || + strings.Contains(campaignID, `\`) || + campaignID == "." || + campaignID == ".." { + return fmt.Errorf("campaign id %q must be a single path segment", campaignID) } - - 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, ", "), - ) + return nil } diff --git a/internal/app/campaign_config_path_test.go b/internal/app/campaign_config_path_test.go index fca8458..ae6165d 100644 --- a/internal/app/campaign_config_path_test.go +++ b/internal/app/campaign_config_path_test.go @@ -1,49 +1,84 @@ package app import ( - "os" "path/filepath" "strings" "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/config" ) -func TestResolveCampaignConfigPathExplicitWins(t *testing.T) { +func TestResolveCampaignConfigPathCampaignFileWins(t *testing.T) { explicit := filepath.Join(t.TempDir(), "custom-campaign.yml") - got, err := resolveCampaignConfigPathWithCandidates(explicit, []string{filepath.Join(t.TempDir(), "campaign.yml")}) + got, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "", explicit) if err != nil { - t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err) + t.Fatalf("resolveCampaignConfigPath() error = %v", err) } if got != explicit { t.Fatalf("path = %q, want explicit path %q", got, explicit) } } -func TestResolveCampaignConfigPathUsesFirstExistingDefault(t *testing.T) { +func TestResolveCampaignConfigPathUsesSelectedCampaignID(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) - } + pipelineCfg := &config.PipelineConfig{} + pipelineCfg.Campaigns.Root = dir - got, err := resolveCampaignConfigPathWithCandidates("", []string{missing, found}) + got, err := resolveCampaignConfigPath(pipelineCfg, "icewind", "") if err != nil { - t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err) + t.Fatalf("resolveCampaignConfigPath() error = %v", err) } - if got != filepath.Clean(found) { - t.Fatalf("path = %q, want %q", got, filepath.Clean(found)) + want := filepath.Join(dir, "icewind", "campaign.yml") + if got != filepath.Clean(want) { + t.Fatalf("path = %q, want %q", got, filepath.Clean(want)) } } -func TestResolveCampaignConfigPathErrorIncludesSearchedPaths(t *testing.T) { - _, err := resolveCampaignConfigPathWithCandidates("", []string{"/usr/local/etc/narratio/campaign.yml", "/etc/narratio/campaign.yml"}) +func TestResolveCampaignConfigPathUsesDefaultCampaignID(t *testing.T) { + dir := t.TempDir() + pipelineCfg := &config.PipelineConfig{} + pipelineCfg.Campaigns.Root = dir + pipelineCfg.Campaigns.DefaultCampaignID = "dilfs" + + got, err := resolveCampaignConfigPath(pipelineCfg, "", "") + if err != nil { + t.Fatalf("resolveCampaignConfigPath() error = %v", err) + } + want := filepath.Join(dir, "dilfs", "campaign.yml") + if got != filepath.Clean(want) { + t.Fatalf("path = %q, want %q", got, filepath.Clean(want)) + } +} + +func TestResolveCampaignConfigPathRejectsCampaignIDAndFile(t *testing.T) { + _, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "dilfs", filepath.Join(t.TempDir(), "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()) + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("error = %q, want mutual exclusion", err.Error()) + } +} + +func TestResolveCampaignConfigPathRequiresCampaignSelection(t *testing.T) { + _, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "", "") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "no campaign selected") { + t.Fatalf("error = %q, want missing selection guidance", err.Error()) + } +} + +func TestResolveCampaignConfigPathRejectsPathLikeCampaignID(t *testing.T) { + pipelineCfg := &config.PipelineConfig{} + pipelineCfg.Campaigns.Root = t.TempDir() + + _, err := resolveCampaignConfigPath(pipelineCfg, "../icewind", "") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "single path segment") { + t.Fatalf("error = %q, want path segment guidance", err.Error()) } } diff --git a/internal/app/clean.go b/internal/app/clean.go index e97d0fc..9051a82 100644 --- a/internal/app/clean.go +++ b/internal/app/clean.go @@ -52,7 +52,7 @@ func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCac if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("clean: session_id is required unless --all is set") } - cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions()) + cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return fmt.Errorf("clean: %w", err) } @@ -92,10 +92,11 @@ func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCac func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error { if strings.TrimSpace(flags.campaignPath) != "" || + strings.TrimSpace(flags.campaignFilePath) != "" || strings.TrimSpace(flags.sessionPath) != "" || strings.TrimSpace(flags.sessionID) != "" || strings.TrimSpace(flags.previousSessionID) != "" { - return fmt.Errorf("clean: --all cannot be combined with --campaign, --session, a session_id, or --previous-session-id") + return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id") } resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath) if err != nil { diff --git a/internal/app/clean_test.go b/internal/app/clean_test.go index e32f389..9ff73ec 100644 --- a/internal/app/clean_test.go +++ b/internal/app/clean_test.go @@ -26,7 +26,7 @@ func TestExecuteCleanSessionDeletesWorkAndSpoolButPreservesCache(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -48,7 +48,7 @@ func TestExecuteCleanSessionDryRunDeletesNothing(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr) + code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -65,7 +65,7 @@ func TestExecuteCleanMissingSessionPathsSucceeds(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -105,7 +105,7 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr) + code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -122,7 +122,7 @@ func TestExecuteCleanLocalAudioClearCacheIsNoop(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr) + code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -184,7 +184,7 @@ func TestExecuteCleanAllRejectsSessionScopedFlags(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--all"}, &stdout, &stderr) + code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--all"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 14712f3..bba4bf2 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -31,11 +31,11 @@ func TestExecuteValidCommands(t *testing.T) { args []string wantOut string }{ - {name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="}, - {name: "session plan", args: []string{"session", "plan", "2026-05-03", "--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: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"}, - {name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, - {name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, + {name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="}, + {name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"}, + {name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"}, + {name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, + {name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, } for _, tc := range cases { @@ -98,7 +98,7 @@ func TestExecuteRunStageUnknownFails(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run-stage", "unknown", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"run-stage", "unknown", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -116,7 +116,7 @@ func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run-stage", "normalize", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"run-stage", "normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -140,14 +140,14 @@ func TestExecuteRunStageTranscribeUsesConfiguredWhisperXServer(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run-stage", "prepare", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"run-stage", "prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &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", "transcribe", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code = Execute([]string{"run-stage", "transcribe", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -237,7 +237,7 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run-stage", "polish", sessionID, "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code := Execute([]string{"run-stage", "polish", sessionID, "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -285,7 +285,7 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -304,13 +304,11 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T) 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 }() + _ = campaignPath var stdout bytes.Buffer var stderr bytes.Buffer @@ -323,15 +321,12 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T) } } -func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) { +func TestExecuteMissingCampaignConfigReportsRegistryPath(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 - }() + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + if err := os.Remove(campaignPath); err != nil { + t.Fatalf("remove campaign config: %v", err) + } var stdout bytes.Buffer var stderr bytes.Buffer @@ -342,11 +337,67 @@ func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) { 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:") { + if !strings.Contains(stderr.String(), "load campaign config") { 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()) + if !strings.Contains(stderr.String(), filepath.ToSlash(filepath.Join("campaigns", "sample-campaign", "campaign.yml"))) { + t.Fatalf("stderr = %q, want campaign registry path", stderr.String()) + } +} + +func TestExecuteUsesPipelineDefaultCampaignID(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Campaign: sample-campaign") { + t.Fatalf("stdout = %q, want default campaign", stdout.String()) + } +} + +func TestExecuteCampaignIDSelectsRegistryCampaign(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + campaignRoot := filepath.Dir(filepath.Dir(campaignPath)) + otherDir := filepath.Join(campaignRoot, "icewind") + mustWriteTestFile(t, filepath.Join(otherDir, "campaign.yml"), `campaign_id: icewind +inputs: + speakers_file: ./speakers.yml + autocorrect_file: ./autocorrect.yml + glossary_file: ./glossary.yml +`) + mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") + mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n") + mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n") + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", "icewind", "--session", sessionPath}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "Campaign: icewind") { + t.Fatalf("stdout = %q, want selected campaign", stdout.String()) + } +} + +func TestExecuteRejectsCampaignIDAndCampaignFile(t *testing.T) { + workspaceRoot := t.TempDir() + pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) + if code == 0 { + t.Fatal("exit code = 0, want non-zero") + } + if !strings.Contains(stderr.String(), "mutually exclusive") { + t.Fatalf("stderr = %q, want mutually exclusive error", stderr.String()) } } @@ -391,7 +442,9 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ... dir := t.TempDir() pipelinePath := filepath.Join(dir, "pipeline.yml") - campaignPath := filepath.Join(dir, "campaign.yml") + campaignRoot := filepath.Join(dir, "campaigns") + campaignDir := filepath.Join(campaignRoot, "sample-campaign") + campaignPath := filepath.Join(campaignDir, "campaign.yml") sessionPath := filepath.Join(dir, "session.yml") url := "https://example.com/transcribe" if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" { @@ -405,6 +458,9 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ... pipelineYAML := `workspace: root: ` + workspaceRoot + ` +campaigns: + root: ` + campaignRoot + ` + default_campaign_id: sample-campaign cache: root: ` + filepath.Join(workspaceRoot, "cache") + ` spool: @@ -438,7 +494,7 @@ notification: inputs: audio_dir: ./audio ` - campaignYAML := `campaign: sample-campaign + campaignYAML := `campaign_id: sample-campaign inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml @@ -448,6 +504,9 @@ inputs: if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline config: %v", err) } + if err := os.MkdirAll(campaignDir, 0o755); err != nil { + t.Fatalf("create campaign dir: %v", err) + } if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { t.Fatalf("write campaign config: %v", err) } @@ -455,9 +514,9 @@ inputs: t.Fatalf("write session config: %v", err) } - mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") - mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n") - mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") + mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") + mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n") + mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") return pipelinePath, campaignPath, sessionPath @@ -466,7 +525,7 @@ inputs: func writeAppTestCampaignConfig(t *testing.T, dir string) string { t.Helper() campaignPath := filepath.Join(dir, "campaign.yml") - campaignYAML := `campaign: sample-campaign + campaignYAML := `campaign_id: sample-campaign inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml diff --git a/internal/app/config_loader.go b/internal/app/config_loader.go index a747860..f172dfc 100644 --- a/internal/app/config_loader.go +++ b/internal/app/config_loader.go @@ -19,8 +19,8 @@ type pipelineCampaignConfig struct { Campaign *config.CampaignConfig } -func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) { - base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag) +func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) { + base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag) if err != nil { return nil, err } @@ -42,7 +42,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF return nil, missingSessionConfigError(discoveredSession.Searched, "remote session loading requires a session_id") } - sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, base.Campaign.Campaign, sessionID) + sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID) remoteKey := artifacts.S3SessionConfigKey(sessionPrefix) partialCfg := &config.Config{ Pipeline: base.Pipeline, @@ -91,23 +91,28 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF ) } -func loadPipelineCampaignConfig(pipelineFlag, campaignFlag string) (*pipelineCampaignConfig, error) { +func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) { resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag) if err != nil { return nil, err } - resolvedCampaignPath, err := resolveCampaignConfigPath(campaignFlag) - if err != nil { - return nil, err - } pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath) if err != nil { return nil, err } + resolvedCampaignPath, err := resolveCampaignConfigPath(pipelineCfg, campaignFlag, campaignFileFlag) + if err != nil { + return nil, err + } campaignCfg, err := config.LoadCampaign(resolvedCampaignPath) if err != nil { return nil, err } + if selectedID := strings.TrimSpace(campaignFlag); selectedID != "" && strings.TrimSpace(campaignFileFlag) == "" { + if got := config.CampaignID(campaignCfg); got != selectedID { + return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID) + } + } return &pipelineCampaignConfig{ PipelinePath: resolvedPipelinePath, CampaignPath: resolvedCampaignPath, diff --git a/internal/app/operator_helpers.go b/internal/app/operator_helpers.go index 3ac5ef0..b6281ca 100644 --- a/internal/app/operator_helpers.go +++ b/internal/app/operator_helpers.go @@ -21,6 +21,7 @@ import ( type commonConfigFlags struct { pipelinePath string campaignPath string + campaignFilePath string sessionPath string sessionID string previousSessionID string @@ -42,7 +43,8 @@ func (e findingError) Error() string { func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) { fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") - fs.StringVar(&flags.campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") + fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID") + fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml") fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier") } @@ -133,7 +135,7 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error { } findings := []finding{} - cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions()) + cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { findings = append(findings, errorFinding("config", err.Error())) return renderFindings(out, "", "", findings) @@ -213,7 +215,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error { if strings.TrimSpace(flags.sessionID) == "" { return fmt.Errorf("status: session_id is required") } - cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions()) + cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return fmt.Errorf("status: %w", err) } @@ -282,10 +284,11 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error { positionalSessionID, args := pullLeadingSessionID(args) fs := flag.NewFlagSet("session init", flag.ContinueOnError) fs.SetOutput(io.Discard) - var pipelinePath, campaignPath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string + var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string var remote, 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(&campaignPath, "campaign", "", "campaign ID") + fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier") fs.StringVar(&date, "date", "", "session date") fs.StringVar(&title, "title", "", "session title") @@ -319,13 +322,13 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error { return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive") } - base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath) + base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath) if err != nil { return fmt.Errorf("session init: %w", err) } input := sessionInitInput{ - Campaign: base.Campaign.Campaign, + Campaign: config.CampaignID(base.Campaign), CampaignPath: base.CampaignPath, TemplateFile: base.Campaign.SessionTemplateFile, SessionID: sessionID, @@ -370,7 +373,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error { if err != nil { return fmt.Errorf("session init: %w", err) } - sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, base.Campaign.Campaign, sessionID) + sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID) key := artifacts.S3SessionConfigKey(sessionPrefix) exists, err := store.Exists(ctx, key) if err != nil { @@ -606,7 +609,7 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error { } func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore bool) (*config.Config, storage.ObjectStore, *effectiveLocks, *manifest.Manifest, error) { - cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions()) + cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions()) if err != nil { return nil, nil, nil, nil, err } diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index 30ebc7d..f9922c9 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -27,7 +27,7 @@ func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) { code := Execute([]string{ "session", "init", "2026-06-07", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--title", "The Black Cabin", "--remote", }, &stdout, &stderr) @@ -104,7 +104,7 @@ func TestExecuteSessionInitExplicitConfigWinsOverDefaults(t *testing.T) { explicitDir := t.TempDir() explicitCampaign := filepath.Join(explicitDir, "campaign.yml") - if err := os.WriteFile(explicitCampaign, []byte(`campaign: explicit-campaign + if err := os.WriteFile(explicitCampaign, []byte(`campaign_id: explicit-campaign inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml @@ -125,7 +125,7 @@ inputs: code := Execute([]string{ "session", "init", "2026-06-07", "--config", defaultPipeline, - "--campaign", explicitCampaign, + "--campaign-file", explicitCampaign, "--remote", }, &stdout, &stderr) if code != 0 { @@ -159,12 +159,9 @@ func TestExecuteSessionInitRequiresSessionID(t *testing.T) { func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T) { origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) - origCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...) config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")} - config.DefaultCampaignConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-campaign.yml")} t.Cleanup(func() { config.DefaultPipelineConfigSearchPaths = origPipelineDefaults - config.DefaultCampaignConfigSearchPaths = origCampaignDefaults }) var stdout bytes.Buffer @@ -228,7 +225,7 @@ inputs: code := Execute([]string{ "session", "init", "2026-06-07", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--previous-session-id", "2026-05-31", "--date", "2026-06-07", "--title", "The Black Cabin", @@ -276,7 +273,7 @@ inputs: code := Execute([]string{ "session", "init", "2026-06-07", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--remote", }, &stdout, &stderr) if code != 0 { @@ -314,7 +311,7 @@ inputs: code := Execute([]string{ "session", "init", "2026-06-07", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--output", outputPath, }, &stdout, &stderr) if code != 0 { @@ -344,7 +341,7 @@ inputs: code := Execute([]string{ "session", "init", "2026-06-07", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--remote", }, &stdout, &stderr) if code == 0 { @@ -369,7 +366,7 @@ inputs: code := Execute([]string{ "session", "init", "2026-06-07", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--title", "Unused Title", "--remote", }, &stdout, &stderr) @@ -396,7 +393,7 @@ inputs: code := Execute([]string{ "session", "init", "2026-06-07", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--remote", }, &stdout, &stderr) if code == 0 { @@ -441,7 +438,7 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } @@ -462,7 +459,7 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) { code := Execute([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "manual edit", }, &stdout, &stderr) @@ -483,7 +480,7 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) { code = Execute([]string{ "session", "locks", "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { @@ -498,7 +495,7 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) { code = Execute([]string{ "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { @@ -528,7 +525,7 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) { code := Execute([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "first", }, &stdout, &stderr) @@ -541,7 +538,7 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) { code = Execute([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "second", }, &stdout, &stderr) @@ -557,7 +554,7 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) { code = Execute([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "second", "--force", @@ -609,7 +606,7 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) { code := Execute([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code == 0 { @@ -624,7 +621,7 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) { code = Execute([]string{ "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code == 0 { @@ -655,13 +652,11 @@ func TestExecuteTopLevelLockAndUnlockAreRemoved(t *testing.T) { func withDefaultPipelineCampaignConfigs(t *testing.T, pipelinePath, campaignPath string) { t.Helper() origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) - origCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...) config.DefaultPipelineConfigSearchPaths = []string{pipelinePath} - config.DefaultCampaignConfigSearchPaths = []string{campaignPath} t.Cleanup(func() { config.DefaultPipelineConfigSearchPaths = origPipelineDefaults - config.DefaultCampaignConfigSearchPaths = origCampaignDefaults }) + _ = campaignPath } func writeSessionInitTemplate(t *testing.T, campaignPath, templateYAML string) { @@ -711,7 +706,7 @@ func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) { code := Execute([]string{ "session", "artifacts", "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--remote", }, &stdout, &stderr) @@ -747,7 +742,7 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) { code := Execute([]string{ "session", "artifacts", "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--remote", }, &stdout, &stderr) @@ -804,7 +799,7 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) { code := Execute([]string{ "session", "status", "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { @@ -848,7 +843,7 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin code := Execute([]string{ "session", "status", "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { @@ -884,7 +879,7 @@ func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code := Execute([]string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } diff --git a/internal/app/plan.go b/internal/app/plan.go index b5b8e3d..e1999ec 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -23,12 +23,14 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var campaignPath string + var campaignFilePath 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(&campaignPath, "campaign", "", "campaign ID") + fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") @@ -51,7 +53,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { if strings.TrimSpace(sessionID) == "" { return fmt.Errorf("plan: session_id is required") } - cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{ + cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ SessionID: sessionID, PreviousSessionID: previousSessionID, }) diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index bf05690..37dc385 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -18,7 +18,7 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var out bytes.Buffer - args := []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath} + args := []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath} if err := Plan(context.Background(), args, &out); err != nil { t.Fatalf("first Plan() error = %v", err) @@ -74,7 +74,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) { } var out bytes.Buffer - if err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out); err != nil { + if err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out); err != nil { t.Fatalf("Plan() error = %v", err) } got := out.String() @@ -127,7 +127,7 @@ inputs: } var out bytes.Buffer - err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) + err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err == nil { t.Fatal("expected error, got nil") } diff --git a/internal/app/remote_session_test.go b/internal/app/remote_session_test.go index 665a785..0d6909b 100644 --- a/internal/app/remote_session_test.go +++ b/internal/app/remote_session_test.go @@ -29,7 +29,7 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr) + code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -77,7 +77,7 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr) + code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } @@ -92,7 +92,7 @@ func TestExecuteExplicitLocalSessionPrecedenceSkipsRemote(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -110,7 +110,7 @@ func TestExecuteLocalSessionDiscoveryPrecedenceSkipsRemote(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr) + code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -129,7 +129,7 @@ func TestExecuteRemoteSessionMissingObjectFailsClearly(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr) + code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -149,7 +149,7 @@ func TestExecuteRemoteSessionRequiresSessionID(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr) + code := Execute([]string{"session", "plan", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -177,7 +177,7 @@ func TestExecuteRemoteSessionStorageInitErrorFailsClearly(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr) + code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -196,7 +196,7 @@ func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr) + code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -219,7 +219,7 @@ inputs: var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr) + code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -238,7 +238,7 @@ func TestExecuteRemoteSessionMismatchFails(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr) + code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } diff --git a/internal/app/restore.go b/internal/app/restore.go index 9ba54ec..874d338 100644 --- a/internal/app/restore.go +++ b/internal/app/restore.go @@ -29,6 +29,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var campaignPath string + var campaignFilePath string var sessionPath string var sessionID string var previousSessionID string @@ -36,14 +37,15 @@ 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(&campaignPath, "campaign", "", "campaign ID") + fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files") fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state") fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects") fs.Usage = func() { - _, _ = fmt.Fprintln(out, "Usage: narratio session restore [--config ] [--campaign ] [--session ] [--previous-session-id ] [--dry-run] [--force] [--include-audio]") + _, _ = fmt.Fprintln(out, "Usage: narratio session restore [--config ] [--campaign ] [--campaign-file ] [--session ] [--previous-session-id ] [--dry-run] [--force] [--include-audio]") _, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out, "Flags:") fs.PrintDefaults() @@ -70,7 +72,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error { if strings.TrimSpace(sessionID) == "" { return fmt.Errorf("restore: session_id is required") } - cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{ + cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ SessionID: sessionID, PreviousSessionID: previousSessionID, }) diff --git a/internal/app/restore_execution_test.go b/internal/app/restore_execution_test.go index a4d50f1..a144d2f 100644 --- a/internal/app/restore_execution_test.go +++ b/internal/app/restore_execution_test.go @@ -32,7 +32,7 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -74,7 +74,7 @@ func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -100,7 +100,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) if code != 0 { t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -115,7 +115,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T) } stdout.Reset() stderr.Reset() - code = Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) + code = Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) if code != 0 { t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -138,7 +138,7 @@ func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -171,7 +171,7 @@ func TestExecuteRestoreDryRunReportsPreviousCacheWithoutWriting(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -200,7 +200,7 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -232,7 +232,7 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -259,7 +259,7 @@ func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } @@ -285,7 +285,7 @@ func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -332,7 +332,7 @@ func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing. var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } diff --git a/internal/app/restore_test.go b/internal/app/restore_test.go index 78d5bb5..c9d3ed4 100644 --- a/internal/app/restore_test.go +++ b/internal/app/restore_test.go @@ -81,7 +81,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { []string{ "session", "restore", "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run", "--force", @@ -123,7 +123,7 @@ func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -145,7 +145,7 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -173,7 +173,7 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -264,7 +264,7 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) { []string{ "session", "restore", "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run", }, @@ -313,7 +313,7 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code == 0 { t.Fatal("exit code = 0, want non-zero") } @@ -365,7 +365,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) + code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } diff --git a/internal/app/restore_workflow_test.go b/internal/app/restore_workflow_test.go index e7a0a6c..b842022 100644 --- a/internal/app/restore_workflow_test.go +++ b/internal/app/restore_workflow_test.go @@ -48,7 +48,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) { []string{ "session", "restore", cfg.Session.SessionID, "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, @@ -86,7 +86,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) { []string{ "run-stage", "analyze", cfg.Session.SessionID, "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--force", "--artifacts", "player_handout", @@ -192,7 +192,7 @@ previous_session_id: 2026-04-26 []string{ "session", "restore", cfg.Session.SessionID, "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, @@ -242,7 +242,7 @@ previous_session_id: 2026-04-26 []string{ "run-stage", "analyze", cfg.Session.SessionID, "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--force", "--artifacts", "session_recap", diff --git a/internal/app/resume.go b/internal/app/resume.go index e373023..9801e3e 100644 --- a/internal/app/resume.go +++ b/internal/app/resume.go @@ -20,13 +20,15 @@ func Resume(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var campaignPath string + var campaignFilePath 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(&campaignPath, "campaign", "", "campaign ID") + fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.BoolVar(&force, "force", false, "force stage execution") @@ -50,7 +52,7 @@ func Resume(ctx context.Context, args []string, out io.Writer) error { if strings.TrimSpace(sessionID) == "" { return fmt.Errorf("resume: session_id is required") } - cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{ + cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, 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 1c9594e..52af9bd 100644 --- a/internal/app/resume_run_stage_test.go +++ b/internal/app/resume_run_stage_test.go @@ -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{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) + err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("Resume() error = %v", err) } @@ -64,7 +64,7 @@ func TestResumeNoRemainingStages(t *testing.T) { } var out bytes.Buffer - err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) + err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("Resume() error = %v", err) } @@ -93,7 +93,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) { } var out bytes.Buffer - err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out) + err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out) if err != nil { t.Fatalf("Resume() error = %v", err) } @@ -111,7 +111,7 @@ func TestRunStageExecutesOnlySelectedStage(t *testing.T) { mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") var out bytes.Buffer - err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) + err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("RunStage() error = %v", err) } @@ -148,7 +148,7 @@ func TestRunStageSkipAndForce(t *testing.T) { } var out bytes.Buffer - err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) + err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &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{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out) + err = RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out) if err != nil { t.Fatalf("RunStage(force) error = %v", err) } @@ -184,7 +184,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing } var out bytes.Buffer - err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out) + err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &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{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) + err = Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("Resume() error = %v", err) } @@ -220,7 +220,7 @@ func TestRunStageTrimExecutes(t *testing.T) { mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1},{"id":2}]}`) var out bytes.Buffer - err := RunStage(context.Background(), []string{"trim", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) + err := RunStage(context.Background(), []string{"trim", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("RunStage(trim) error = %v", err) } @@ -249,7 +249,7 @@ func TestRunStageNormalizeExecutes(t *testing.T) { mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1},{"id":2}]}`) var out bytes.Buffer - err := RunStage(context.Background(), []string{"normalize", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) + err := RunStage(context.Background(), []string{"normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("RunStage(normalize) error = %v", err) } diff --git a/internal/app/run.go b/internal/app/run.go index cdf7498..1ab0971 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -18,13 +18,15 @@ func Run(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var campaignPath string + var campaignFilePath 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(&campaignPath, "campaign", "", "campaign ID") + fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") @@ -48,7 +50,7 @@ func Run(ctx context.Context, args []string, out io.Writer) error { if strings.TrimSpace(sessionID) == "" { return fmt.Errorf("run: session_id is required") } - cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{ + cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{ SessionID: sessionID, PreviousSessionID: previousSessionID, }) diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index 670acdd..bfc9e5d 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -25,13 +25,15 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var campaignPath string + var campaignFilePath 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(&campaignPath, "campaign", "", "campaign ID") + fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") @@ -70,6 +72,7 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error { StageName: stageName, PipelinePath: pipelinePath, CampaignPath: campaignPath, + CampaignFilePath: campaignFilePath, SessionPath: sessionPath, SessionID: sessionID, PreviousSessionID: previousSessionID, @@ -100,12 +103,14 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var campaignPath string + var campaignFilePath string var sessionPath string var sessionID string var previousSessionID string 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(&campaignPath, "campaign", "", "campaign ID") + fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute during analyze (comma-separated or repeatable)") @@ -138,6 +143,7 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error { StageName: "analyze", PipelinePath: pipelinePath, CampaignPath: campaignPath, + CampaignFilePath: campaignFilePath, SessionPath: sessionPath, SessionID: sessionID, PreviousSessionID: previousSessionID, @@ -166,12 +172,14 @@ func Publish(ctx context.Context, args []string, out io.Writer) error { var pipelinePath string var campaignPath string + var campaignFilePath string var sessionPath string var sessionID string var previousSessionID string 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(&campaignPath, "campaign", "", "campaign ID") + fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to publish (comma-separated or repeatable)") @@ -204,6 +212,7 @@ func Publish(ctx context.Context, args []string, out io.Writer) error { StageName: "archive", PipelinePath: pipelinePath, CampaignPath: campaignPath, + CampaignFilePath: campaignFilePath, SessionPath: sessionPath, SessionID: sessionID, PreviousSessionID: previousSessionID, @@ -229,6 +238,7 @@ type singleStageCommand struct { StageName string PipelinePath string CampaignPath string + CampaignFilePath string SessionPath string SessionID string PreviousSessionID string @@ -242,7 +252,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum return nil, fmt.Errorf("%s: %w", req.CommandName, err) } - cfg, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.SessionPath, config.SessionLoadOptions{ + cfg, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{ SessionID: req.SessionID, PreviousSessionID: req.PreviousSessionID, }) diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index c8fdf9e..433fffd 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -1015,7 +1015,7 @@ func testConfig(t *testing.T) *config.Config { pipelinePath := filepath.Join(cfgDir, "pipeline.yml") mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\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, campaignPath, "campaign_id: 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") @@ -1024,7 +1024,7 @@ func testConfig(t *testing.T) *config.Config { return &config.Config{ Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, - Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, + Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"}, PipelinePath: pipelinePath, CampaignPath: campaignPath, SessionPath: sessionPath, @@ -1070,7 +1070,7 @@ whisperx: notification: timeout: 10s ` - campaignYAML := `campaign: sample-campaign + campaignYAML := `campaign_id: sample-campaign inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml diff --git a/internal/app/session_cli_test.go b/internal/app/session_cli_test.go index 47a55d3..8081b30 100644 --- a/internal/app/session_cli_test.go +++ b/internal/app/session_cli_test.go @@ -35,7 +35,7 @@ inputs: err := Plan(context.Background(), []string{ "2026-04-04", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--previous-session-id", "2026-03-28", }, &out) if err == nil { @@ -54,7 +54,7 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var out bytes.Buffer - err := Plan(context.Background(), []string{"2026-04-04", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) + err := Plan(context.Background(), []string{"2026-04-04", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err == nil { t.Fatal("expected error, got nil") } @@ -84,7 +84,7 @@ inputs: err := Plan(context.Background(), []string{ "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--previous-session-id", "2026-04-25", }, &out) @@ -101,7 +101,7 @@ func TestRunStageAcceptsPositionalSessionIDAndParsesStageName(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) var out bytes.Buffer - err := RunStage(context.Background(), []string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) + err := RunStage(context.Background(), []string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) if err != nil { t.Fatalf("RunStage() error = %v", err) } diff --git a/internal/app/session_oriented_cli_test.go b/internal/app/session_oriented_cli_test.go index 82e98b5..3fe93f6 100644 --- a/internal/app/session_oriented_cli_test.go +++ b/internal/app/session_oriented_cli_test.go @@ -36,7 +36,7 @@ func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) { "run", "2026-05-03", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { @@ -60,7 +60,7 @@ func TestExecutePositionalSessionIDMismatchFails(t *testing.T) { "run", "2026-05-04", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code == 0 { @@ -110,7 +110,7 @@ inputs: "run", "2026-06-07", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, }, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) @@ -142,25 +142,25 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) { }{ { name: "resume", - args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, + args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantStage: "prepare", wantForce: false, }, { name: "analyze", - args: []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, + args: []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, wantStage: "analyze", wantForce: true, }, { name: "publish", - args: []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, + args: []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, wantStage: "archive", wantForce: true, }, { name: "run-stage", - args: []string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, + args: []string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, wantStage: "archive", wantForce: false, }, @@ -225,27 +225,27 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) { }{ { name: "validate", - args: []string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, + args: []string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, want: "OK config", }, { name: "status", - args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, + args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, want: "Session: 2026-05-03", }, { name: "plan", - args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, + args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, want: "narratio session plan: workdir prepared", }, { name: "artifacts", - args: []string{"session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, + args: []string{"session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, want: "Built-in:", }, { name: "locks", - args: []string{"session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, + args: []string{"session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, want: "Archive locks:", }, } @@ -275,7 +275,7 @@ func TestExecuteSessionInitAcceptsPositionalSessionID(t *testing.T) { code := Execute([]string{ "session", "init", "2026-06-07", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--output", outputPath, }, &stdout, &stderr) if code != 0 { @@ -302,7 +302,7 @@ func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) { code := Execute([]string{ "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, "--reason", "review", }, &stdout, &stderr) @@ -319,7 +319,7 @@ func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) { code = Execute([]string{ "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "--config", pipelinePath, - "--campaign", campaignPath, + "--campaign-file", campaignPath, "--session", sessionPath, }, &stdout, &stderr) if code != 0 { @@ -344,7 +344,7 @@ func TestExecuteCleanAcceptsPositionalSessionID(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) + code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr) if code != 0 { t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) } diff --git a/internal/config/campaign_config_test.go b/internal/config/campaign_config_test.go index d60f7bd..e46229d 100644 --- a/internal/config/campaign_config_test.go +++ b/internal/config/campaign_config_test.go @@ -7,24 +7,67 @@ import ( "testing" ) -func TestCampaignConfigDefaultSearchOrder(t *testing.T) { - want := []string{ - "/usr/local/etc/narratio/campaign.yml", - "/etc/narratio/campaign.yml", +func TestPipelineCampaignRegistryStrictDecode(t *testing.T) { + dir := t.TempDir() + pipelinePath := filepath.Join(dir, "pipeline.yml") + pipelineYAML := `workspace: + root: /tmp/narratio-work +campaigns: + root: /srv/narratio/campaigns + default_campaign_id: dilfs +whisperx: + transcribe_url: https://example.com/transcribe +notification: + timeout: 10s +` + if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { + t.Fatalf("write pipeline.yml: %v", err) } - if len(DefaultCampaignConfigSearchPaths) != len(want) { - t.Fatalf("DefaultCampaignConfigSearchPaths = %#v, want %#v", DefaultCampaignConfigSearchPaths, want) + cfg, err := LoadPipeline(pipelinePath) + if err != nil { + t.Fatalf("LoadPipeline() error = %v", err) } - for i := range want { - if DefaultCampaignConfigSearchPaths[i] != want[i] { - t.Fatalf("DefaultCampaignConfigSearchPaths[%d] = %q, want %q", i, DefaultCampaignConfigSearchPaths[i], want[i]) - } + if cfg.Campaigns.Root != "/srv/narratio/campaigns" { + t.Fatalf("campaigns.root = %q", cfg.Campaigns.Root) + } + if cfg.Campaigns.DefaultCampaignID != "dilfs" { + t.Fatalf("campaigns.default_campaign_id = %q", cfg.Campaigns.DefaultCampaignID) + } +} + +func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) { + pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, + "campaign_id: 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", + ) + + cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err != nil { + t.Fatalf("LoadWithSessionOptions() error = %v", err) + } + if CampaignID(cfg.Campaign) != "sample-campaign" { + t.Fatalf("CampaignID() = %q, want sample-campaign", CampaignID(cfg.Campaign)) + } +} + +func TestCampaignStrictDecodeRejectsLegacyCampaignField(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\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 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", + "campaign_id: 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", ) @@ -39,7 +82,7 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) { func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, - "campaign: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", + "campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\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", ) @@ -54,7 +97,7 @@ func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) { 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", + "campaign_id: 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", ) @@ -75,7 +118,7 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) { 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", + "campaign_id: 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", ) @@ -93,7 +136,7 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) { 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", + "campaign_id: 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", ) @@ -108,7 +151,7 @@ func TestCampaignSessionMismatchFails(t *testing.T) { 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", + "campaign_id: 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") diff --git a/internal/config/config.go b/internal/config/config.go index e1d223f..c51ce05 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { // PipelineConfig contains durable pipeline-level settings. type PipelineConfig struct { Workspace WorkspaceConfig `yaml:"workspace"` + Campaigns CampaignsConfig `yaml:"campaigns"` Storage StorageConfig `yaml:"storage"` Spool SpoolConfig `yaml:"spool"` Cache CacheConfig `yaml:"cache"` @@ -31,9 +32,15 @@ type PipelineConfig struct { Notification NotificationConfig `yaml:"notification"` } +// CampaignsConfig configures the local campaign registry. +type CampaignsConfig struct { + Root string `yaml:"root"` + DefaultCampaignID string `yaml:"default_campaign_id"` +} + // CampaignConfig contains stable campaign-level identity and input defaults. type CampaignConfig struct { - Campaign string `yaml:"campaign"` + CampaignID string `yaml:"campaign_id"` SessionTemplateFile string `yaml:"session_template_file"` Inputs CampaignInputsConfig `yaml:"inputs"` } diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 576d633..ac96972 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -7,14 +7,13 @@ import "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" const ( DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml" DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml" - DefaultCampaignConfigPathUsrLocal = "/usr/local/etc/narratio/campaign.yml" - DefaultCampaignConfigPathEtc = "/etc/narratio/campaign.yml" DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml" DefaultSessionConfigPathEtc = "/etc/narratio/session.yml" DefaultS3AccessKeyIDEnv = "OBJECT_STORAGE_KEY_ID" DefaultS3SecretAccessKeyEnv = "OBJECT_STORAGE_KEY" DefaultStorageS3RootPrefix = "dnd" DefaultWorkspaceRoot = "/var/lib/narratio" + DefaultCampaignsRoot = "/usr/local/share/narratio/campaigns" DefaultSpoolRoot = "/var/spool/narratio" DefaultCacheRoot = "/var/cache/narratio" DefaultCacheS3Audio = true @@ -93,16 +92,6 @@ 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{ - 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 0d884a4..7d53ec8 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -194,7 +194,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig return ResolvedStableInputs{}, fmt.Errorf("session config is required") } - campaignName := strings.TrimSpace(campaignCfg.Campaign) + campaignName := CampaignID(campaignCfg) sessionCampaign := strings.TrimSpace(sessionCfg.Campaign) if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName { return ResolvedStableInputs{}, fmt.Errorf( @@ -234,6 +234,14 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig return stable, nil } +// CampaignID returns the canonical campaign identity from campaign config. +func CampaignID(cfg *CampaignConfig) string { + if cfg == nil { + return "" + } + return strings.TrimSpace(cfg.CampaignID) +} + func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath string) ResolvedInputFile { if strings.TrimSpace(sessionValue) != "" { return ResolvedInputFile{ @@ -316,6 +324,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) { return } applyWorkspaceDefaults(&cfg.Workspace) + applyCampaignsDefaults(&cfg.Campaigns) applyStorageDefaults(&cfg.Storage) applySpoolDefaults(&cfg.Spool) applyCacheDefaults(&cfg.Cache) @@ -331,6 +340,15 @@ func applyPipelineDefaults(cfg *PipelineConfig) { applyScriptoriumDefaults(cfg.Scriptorium) } +func applyCampaignsDefaults(cfg *CampaignsConfig) { + if cfg == nil { + return + } + if cfg.Root == "" { + cfg.Root = DefaultCampaignsRoot + } +} + func applyWorkspaceDefaults(cfg *WorkspaceConfig) { if cfg == nil { return diff --git a/internal/config/load_validate_test.go b/internal/config/load_validate_test.go index 9e4f216..031a900 100644 --- a/internal/config/load_validate_test.go +++ b/internal/config/load_validate_test.go @@ -916,7 +916,7 @@ func TestValidateMissingAudioSource(t *testing.T) { Report: boolPtr(true), }, }, - Campaign: &CampaignConfig{Campaign: "sample-campaign"}, + Campaign: &CampaignConfig{CampaignID: "sample-campaign"}, Session: &SessionConfig{ SessionID: "2026-05-03", Campaign: "sample-campaign", @@ -967,7 +967,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") + campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml") sessionPath := filepath.Join(examplesDir, tt.sessionFile) cfg, err := Load(pipelinePath, campaignPath, sessionPath) @@ -1004,7 +1004,7 @@ func writeConfigFiles(t *testing.T, pipelineYAML, sessionYAML string) (string, s if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { t.Fatalf("write pipeline.yml: %v", err) } - campaignYAML := `campaign: ` + campaignNameFromSessionYAML(sessionYAML) + ` + campaignYAML := `campaign_id: ` + campaignNameFromSessionYAML(sessionYAML) + ` inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml diff --git a/internal/config/validate.go b/internal/config/validate.go index dc4d854..191f7d9 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -46,8 +46,8 @@ 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") + if CampaignID(cfg) == "" { + return fmt.Errorf("campaign.campaign_id is required") } return nil } diff --git a/internal/stage/placeholders_test.go b/internal/stage/placeholders_test.go index 240e650..8066d26 100644 --- a/internal/stage/placeholders_test.go +++ b/internal/stage/placeholders_test.go @@ -33,7 +33,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) { 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, campaignPath, "campaign_id: 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") @@ -52,7 +52,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) { SessionPath: sessionPath, CampaignPath: campaignPath, PipelinePath: pipelinePath, - Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, + Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"}, Pipeline: &config.PipelineConfig{ Workspace: config.WorkspaceConfig{Root: root}, Storage: config.StorageConfig{ diff --git a/internal/stage/prepare_test.go b/internal/stage/prepare_test.go index 20d5891..1502b71 100644 --- a/internal/stage/prepare_test.go +++ b/internal/stage/prepare_test.go @@ -608,7 +608,7 @@ func setupPrepareEnv(t *testing.T) (*Env, *manifest.Manifest) { campaignPath := filepath.Join(cfgDir, "campaign.yml") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") - writeFile(t, campaignPath, `campaign: sample-campaign + writeFile(t, campaignPath, `campaign_id: sample-campaign inputs: speakers_file: ./speakers.yml autocorrect_file: ./autocorrect.yml @@ -624,7 +624,7 @@ inputs: Workspace: config.WorkspaceConfig{Root: workspace}, Cache: config.CacheConfig{Root: filepath.Join(t.TempDir(), "cache"), S3Audio: boolPtr(true)}, }, - Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, + Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"}, SessionPath: sessionPath, CampaignPath: campaignPath, PipelinePath: pipelinePath, diff --git a/internal/stage/transcribe_test.go b/internal/stage/transcribe_test.go index 6f249c5..5dd1fb4 100644 --- a/internal/stage/transcribe_test.go +++ b/internal/stage/transcribe_test.go @@ -236,7 +236,7 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani 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, campaignPath, "campaign_id: 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") @@ -247,7 +247,7 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani PipelinePath: pipelinePath, CampaignPath: campaignPath, SessionPath: sessionPath, - Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, + Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"}, Pipeline: &config.PipelineConfig{ Workspace: config.WorkspaceConfig{Root: workspace}, WhisperX: config.WhisperXConfig{