Implemented multiple campaign support via a campaign directory registry with explicit campaign IDs

This commit is contained in:
2026-05-22 23:01:27 -05:00
parent 7657ec3ad6
commit 9c9cb54339
47 changed files with 775 additions and 329 deletions

View File

@@ -6,9 +6,9 @@
narratio run 2026-04-04 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 <id>` 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. 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: Most session-aware commands accept:
- `--config <path>`: optional explicit `pipeline.yml` path. - `--config <path>`: optional explicit `pipeline.yml` path.
- `--campaign <path>`: optional explicit `campaign.yml` path. - `--campaign <id>`: optional campaign ID selector.
- `--campaign-file <path>`: optional explicit `campaign.yml` path.
- `--session <path>`: optional explicit concrete `session.yml` path. - `--session <path>`: optional explicit concrete `session.yml` path.
- `--previous-session-id <value>`: expected previous session identifier. - `--previous-session-id <value>`: expected previous session identifier.
@@ -56,7 +57,7 @@ The positional `<session_id>` is required even when `--session` is provided. It
### `run` ### `run`
```bash ```bash
narratio run <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] narratio run <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
``` ```
Purpose: Purpose:
@@ -66,7 +67,8 @@ Success output:
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>` - `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
Common failure cases: 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`. - missing local session plus missing/unavailable remote `session.yml`.
- templated `session.yml`; run `narratio session init` to generate concrete YAML. - templated `session.yml`; run `narratio session init` to generate concrete YAML.
- concrete session identity mismatch. - concrete session identity mismatch.
@@ -75,7 +77,7 @@ Common failure cases:
### `resume` ### `resume`
```bash ```bash
narratio resume <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] narratio resume <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
``` ```
Purpose: Purpose:
@@ -88,7 +90,7 @@ Success output:
### `run-stage` ### `run-stage`
```bash ```bash
narratio run-stage <stage> <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] narratio run-stage <stage> <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
``` ```
Valid stage names: Valid stage names:
@@ -111,7 +113,7 @@ Success output:
### `analyze` ### `analyze`
```bash ```bash
narratio analyze <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--artifacts <name[,name...]>] narratio analyze <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--artifacts <name[,name...]>]
``` ```
Purpose: Purpose:
@@ -123,7 +125,7 @@ Purpose:
### `publish` ### `publish`
```bash ```bash
narratio publish <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--artifacts <name[,name...]>] narratio publish <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--artifacts <name[,name...]>]
``` ```
Purpose: Purpose:
@@ -135,7 +137,7 @@ Purpose:
### `clean` ### `clean`
```bash ```bash
narratio clean <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--dry-run] [--clear-cache] narratio clean <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--dry-run] [--clear-cache]
narratio clean --all [--config <pipeline.yml>] [--dry-run] [--clear-cache] narratio clean --all [--config <pipeline.yml>] [--dry-run] [--clear-cache]
``` ```
@@ -156,7 +158,7 @@ Cache behavior:
### `session plan` ### `session plan`
```bash ```bash
narratio session plan <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] narratio session plan <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force]
``` ```
Purpose: Purpose:
@@ -170,7 +172,7 @@ Success output includes:
### `session status` ### `session status`
```bash ```bash
narratio session status <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] narratio session status <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
``` ```
Output includes: Output includes:
@@ -183,7 +185,7 @@ Output includes:
### `session validate` ### `session validate`
```bash ```bash
narratio session validate <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] narratio session validate <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
``` ```
Checks include: Checks include:
@@ -200,7 +202,8 @@ Warnings do not fail the command. Any `ERROR` finding exits non-zero.
```bash ```bash
narratio session init <session_id> --output ./session.yml narratio session init <session_id> --output ./session.yml
narratio session init <session_id> --remote narratio session init <session_id> --remote
narratio session init <session_id> --config <pipeline.yml> --campaign <campaign.yml> --remote narratio session init <session_id> --config <pipeline.yml> --campaign icewind --remote
narratio session init <session_id> --config <pipeline.yml> --campaign-file ./campaign.yml --remote
``` ```
Additional flags: Additional flags:
@@ -214,7 +217,9 @@ Additional flags:
Behavior: Behavior:
- exactly one of `--output` or `--remote` is required. - 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 <id>` selects a campaign under `pipeline.campaigns.root`.
- `--campaign-file <path>` 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 `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. - 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. - 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` ### `session restore`
```bash ```bash
narratio session restore <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--dry-run] [--force] [--include-audio] narratio session restore <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--dry-run] [--force] [--include-audio]
``` ```
Purpose: Purpose:
@@ -241,7 +246,7 @@ When `--include-audio` is set, S3 audio files are restored through the shared au
### `session artifacts` ### `session artifacts`
```bash ```bash
narratio session artifacts <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--remote] narratio session artifacts <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--remote]
``` ```
Purpose: Purpose:
@@ -252,9 +257,9 @@ Purpose:
### `session locks` ### `session locks`
```bash ```bash
narratio session locks <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] narratio session locks <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
narratio session locks add <session_id> <source> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--reason <text>] [--force] narratio session locks add <session_id> <source> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--reason <text>] [--force]
narratio session locks remove <session_id> <source> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] narratio session locks remove <session_id> <source> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
``` ```
Behavior: Behavior:

View File

@@ -28,6 +28,7 @@ Behavior:
- strict YAML decode is enabled (`KnownFields(true)`): unknown fields fail. - 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. - ordinary local and remote `session.yml` files must be concrete YAML; template placeholders are rejected.
- defaults are applied for optional pipeline fields. - 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. - campaign-level stable input paths fill missing session input paths.
- session-level stable input paths override campaign-level input paths. - session-level stable input paths override campaign-level input paths.
- campaign config may point `session init` to a session template. - campaign config may point `session init` to a session template.
@@ -60,11 +61,14 @@ Pipeline config lookup:
Campaign config lookup: Campaign config lookup:
- if `--campaign <path>` is provided, that path is used. - pipeline config is loaded first.
- if omitted, Narratio searches in order: - if `--campaign-file <path>` is provided, that path is used.
1. `/usr/local/etc/narratio/campaign.yml` - otherwise, if `--campaign <id>` is provided, Narratio loads:
2. `/etc/narratio/campaign.yml` - `{pipeline.campaigns.root}/{id}/campaign.yml`
- first existing file wins. - 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: Session config lookup:
@@ -76,7 +80,7 @@ Session config lookup:
- if no local session file is found, a positional `<session_id>` is present, storage is configured, and campaign identity is resolved, Narratio loads remote `session.yml` from: - if no local session file is found, a positional `<session_id>` 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` - `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`
- local discovery always runs before remote fallback. - 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 ## 3. Session templating
@@ -109,6 +113,9 @@ Template behavior for `narratio session init`:
### `pipeline.yml` ### `pipeline.yml`
```yaml ```yaml
campaigns:
root: /usr/local/share/narratio/campaigns
default_campaign_id: sample-campaign
whisperx: whisperx:
transcribe_url: "https://transcription.example.com/transcribe" transcribe_url: "https://transcription.example.com/transcribe"
``` ```
@@ -116,13 +123,14 @@ whisperx:
Why this is sufficient: Why this is sufficient:
- `whisperx.transcribe_url` is required. - `whisperx.transcribe_url` is required.
- `campaigns.default_campaign_id` selects the default campaign when `--campaign` is omitted.
- `workspace.root` defaults to `/var/lib/narratio`. - `workspace.root` defaults to `/var/lib/narratio`.
- optional sections (`seriatim`, `audita`, `archive`, `scriptorium`, `trim`, `normalize`, etc.) receive defaults or stay inactive. - optional sections (`seriatim`, `audita`, `archive`, `scriptorium`, `trim`, `normalize`, etc.) receive defaults or stay inactive.
### `campaign.yml` ### `campaign.yml`
```yaml ```yaml
campaign: sample-campaign campaign_id: sample-campaign
session_template_file: ./session.template.yml session_template_file: ./session.template.yml
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
@@ -132,7 +140,7 @@ inputs:
Why this is sufficient: 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`. - stable input files are required and resolve relative to `campaign.yml` when copied during `prepare`.
### `session.yml` ### `session.yml`
@@ -153,7 +161,8 @@ Why this is sufficient:
Minimal local-file usage: Minimal local-file usage:
```bash ```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: Previous-session-enabled variant:
@@ -166,7 +175,7 @@ inputs:
``` ```
```bash ```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 ## 5. Production-oriented config set
@@ -187,6 +196,10 @@ storage:
access_key_id_env: OBJECT_STORAGE_KEY_ID access_key_id_env: OBJECT_STORAGE_KEY_ID
secret_access_key_env: OBJECT_STORAGE_KEY secret_access_key_env: OBJECT_STORAGE_KEY
campaigns:
root: /srv/narratio/campaigns
default_campaign_id: forsaken
spool: spool:
root: /var/spool/narratio root: /var/spool/narratio
delete_audio_after_archive: true delete_audio_after_archive: true
@@ -230,7 +243,7 @@ scriptorium:
### `campaign.yml` ### `campaign.yml`
```yaml ```yaml
campaign: forsaken campaign_id: forsaken
inputs: inputs:
speakers_file: /srv/narratio/campaigns/forsaken/speakers.yml speakers_file: /srv/narratio/campaigns/forsaken/speakers.yml
autocorrect_file: /srv/narratio/campaigns/forsaken/autocorrect.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`: Then run with explicit or discovered pipeline/campaign config and no `--session`:
```bash ```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: Operational notes:
@@ -279,6 +292,8 @@ Operational notes:
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `pipeline.workspace.root` | string | No | `/var/lib/narratio` | | `pipeline.workspace.root` | string | No | `/var/lib/narratio` |
| `pipeline.workspace.cleanup_after_archive` | bool | No | `false` | | `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.secrets.env_dir` | string | Conditional | none |
| `pipeline.storage.backend` | string | No | empty | | `pipeline.storage.backend` | string | No | empty |
| `pipeline.storage.s3.bucket` | string | Conditional | empty | | `pipeline.storage.s3.bucket` | string | Conditional | empty |
@@ -434,7 +449,7 @@ Restore-related implications:
| Path | Type | Required | Default | | Path | Type | Required | Default |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `campaign.campaign` | string | Yes | none | | `campaign.campaign_id` | string | Yes | none |
| `campaign.session_template_file` | string | No | none | | `campaign.session_template_file` | string | No | none |
| `campaign.inputs.speakers_file` | string | Yes | none | | `campaign.inputs.speakers_file` | string | Yes | none |
| `campaign.inputs.autocorrect_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.session_id` | string | Yes | none |
| `session.previous_session_id` | string | No | empty | | `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.date` | string | No | empty |
| `session.title` | string | No | empty | | `session.title` | string | No | empty |
| `session.inputs.audio_dir` | string | Conditional | empty | | `session.inputs.audio_dir` | string | Conditional | empty |
@@ -499,8 +514,11 @@ Maintained examples:
- `examples/pipeline.minimal.yml` - `examples/pipeline.minimal.yml`
- `examples/pipeline.production.yml` - `examples/pipeline.production.yml`
- `examples/pipeline.full.annotated.yml` - `examples/pipeline.full.annotated.yml`
- `examples/campaign.yml` - `examples/campaigns/sample-campaign/campaign.yml`
- `examples/session.template.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.local-audio.yml`
- `examples/session.s3-audio.yml` - `examples/session.s3-audio.yml`

View File

@@ -6,7 +6,7 @@ Define the implemented `narratio session restore` contract: committed remote-sta
## Inputs and outputs ## Inputs and outputs
Inputs: Inputs:
- CLI syntax: `narratio session restore <session_id>`. - CLI syntax: `narratio session restore <session_id>`.
- 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`. - Resolved/validated `pipeline.yml` and `session.yml`.
- Configured remote object store. - Configured remote object store.
- Remote committed current-state markers (`current/run_id.txt`, `current/manifest.json`). - Remote committed current-state markers (`current/run_id.txt`, `current/manifest.json`).

View File

@@ -62,7 +62,7 @@ Does not own:
- `pipeline.scriptorium.artifacts.<name>.enabled` - `pipeline.scriptorium.artifacts.<name>.enabled`
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` - `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source`
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required` - `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required`
- `campaign.campaign` - `campaign.campaign_id`
- `campaign.inputs.speakers_file` - `campaign.inputs.speakers_file`
- `campaign.inputs.autocorrect_file` - `campaign.inputs.autocorrect_file`
- `campaign.inputs.glossary_file` - `campaign.inputs.glossary_file`

View File

@@ -19,7 +19,7 @@ narratio run 2026-04-04
- use `narratio session status <session_id>` for inspection. - use `narratio session status <session_id>` for inspection.
Notes: 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 <id>` or `--campaign-file <path>` is passed.
- when local `session.yml` discovery misses, positional `<session_id>` loads remote `session.yml` from `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. - when local `session.yml` discovery misses, positional `<session_id>` 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. - 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 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 <id>`, or `--campaign-file <path>` when testing non-system config files. It fails if the object already exists unless `--force` is passed.
Validate before running: Validate before running:

231
docs/roadmap/campaign.md Normal file
View File

@@ -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 <path>`;
- 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 <id>` selects a campaign ID.
- `--campaign-file <path>` 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 <path>` 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 <path>`;
2. explicit `--campaign <id>`;
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 <id>`.
- Commands can load a specific file with `--campaign-file <path>`.
- 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 <id>`;
- `--campaign-file <path>`.
- Update troubleshooting examples that currently pass `--campaign <path>`.
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 <id>` 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 <path>` to `--campaign <id>` or
`--campaign-file <path>`.

View File

@@ -9,20 +9,20 @@ Symptom:
- `run`, `resume`, `run-stage`, `session plan`, or `session restore` fails with config/session not found. - `run`, `resume`, `run-stage`, `session plan`, or `session restore` fails with config/session not found.
Likely Cause: 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. - a local working-directory config file was not passed explicitly.
Diagnostics: Diagnostics:
```bash ```bash
ls -l /usr/local/etc/narratio/pipeline.yml /etc/narratio/pipeline.yml 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 ls -l /usr/local/etc/narratio/session.yml /etc/narratio/session.yml
``` ```
Safe Fix: Safe Fix:
- pass explicit `--config`, `--campaign`, and `--session`. - pass explicit `--config`, `--campaign <id>`, `--campaign-file <path>`, and `--session` as appropriate.
- or place files in documented discovery paths. - or place files in documented discovery paths and set `pipeline.campaigns.default_campaign_id`.
Links: Links:
- [docs/config.md](./config.md) - [docs/config.md](./config.md)
@@ -40,7 +40,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```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: Safe Fix:
@@ -62,7 +62,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```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: Safe Fix:
@@ -84,7 +84,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```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: Safe Fix:
@@ -106,7 +106,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```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: Safe Fix:
@@ -129,7 +129,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```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: Safe Fix:
@@ -154,7 +154,7 @@ Diagnostics:
```bash ```bash
narratio session status 2026-04-04 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: Safe Fix:
@@ -226,7 +226,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```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: Safe Fix:
@@ -249,7 +249,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```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: Safe Fix:
@@ -271,7 +271,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```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: Safe Fix:
@@ -341,7 +341,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```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: Safe Fix:
@@ -365,7 +365,7 @@ Diagnostics:
```bash ```bash
narratio session status 2026-04-04 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: Safe Fix:

View File

@@ -0,0 +1 @@
[]

View File

@@ -1,4 +1,4 @@
campaign: sample-campaign campaign_id: sample-campaign
session_template_file: ./session.template.yml session_template_file: ./session.template.yml
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml

View File

@@ -0,0 +1 @@
[]

View File

@@ -0,0 +1,3 @@
session_id: "{{ session_id }}"
inputs:
audio_dir: ./audio

View File

@@ -0,0 +1,5 @@
match:
- speaker: "Eric Rakestraw"
match:
- "Eric_Rakestraw"
- "Eric"

View File

@@ -27,6 +27,12 @@ storage:
access_key_id_env: OBJECT_STORAGE_KEY_ID access_key_id_env: OBJECT_STORAGE_KEY_ID
secret_access_key_env: OBJECT_STORAGE_KEY 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: spool:
# Optional; defaults to /var/spool/narratio. # Optional; defaults to /var/spool/narratio.
root: /var/spool/narratio root: /var/spool/narratio

View File

@@ -1,2 +1,6 @@
campaigns:
root: /usr/local/share/narratio/campaigns
default_campaign_id: sample-campaign
whisperx: whisperx:
transcribe_url: "https://transcription.example.com/transcribe" transcribe_url: "https://transcription.example.com/transcribe"

View File

@@ -11,6 +11,10 @@ storage:
access_key_id_env: OBJECT_STORAGE_KEY_ID access_key_id_env: OBJECT_STORAGE_KEY_ID
secret_access_key_env: OBJECT_STORAGE_KEY secret_access_key_env: OBJECT_STORAGE_KEY
campaigns:
root: /usr/local/share/narratio/campaigns
default_campaign_id: sample-campaign
spool: spool:
root: /var/spool/narratio root: /var/spool/narratio
delete_audio_after_archive: true delete_audio_after_archive: true

View File

@@ -21,7 +21,7 @@ func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( 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, &stdout,
&stderr, &stderr,
) )
@@ -57,7 +57,7 @@ func TestExecuteRunStageArchivePropagatesSelectedArtifacts(t *testing.T) {
[]string{ []string{
"run-stage", "archive", "2026-05-03", "run-stage", "archive", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--artifacts", "session_recap", "--artifacts", "session_recap",
}, },
@@ -82,7 +82,7 @@ func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( 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, &stdout,
&stderr, &stderr,
) )
@@ -109,7 +109,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
err := RunStage( err := RunStage(
context.Background(), 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, &out,
) )
if err != nil { if err != nil {
@@ -137,7 +137,7 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
err := Resume( err := Resume(
context.Background(), 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, &out,
) )
if err != nil { if err != nil {
@@ -172,7 +172,7 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( 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, &stdout,
&stderr, &stderr,
) )
@@ -211,7 +211,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
"analyze", "analyze",
"2026-05-03", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--artifacts", "player_handout,session_recap", "--artifacts", "player_handout,session_recap",
}, },
@@ -233,7 +233,7 @@ func TestExecuteAnalyzeUnknownArtifactFailsValidation(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( 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, &stdout,
&stderr, &stderr,
) )
@@ -307,7 +307,7 @@ func TestExecutePublishForceRunsArchive(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( 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, &stdout,
&stderr, &stderr,
) )
@@ -359,7 +359,7 @@ func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( 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, &stdout,
&stderr, &stderr,
) )

View File

@@ -1,49 +1,44 @@
package app package app
import ( import (
"errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
func resolveCampaignConfigPath(flagValue string) (string, error) { func resolveCampaignConfigPath(pipelineCfg *config.PipelineConfig, campaignIDFlag, campaignFileFlag string) (string, error) {
return resolveCampaignConfigPathWithCandidates(flagValue, config.DefaultCampaignConfigSearchPaths) 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 <id> 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) { func validateCampaignIDToken(campaignID string) error {
if explicit := strings.TrimSpace(flagValue); explicit != "" { if filepath.IsAbs(campaignID) ||
return explicit, nil strings.Contains(campaignID, "/") ||
strings.Contains(campaignID, `\`) ||
campaignID == "." ||
campaignID == ".." {
return fmt.Errorf("campaign id %q must be a single path segment", campaignID)
} }
return nil
ordered := make([]string, 0, len(candidates))
for _, raw := range candidates {
path := strings.TrimSpace(raw)
if path == "" {
continue
}
ordered = append(ordered, path)
info, err := os.Stat(path)
if err == nil {
if info.IsDir() {
continue
}
return filepath.Clean(path), nil
}
if errors.Is(err, os.ErrNotExist) {
continue
}
return "", fmt.Errorf("check default campaign config %q: %w", path, err)
}
if len(ordered) == 0 {
return "", fmt.Errorf("no campaign config path provided and no default locations configured")
}
return "", fmt.Errorf(
"no campaign config path provided and no default campaign config found; searched: %s; pass --campaign to use an explicit path",
strings.Join(ordered, ", "),
)
} }

View File

@@ -1,49 +1,84 @@
package app package app
import ( import (
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "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") 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 { if err != nil {
t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err) t.Fatalf("resolveCampaignConfigPath() error = %v", err)
} }
if got != explicit { if got != explicit {
t.Fatalf("path = %q, want explicit path %q", 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() dir := t.TempDir()
missing := filepath.Join(dir, "missing.yml") pipelineCfg := &config.PipelineConfig{}
found := filepath.Join(dir, "campaign.yml") pipelineCfg.Campaigns.Root = dir
if err := os.WriteFile(found, []byte("campaign: sample-campaign\n"), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err)
}
got, err := resolveCampaignConfigPathWithCandidates("", []string{missing, found}) got, err := resolveCampaignConfigPath(pipelineCfg, "icewind", "")
if err != nil { if err != nil {
t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err) t.Fatalf("resolveCampaignConfigPath() error = %v", err)
} }
if got != filepath.Clean(found) { want := filepath.Join(dir, "icewind", "campaign.yml")
t.Fatalf("path = %q, want %q", got, filepath.Clean(found)) if got != filepath.Clean(want) {
t.Fatalf("path = %q, want %q", got, filepath.Clean(want))
} }
} }
func TestResolveCampaignConfigPathErrorIncludesSearchedPaths(t *testing.T) { func TestResolveCampaignConfigPathUsesDefaultCampaignID(t *testing.T) {
_, err := resolveCampaignConfigPathWithCandidates("", []string{"/usr/local/etc/narratio/campaign.yml", "/etc/narratio/campaign.yml"}) 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 { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if !strings.Contains(err.Error(), "searched") { if !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %q, want searched paths", err.Error()) t.Fatalf("error = %q, want mutual exclusion", err.Error())
} }
if !strings.Contains(err.Error(), "pass --campaign") { }
t.Fatalf("error = %q, want explicit-campaign guidance", 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())
} }
} }

View File

@@ -52,7 +52,7 @@ func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCac
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("clean: session_id is required unless --all is set") 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 { if err != nil {
return fmt.Errorf("clean: %w", err) 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 { func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error {
if strings.TrimSpace(flags.campaignPath) != "" || if strings.TrimSpace(flags.campaignPath) != "" ||
strings.TrimSpace(flags.campaignFilePath) != "" ||
strings.TrimSpace(flags.sessionPath) != "" || strings.TrimSpace(flags.sessionPath) != "" ||
strings.TrimSpace(flags.sessionID) != "" || strings.TrimSpace(flags.sessionID) != "" ||
strings.TrimSpace(flags.previousSessionID) != "" { 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) resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath)
if err != nil { if err != nil {

View File

@@ -26,7 +26,7 @@ func TestExecuteCleanSessionDeletesWorkAndSpoolButPreservesCache(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -105,7 +105,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }

View File

@@ -31,11 +31,11 @@ func TestExecuteValidCommands(t *testing.T) {
args []string args []string
wantOut 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: "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", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"}, {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", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"}, {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", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, {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", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, {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 { for _, tc := range cases {
@@ -98,7 +98,7 @@ func TestExecuteRunStageUnknownFails(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -116,7 +116,7 @@ func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
stdout.Reset() stdout.Reset()
stderr.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 { if code != 0 {
t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -237,7 +237,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -285,7 +285,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") 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) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath} config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
config.DefaultCampaignConfigSearchPaths = []string{campaignPath}
defer func() { defer func() {
config.DefaultPipelineConfigSearchPaths = originalDefaults config.DefaultPipelineConfigSearchPaths = originalDefaults
config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults
}() }()
_ = campaignPath
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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() workspaceRoot := t.TempDir()
pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
missingCampaignPath := filepath.Join(t.TempDir(), "campaign.yml") if err := os.Remove(campaignPath); err != nil {
originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...) t.Fatalf("remove campaign config: %v", err)
config.DefaultCampaignConfigSearchPaths = []string{missingCampaignPath} }
defer func() {
config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults
}()
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -342,11 +337,67 @@ func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) {
if stdout.Len() != 0 { if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String()) 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()) t.Fatalf("stderr = %q, want campaign discovery failure", stderr.String())
} }
if !strings.Contains(stderr.String(), "pass --campaign") { if !strings.Contains(stderr.String(), filepath.ToSlash(filepath.Join("campaigns", "sample-campaign", "campaign.yml"))) {
t.Fatalf("stderr = %q, want explicit campaign guidance", stderr.String()) 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() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml") 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") sessionPath := filepath.Join(dir, "session.yml")
url := "https://example.com/transcribe" url := "https://example.com/transcribe"
if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" { if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" {
@@ -405,6 +458,9 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...
pipelineYAML := `workspace: pipelineYAML := `workspace:
root: ` + workspaceRoot + ` root: ` + workspaceRoot + `
campaigns:
root: ` + campaignRoot + `
default_campaign_id: sample-campaign
cache: cache:
root: ` + filepath.Join(workspaceRoot, "cache") + ` root: ` + filepath.Join(workspaceRoot, "cache") + `
spool: spool:
@@ -438,7 +494,7 @@ notification:
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
` `
campaignYAML := `campaign: sample-campaign campaignYAML := `campaign_id: sample-campaign
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
@@ -448,6 +504,9 @@ inputs:
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline config: %v", err) 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 { if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign config: %v", err) t.Fatalf("write campaign config: %v", err)
} }
@@ -455,9 +514,9 @@ inputs:
t.Fatalf("write session config: %v", err) 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(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, campaignPath, sessionPath return pipelinePath, campaignPath, sessionPath
@@ -466,7 +525,7 @@ inputs:
func writeAppTestCampaignConfig(t *testing.T, dir string) string { func writeAppTestCampaignConfig(t *testing.T, dir string) string {
t.Helper() t.Helper()
campaignPath := filepath.Join(dir, "campaign.yml") campaignPath := filepath.Join(dir, "campaign.yml")
campaignYAML := `campaign: sample-campaign campaignYAML := `campaign_id: sample-campaign
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml

View File

@@ -19,8 +19,8 @@ type pipelineCampaignConfig struct {
Campaign *config.CampaignConfig Campaign *config.CampaignConfig
} }
func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) { func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) {
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag) base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag)
if err != nil { if err != nil {
return nil, err 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") 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) remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
partialCfg := &config.Config{ partialCfg := &config.Config{
Pipeline: base.Pipeline, 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) resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignFlag)
if err != nil {
return nil, err
}
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath) pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resolvedCampaignPath, err := resolveCampaignConfigPath(pipelineCfg, campaignFlag, campaignFileFlag)
if err != nil {
return nil, err
}
campaignCfg, err := config.LoadCampaign(resolvedCampaignPath) campaignCfg, err := config.LoadCampaign(resolvedCampaignPath)
if err != nil { if err != nil {
return nil, err 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{ return &pipelineCampaignConfig{
PipelinePath: resolvedPipelinePath, PipelinePath: resolvedPipelinePath,
CampaignPath: resolvedCampaignPath, CampaignPath: resolvedCampaignPath,

View File

@@ -21,6 +21,7 @@ import (
type commonConfigFlags struct { type commonConfigFlags struct {
pipelinePath string pipelinePath string
campaignPath string campaignPath string
campaignFilePath string
sessionPath string sessionPath string
sessionID string sessionID string
previousSessionID string previousSessionID string
@@ -42,7 +43,8 @@ func (e findingError) Error() string {
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) { func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") 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.sessionPath, "session", "", "path to session.yml")
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier") 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{} 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 { if err != nil {
findings = append(findings, errorFinding("config", err.Error())) findings = append(findings, errorFinding("config", err.Error()))
return renderFindings(out, "", "", findings) return renderFindings(out, "", "", findings)
@@ -213,7 +215,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("status: session_id is required") 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 { if err != nil {
return fmt.Errorf("status: %w", err) 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) positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("session init", flag.ContinueOnError) fs := flag.NewFlagSet("session init", flag.ContinueOnError)
fs.SetOutput(io.Discard) 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 var remote, force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&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(&previousSessionID, "previous-session-id", "", "previous session identifier")
fs.StringVar(&date, "date", "", "session date") fs.StringVar(&date, "date", "", "session date")
fs.StringVar(&title, "title", "", "session title") 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") 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 { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
input := sessionInitInput{ input := sessionInitInput{
Campaign: base.Campaign.Campaign, Campaign: config.CampaignID(base.Campaign),
CampaignPath: base.CampaignPath, CampaignPath: base.CampaignPath,
TemplateFile: base.Campaign.SessionTemplateFile, TemplateFile: base.Campaign.SessionTemplateFile,
SessionID: sessionID, SessionID: sessionID,
@@ -370,7 +373,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) 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) key := artifacts.S3SessionConfigKey(sessionPrefix)
exists, err := store.Exists(ctx, key) exists, err := store.Exists(ctx, key)
if err != nil { 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) { 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 { if err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, err
} }

View File

@@ -27,7 +27,7 @@ func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) {
code := Execute([]string{ code := Execute([]string{
"session", "init", "2026-06-07", "session", "init", "2026-06-07",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--title", "The Black Cabin", "--title", "The Black Cabin",
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -104,7 +104,7 @@ func TestExecuteSessionInitExplicitConfigWinsOverDefaults(t *testing.T) {
explicitDir := t.TempDir() explicitDir := t.TempDir()
explicitCampaign := filepath.Join(explicitDir, "campaign.yml") 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: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
@@ -125,7 +125,7 @@ inputs:
code := Execute([]string{ code := Execute([]string{
"session", "init", "2026-06-07", "session", "init", "2026-06-07",
"--config", defaultPipeline, "--config", defaultPipeline,
"--campaign", explicitCampaign, "--campaign-file", explicitCampaign,
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -159,12 +159,9 @@ func TestExecuteSessionInitRequiresSessionID(t *testing.T) {
func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T) { func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T) {
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
origCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")} config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")}
config.DefaultCampaignConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-campaign.yml")}
t.Cleanup(func() { t.Cleanup(func() {
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
config.DefaultCampaignConfigSearchPaths = origCampaignDefaults
}) })
var stdout bytes.Buffer var stdout bytes.Buffer
@@ -228,7 +225,7 @@ inputs:
code := Execute([]string{ code := Execute([]string{
"session", "init", "2026-06-07", "session", "init", "2026-06-07",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--previous-session-id", "2026-05-31", "--previous-session-id", "2026-05-31",
"--date", "2026-06-07", "--date", "2026-06-07",
"--title", "The Black Cabin", "--title", "The Black Cabin",
@@ -276,7 +273,7 @@ inputs:
code := Execute([]string{ code := Execute([]string{
"session", "init", "2026-06-07", "session", "init", "2026-06-07",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -314,7 +311,7 @@ inputs:
code := Execute([]string{ code := Execute([]string{
"session", "init", "2026-06-07", "session", "init", "2026-06-07",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--output", outputPath, "--output", outputPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -344,7 +341,7 @@ inputs:
code := Execute([]string{ code := Execute([]string{
"session", "init", "2026-06-07", "session", "init", "2026-06-07",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
if code == 0 { if code == 0 {
@@ -369,7 +366,7 @@ inputs:
code := Execute([]string{ code := Execute([]string{
"session", "init", "2026-06-07", "session", "init", "2026-06-07",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--title", "Unused Title", "--title", "Unused Title",
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -396,7 +393,7 @@ inputs:
code := Execute([]string{ code := Execute([]string{
"session", "init", "2026-06-07", "session", "init", "2026-06-07",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
if code == 0 { if code == 0 {
@@ -441,7 +438,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) 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{ code := Execute([]string{
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--reason", "manual edit", "--reason", "manual edit",
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -483,7 +480,7 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
code = Execute([]string{ code = Execute([]string{
"session", "locks", "2026-05-03", "session", "locks", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -498,7 +495,7 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
code = Execute([]string{ code = Execute([]string{
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -528,7 +525,7 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
code := Execute([]string{ code := Execute([]string{
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--reason", "first", "--reason", "first",
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -541,7 +538,7 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
code = Execute([]string{ code = Execute([]string{
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--reason", "second", "--reason", "second",
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -557,7 +554,7 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
code = Execute([]string{ code = Execute([]string{
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--reason", "second", "--reason", "second",
"--force", "--force",
@@ -609,7 +606,7 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
code := Execute([]string{ code := Execute([]string{
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code == 0 { if code == 0 {
@@ -624,7 +621,7 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
code = Execute([]string{ code = Execute([]string{
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code == 0 { if code == 0 {
@@ -655,13 +652,11 @@ func TestExecuteTopLevelLockAndUnlockAreRemoved(t *testing.T) {
func withDefaultPipelineCampaignConfigs(t *testing.T, pipelinePath, campaignPath string) { func withDefaultPipelineCampaignConfigs(t *testing.T, pipelinePath, campaignPath string) {
t.Helper() t.Helper()
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
origCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath} config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
config.DefaultCampaignConfigSearchPaths = []string{campaignPath}
t.Cleanup(func() { t.Cleanup(func() {
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
config.DefaultCampaignConfigSearchPaths = origCampaignDefaults
}) })
_ = campaignPath
} }
func writeSessionInitTemplate(t *testing.T, campaignPath, templateYAML string) { func writeSessionInitTemplate(t *testing.T, campaignPath, templateYAML string) {
@@ -711,7 +706,7 @@ func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
code := Execute([]string{ code := Execute([]string{
"session", "artifacts", "2026-05-03", "session", "artifacts", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -747,7 +742,7 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
code := Execute([]string{ code := Execute([]string{
"session", "artifacts", "2026-05-03", "session", "artifacts", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -804,7 +799,7 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
code := Execute([]string{ code := Execute([]string{
"session", "status", "2026-05-03", "session", "status", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -848,7 +843,7 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
code := Execute([]string{ code := Execute([]string{
"session", "status", "2026-05-03", "session", "status", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -884,7 +879,7 @@ func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }

View File

@@ -23,12 +23,14 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&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(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") 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) == "" { if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("plan: session_id is required") 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, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })

View File

@@ -18,7 +18,7 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer 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 { if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("first Plan() error = %v", err) t.Fatalf("first Plan() error = %v", err)
@@ -74,7 +74,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
} }
var out bytes.Buffer 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) t.Fatalf("Plan() error = %v", err)
} }
got := out.String() got := out.String()
@@ -127,7 +127,7 @@ inputs:
} }
var out bytes.Buffer 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 { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }

View File

@@ -29,7 +29,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -77,7 +77,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -149,7 +149,7 @@ func TestExecuteRemoteSessionRequiresSessionID(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -177,7 +177,7 @@ func TestExecuteRemoteSessionStorageInitErrorFailsClearly(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -196,7 +196,7 @@ func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -219,7 +219,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -238,7 +238,7 @@ func TestExecuteRemoteSessionMismatchFails(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }

View File

@@ -29,6 +29,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
@@ -36,14 +37,15 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
var force bool var force bool
var includeAudio bool var includeAudio bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&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(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") 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(&dryRun, "dry-run", false, "plan restore actions without writing local files")
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state") fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects") fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
fs.Usage = func() { fs.Usage = func() {
_, _ = fmt.Fprintln(out, "Usage: narratio session restore <session_id> [--config <path>] [--campaign <path>] [--session <path>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]") _, _ = fmt.Fprintln(out, "Usage: narratio session restore <session_id> [--config <path>] [--campaign <id>] [--campaign-file <path>] [--session <path>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]")
_, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintln(out, "Flags:") _, _ = fmt.Fprintln(out, "Flags:")
fs.PrintDefaults() fs.PrintDefaults()
@@ -70,7 +72,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
if strings.TrimSpace(sessionID) == "" { if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("restore: session_id is required") 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, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })

View File

@@ -32,7 +32,7 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String()) 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() stdout.Reset()
stderr.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 { if code != 0 {
t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -232,7 +232,7 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) 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 stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -332,7 +332,7 @@ func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }

View File

@@ -81,7 +81,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
[]string{ []string{
"session", "restore", "2026-05-03", "session", "restore", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--dry-run", "--dry-run",
"--force", "--force",
@@ -123,7 +123,7 @@ func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -145,7 +145,7 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -173,7 +173,7 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -264,7 +264,7 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
[]string{ []string{
"session", "restore", "2026-05-03", "session", "restore", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--dry-run", "--dry-run",
}, },
@@ -313,7 +313,7 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -365,7 +365,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }

View File

@@ -48,7 +48,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
[]string{ []string{
"session", "restore", cfg.Session.SessionID, "session", "restore", cfg.Session.SessionID,
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, },
&stdout, &stdout,
@@ -86,7 +86,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
[]string{ []string{
"run-stage", "analyze", cfg.Session.SessionID, "run-stage", "analyze", cfg.Session.SessionID,
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--force", "--force",
"--artifacts", "player_handout", "--artifacts", "player_handout",
@@ -192,7 +192,7 @@ previous_session_id: 2026-04-26
[]string{ []string{
"session", "restore", cfg.Session.SessionID, "session", "restore", cfg.Session.SessionID,
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, },
&stdout, &stdout,
@@ -242,7 +242,7 @@ previous_session_id: 2026-04-26
[]string{ []string{
"run-stage", "analyze", cfg.Session.SessionID, "run-stage", "analyze", cfg.Session.SessionID,
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--force", "--force",
"--artifacts", "session_recap", "--artifacts", "session_recap",

View File

@@ -20,13 +20,15 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&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(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution") 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) == "" { if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("resume: session_id is required") 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, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })

View File

@@ -32,7 +32,7 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer 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 { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Resume() error = %v", err)
} }
@@ -64,7 +64,7 @@ func TestResumeNoRemainingStages(t *testing.T) {
} }
var out bytes.Buffer 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 { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Resume() error = %v", err)
} }
@@ -93,7 +93,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
} }
var out bytes.Buffer 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 { if err != nil {
t.Fatalf("Resume() error = %v", err) 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") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer 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 { if err != nil {
t.Fatalf("RunStage() error = %v", err) t.Fatalf("RunStage() error = %v", err)
} }
@@ -148,7 +148,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
} }
var out bytes.Buffer 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 { if err != nil {
t.Fatalf("RunStage() error = %v", err) t.Fatalf("RunStage() error = %v", err)
} }
@@ -157,7 +157,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
} }
out.Reset() 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 { if err != nil {
t.Fatalf("RunStage(force) error = %v", err) t.Fatalf("RunStage(force) error = %v", err)
} }
@@ -184,7 +184,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
} }
var out bytes.Buffer 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 { if err != nil {
t.Fatalf("RunStage(force) error = %v", err) t.Fatalf("RunStage(force) error = %v", err)
} }
@@ -203,7 +203,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
} }
out.Reset() 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 { if err != nil {
t.Fatalf("Resume() error = %v", err) 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}]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
var out bytes.Buffer 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 { if err != nil {
t.Fatalf("RunStage(trim) error = %v", err) 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}]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1},{"id":2}]}`)
var out bytes.Buffer 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 { if err != nil {
t.Fatalf("RunStage(normalize) error = %v", err) t.Fatalf("RunStage(normalize) error = %v", err)
} }

View File

@@ -18,13 +18,15 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&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(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") 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) == "" { if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("run: session_id is required") 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, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })

View File

@@ -25,13 +25,15 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&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(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") 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, StageName: stageName,
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
CampaignFilePath: campaignFilePath,
SessionPath: sessionPath, SessionPath: sessionPath,
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
@@ -100,12 +103,14 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&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(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") 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)") 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", StageName: "analyze",
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
CampaignFilePath: campaignFilePath,
SessionPath: sessionPath, SessionPath: sessionPath,
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
@@ -166,12 +172,14 @@ func Publish(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&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(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to publish (comma-separated or repeatable)") 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", StageName: "archive",
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
CampaignFilePath: campaignFilePath,
SessionPath: sessionPath, SessionPath: sessionPath,
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
@@ -229,6 +238,7 @@ type singleStageCommand struct {
StageName string StageName string
PipelinePath string PipelinePath string
CampaignPath string CampaignPath string
CampaignFilePath string
SessionPath string SessionPath string
SessionID string SessionID string
PreviousSessionID string PreviousSessionID string
@@ -242,7 +252,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
return nil, fmt.Errorf("%s: %w", req.CommandName, err) 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, SessionID: req.SessionID,
PreviousSessionID: req.PreviousSessionID, PreviousSessionID: req.PreviousSessionID,
}) })

View File

@@ -1015,7 +1015,7 @@ func testConfig(t *testing.T) *config.Config {
pipelinePath := filepath.Join(cfgDir, "pipeline.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") 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, 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, "speakers.yml"), "alice: alice.flac\n")
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
@@ -1024,7 +1024,7 @@ func testConfig(t *testing.T) *config.Config {
return &config.Config{ return &config.Config{
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
SessionPath: sessionPath, SessionPath: sessionPath,
@@ -1070,7 +1070,7 @@ whisperx:
notification: notification:
timeout: 10s timeout: 10s
` `
campaignYAML := `campaign: sample-campaign campaignYAML := `campaign_id: sample-campaign
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml

View File

@@ -35,7 +35,7 @@ inputs:
err := Plan(context.Background(), []string{ err := Plan(context.Background(), []string{
"2026-04-04", "2026-04-04",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--previous-session-id", "2026-03-28", "--previous-session-id", "2026-03-28",
}, &out) }, &out)
if err == nil { if err == nil {
@@ -54,7 +54,7 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer 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 { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
@@ -84,7 +84,7 @@ inputs:
err := Plan(context.Background(), []string{ err := Plan(context.Background(), []string{
"2026-05-03", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--previous-session-id", "2026-04-25", "--previous-session-id", "2026-04-25",
}, &out) }, &out)
@@ -101,7 +101,7 @@ func TestRunStageAcceptsPositionalSessionIDAndParsesStageName(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer 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 { if err != nil {
t.Fatalf("RunStage() error = %v", err) t.Fatalf("RunStage() error = %v", err)
} }

View File

@@ -36,7 +36,7 @@ func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
"run", "run",
"2026-05-03", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -60,7 +60,7 @@ func TestExecutePositionalSessionIDMismatchFails(t *testing.T) {
"run", "run",
"2026-05-04", "2026-05-04",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code == 0 { if code == 0 {
@@ -110,7 +110,7 @@ inputs:
"run", "run",
"2026-06-07", "2026-06-07",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -142,25 +142,25 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
}{ }{
{ {
name: "resume", 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", wantStage: "prepare",
wantForce: false, wantForce: false,
}, },
{ {
name: "analyze", 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", wantStage: "analyze",
wantForce: true, wantForce: true,
}, },
{ {
name: "publish", 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", wantStage: "archive",
wantForce: true, wantForce: true,
}, },
{ {
name: "run-stage", 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", wantStage: "archive",
wantForce: false, wantForce: false,
}, },
@@ -225,27 +225,27 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
}{ }{
{ {
name: "validate", 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", want: "OK config",
}, },
{ {
name: "status", 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", want: "Session: 2026-05-03",
}, },
{ {
name: "plan", 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", want: "narratio session plan: workdir prepared",
}, },
{ {
name: "artifacts", 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:", want: "Built-in:",
}, },
{ {
name: "locks", 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:", want: "Archive locks:",
}, },
} }
@@ -275,7 +275,7 @@ func TestExecuteSessionInitAcceptsPositionalSessionID(t *testing.T) {
code := Execute([]string{ code := Execute([]string{
"session", "init", "2026-06-07", "session", "init", "2026-06-07",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--output", outputPath, "--output", outputPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -302,7 +302,7 @@ func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) {
code := Execute([]string{ code := Execute([]string{
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--reason", "review", "--reason", "review",
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -319,7 +319,7 @@ func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) {
code = Execute([]string{ code = Execute([]string{
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed", "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
@@ -344,7 +344,7 @@ func TestExecuteCleanAcceptsPositionalSessionID(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr 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 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }

View File

@@ -7,24 +7,67 @@ import (
"testing" "testing"
) )
func TestCampaignConfigDefaultSearchOrder(t *testing.T) { func TestPipelineCampaignRegistryStrictDecode(t *testing.T) {
want := []string{ dir := t.TempDir()
"/usr/local/etc/narratio/campaign.yml", pipelinePath := filepath.Join(dir, "pipeline.yml")
"/etc/narratio/campaign.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) { cfg, err := LoadPipeline(pipelinePath)
t.Fatalf("DefaultCampaignConfigSearchPaths = %#v, want %#v", DefaultCampaignConfigSearchPaths, want) if err != nil {
t.Fatalf("LoadPipeline() error = %v", err)
} }
for i := range want { if cfg.Campaigns.Root != "/srv/narratio/campaigns" {
if DefaultCampaignConfigSearchPaths[i] != want[i] { t.Fatalf("campaigns.root = %q", cfg.Campaigns.Root)
t.Fatalf("DefaultCampaignConfigSearchPaths[%d] = %q, want %q", i, DefaultCampaignConfigSearchPaths[i], want[i]) }
} 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) { func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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) { func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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) { func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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) { func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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) { func TestCampaignSessionMismatchFails(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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", "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) { func TestLoadMissingCampaignFileFails(t *testing.T) {
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(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", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml") missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")

View File

@@ -17,6 +17,7 @@ type Config struct {
// PipelineConfig contains durable pipeline-level settings. // PipelineConfig contains durable pipeline-level settings.
type PipelineConfig struct { type PipelineConfig struct {
Workspace WorkspaceConfig `yaml:"workspace"` Workspace WorkspaceConfig `yaml:"workspace"`
Campaigns CampaignsConfig `yaml:"campaigns"`
Storage StorageConfig `yaml:"storage"` Storage StorageConfig `yaml:"storage"`
Spool SpoolConfig `yaml:"spool"` Spool SpoolConfig `yaml:"spool"`
Cache CacheConfig `yaml:"cache"` Cache CacheConfig `yaml:"cache"`
@@ -31,9 +32,15 @@ type PipelineConfig struct {
Notification NotificationConfig `yaml:"notification"` 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. // CampaignConfig contains stable campaign-level identity and input defaults.
type CampaignConfig struct { type CampaignConfig struct {
Campaign string `yaml:"campaign"` CampaignID string `yaml:"campaign_id"`
SessionTemplateFile string `yaml:"session_template_file"` SessionTemplateFile string `yaml:"session_template_file"`
Inputs CampaignInputsConfig `yaml:"inputs"` Inputs CampaignInputsConfig `yaml:"inputs"`
} }

View File

@@ -7,14 +7,13 @@ import "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
const ( const (
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml" DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
DefaultPipelineConfigPathEtc = "/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" DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml"
DefaultSessionConfigPathEtc = "/etc/narratio/session.yml" DefaultSessionConfigPathEtc = "/etc/narratio/session.yml"
DefaultS3AccessKeyIDEnv = "OBJECT_STORAGE_KEY_ID" DefaultS3AccessKeyIDEnv = "OBJECT_STORAGE_KEY_ID"
DefaultS3SecretAccessKeyEnv = "OBJECT_STORAGE_KEY" DefaultS3SecretAccessKeyEnv = "OBJECT_STORAGE_KEY"
DefaultStorageS3RootPrefix = "dnd" DefaultStorageS3RootPrefix = "dnd"
DefaultWorkspaceRoot = "/var/lib/narratio" DefaultWorkspaceRoot = "/var/lib/narratio"
DefaultCampaignsRoot = "/usr/local/share/narratio/campaigns"
DefaultSpoolRoot = "/var/spool/narratio" DefaultSpoolRoot = "/var/spool/narratio"
DefaultCacheRoot = "/var/cache/narratio" DefaultCacheRoot = "/var/cache/narratio"
DefaultCacheS3Audio = true DefaultCacheS3Audio = true
@@ -93,16 +92,6 @@ var DefaultPipelineConfigSearchPaths = []string{
DefaultPipelineConfigPathEtc, 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 // DefaultSessionConfigSearchPaths defines the default search order for
// session.yml when callers do not provide an explicit path. // session.yml when callers do not provide an explicit path.
// //

View File

@@ -194,7 +194,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
return ResolvedStableInputs{}, fmt.Errorf("session config is required") return ResolvedStableInputs{}, fmt.Errorf("session config is required")
} }
campaignName := strings.TrimSpace(campaignCfg.Campaign) campaignName := CampaignID(campaignCfg)
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign) sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName { if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName {
return ResolvedStableInputs{}, fmt.Errorf( return ResolvedStableInputs{}, fmt.Errorf(
@@ -234,6 +234,14 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
return stable, nil 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 { func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath string) ResolvedInputFile {
if strings.TrimSpace(sessionValue) != "" { if strings.TrimSpace(sessionValue) != "" {
return ResolvedInputFile{ return ResolvedInputFile{
@@ -316,6 +324,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
return return
} }
applyWorkspaceDefaults(&cfg.Workspace) applyWorkspaceDefaults(&cfg.Workspace)
applyCampaignsDefaults(&cfg.Campaigns)
applyStorageDefaults(&cfg.Storage) applyStorageDefaults(&cfg.Storage)
applySpoolDefaults(&cfg.Spool) applySpoolDefaults(&cfg.Spool)
applyCacheDefaults(&cfg.Cache) applyCacheDefaults(&cfg.Cache)
@@ -331,6 +340,15 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
applyScriptoriumDefaults(cfg.Scriptorium) applyScriptoriumDefaults(cfg.Scriptorium)
} }
func applyCampaignsDefaults(cfg *CampaignsConfig) {
if cfg == nil {
return
}
if cfg.Root == "" {
cfg.Root = DefaultCampaignsRoot
}
}
func applyWorkspaceDefaults(cfg *WorkspaceConfig) { func applyWorkspaceDefaults(cfg *WorkspaceConfig) {
if cfg == nil { if cfg == nil {
return return

View File

@@ -916,7 +916,7 @@ func TestValidateMissingAudioSource(t *testing.T) {
Report: boolPtr(true), Report: boolPtr(true),
}, },
}, },
Campaign: &CampaignConfig{Campaign: "sample-campaign"}, Campaign: &CampaignConfig{CampaignID: "sample-campaign"},
Session: &SessionConfig{ Session: &SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",
Campaign: "sample-campaign", Campaign: "sample-campaign",
@@ -967,7 +967,7 @@ func TestExamplesLoadAndValidate(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
pipelinePath := filepath.Join(examplesDir, tt.pipelineFile) 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) sessionPath := filepath.Join(examplesDir, tt.sessionFile)
cfg, err := Load(pipelinePath, campaignPath, sessionPath) 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 { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)
} }
campaignYAML := `campaign: ` + campaignNameFromSessionYAML(sessionYAML) + ` campaignYAML := `campaign_id: ` + campaignNameFromSessionYAML(sessionYAML) + `
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml

View File

@@ -46,8 +46,8 @@ func validateCampaign(cfg *CampaignConfig) error {
if cfg == nil { if cfg == nil {
return fmt.Errorf("campaign config is required") return fmt.Errorf("campaign config is required")
} }
if strings.TrimSpace(cfg.Campaign) == "" { if CampaignID(cfg) == "" {
return fmt.Errorf("campaign.campaign is required") return fmt.Errorf("campaign.campaign_id is required")
} }
return nil return nil
} }

View File

@@ -33,7 +33,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
campaignPath := filepath.Join(cfgDir, "campaign.yml") campaignPath := filepath.Join(cfgDir, "campaign.yml")
pipelinePath := filepath.Join(cfgDir, "pipeline.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n") 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, pipelinePath, "workspace:\n root: "+root+"\n")
writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
@@ -52,7 +52,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
SessionPath: sessionPath, SessionPath: sessionPath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
Pipeline: &config.PipelineConfig{ Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: root}, Workspace: config.WorkspaceConfig{Root: root},
Storage: config.StorageConfig{ Storage: config.StorageConfig{

View File

@@ -608,7 +608,7 @@ func setupPrepareEnv(t *testing.T) (*Env, *manifest.Manifest) {
campaignPath := filepath.Join(cfgDir, "campaign.yml") campaignPath := filepath.Join(cfgDir, "campaign.yml")
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
writeFile(t, campaignPath, `campaign: sample-campaign writeFile(t, campaignPath, `campaign_id: sample-campaign
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
@@ -624,7 +624,7 @@ inputs:
Workspace: config.WorkspaceConfig{Root: workspace}, Workspace: config.WorkspaceConfig{Root: workspace},
Cache: config.CacheConfig{Root: filepath.Join(t.TempDir(), "cache"), S3Audio: boolPtr(true)}, 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, SessionPath: sessionPath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
PipelinePath: pipelinePath, PipelinePath: pipelinePath,

View File

@@ -236,7 +236,7 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
campaignPath := filepath.Join(cfgDir, "campaign.yml") campaignPath := filepath.Join(cfgDir, "campaign.yml")
writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n") writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n")
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\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, "speakers.yml"), "alice: alice.flac\n")
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "glossary.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, PipelinePath: pipelinePath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
SessionPath: sessionPath, SessionPath: sessionPath,
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
Pipeline: &config.PipelineConfig{ Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: workspace}, Workspace: config.WorkspaceConfig{Root: workspace},
WhisperX: config.WhisperXConfig{ WhisperX: config.WhisperXConfig{