Implemented multiple campaign support via a campaign directory registry with explicit campaign IDs
This commit is contained in:
45
docs/cli.md
45
docs/cli.md
@@ -6,9 +6,9 @@
|
||||
narratio run 2026-04-04
|
||||
```
|
||||
|
||||
This command uses default system discovery for `pipeline.yml`, `campaign.yml`, and local `session.yml`. If local session discovery misses and S3 storage is configured, the positional session ID loads remote `session.yml` from the canonical session prefix.
|
||||
This command uses default system discovery for `pipeline.yml`, the pipeline default campaign ID, and local `session.yml`. If local session discovery misses and S3 storage is configured, the positional session ID loads remote `session.yml` from the canonical session prefix.
|
||||
|
||||
Default discovery checks system config locations only. Pass `--config`, `--campaign`, and `--session` to use files from the current working directory.
|
||||
Default pipeline and session discovery checks system config locations only. Pass `--config`, `--campaign-file`, and `--session` to use files from the current working directory. Pass `--campaign <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.
|
||||
|
||||
@@ -45,7 +45,8 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
|
||||
Most session-aware commands accept:
|
||||
|
||||
- `--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.
|
||||
- `--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`
|
||||
|
||||
```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:
|
||||
@@ -66,7 +67,8 @@ Success output:
|
||||
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
|
||||
|
||||
Common failure cases:
|
||||
- missing system default config/campaign/session paths when flags are omitted.
|
||||
- missing system default config/session paths when flags are omitted.
|
||||
- missing selected campaign under `pipeline.campaigns.root`.
|
||||
- missing local session plus missing/unavailable remote `session.yml`.
|
||||
- templated `session.yml`; run `narratio session init` to generate concrete YAML.
|
||||
- concrete session identity mismatch.
|
||||
@@ -75,7 +77,7 @@ Common failure cases:
|
||||
### `resume`
|
||||
|
||||
```bash
|
||||
narratio resume <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:
|
||||
@@ -88,7 +90,7 @@ Success output:
|
||||
### `run-stage`
|
||||
|
||||
```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:
|
||||
@@ -111,7 +113,7 @@ Success output:
|
||||
### `analyze`
|
||||
|
||||
```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:
|
||||
@@ -123,7 +125,7 @@ Purpose:
|
||||
### `publish`
|
||||
|
||||
```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:
|
||||
@@ -135,7 +137,7 @@ Purpose:
|
||||
### `clean`
|
||||
|
||||
```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]
|
||||
```
|
||||
|
||||
@@ -156,7 +158,7 @@ Cache behavior:
|
||||
### `session plan`
|
||||
|
||||
```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:
|
||||
@@ -170,7 +172,7 @@ Success output includes:
|
||||
### `session status`
|
||||
|
||||
```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:
|
||||
@@ -183,7 +185,7 @@ Output includes:
|
||||
### `session validate`
|
||||
|
||||
```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:
|
||||
@@ -200,7 +202,8 @@ Warnings do not fail the command. Any `ERROR` finding exits non-zero.
|
||||
```bash
|
||||
narratio session init <session_id> --output ./session.yml
|
||||
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:
|
||||
@@ -214,7 +217,9 @@ Additional flags:
|
||||
|
||||
Behavior:
|
||||
- exactly one of `--output` or `--remote` is required.
|
||||
- `--config` and `--campaign` are optional overrides; omitted values use normal default config discovery.
|
||||
- `--config`, `--campaign`, and `--campaign-file` are optional overrides; omitted campaign selection uses `pipeline.campaigns.default_campaign_id`.
|
||||
- `--campaign <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 no session template is configured, a minimal concrete session file is generated directly.
|
||||
- template variables must be supplied by matching flags, and supplied template-related flags must be used by the template.
|
||||
@@ -225,7 +230,7 @@ Behavior:
|
||||
### `session restore`
|
||||
|
||||
```bash
|
||||
narratio session restore <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:
|
||||
@@ -241,7 +246,7 @@ When `--include-audio` is set, S3 audio files are restored through the shared au
|
||||
### `session artifacts`
|
||||
|
||||
```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:
|
||||
@@ -252,9 +257,9 @@ Purpose:
|
||||
### `session locks`
|
||||
|
||||
```bash
|
||||
narratio session locks <session_id> [--config <pipeline.yml>] [--campaign <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 remove <session_id> <source> [--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 <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 <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
@@ -28,6 +28,7 @@ Behavior:
|
||||
- strict YAML decode is enabled (`KnownFields(true)`): unknown fields fail.
|
||||
- ordinary local and remote `session.yml` files must be concrete YAML; template placeholders are rejected.
|
||||
- defaults are applied for optional pipeline fields.
|
||||
- campaign identity is selected by ID from the pipeline campaign registry unless `--campaign-file` is used.
|
||||
- campaign-level stable input paths fill missing session input paths.
|
||||
- session-level stable input paths override campaign-level input paths.
|
||||
- campaign config may point `session init` to a session template.
|
||||
@@ -60,11 +61,14 @@ Pipeline config lookup:
|
||||
|
||||
Campaign config lookup:
|
||||
|
||||
- if `--campaign <path>` is provided, that path is used.
|
||||
- if omitted, Narratio searches in order:
|
||||
1. `/usr/local/etc/narratio/campaign.yml`
|
||||
2. `/etc/narratio/campaign.yml`
|
||||
- first existing file wins.
|
||||
- pipeline config is loaded first.
|
||||
- if `--campaign-file <path>` is provided, that path is used.
|
||||
- otherwise, if `--campaign <id>` is provided, Narratio loads:
|
||||
- `{pipeline.campaigns.root}/{id}/campaign.yml`
|
||||
- otherwise, Narratio uses `pipeline.campaigns.default_campaign_id` and loads:
|
||||
- `{pipeline.campaigns.root}/{default_campaign_id}/campaign.yml`
|
||||
- `--campaign` and `--campaign-file` are mutually exclusive.
|
||||
- campaign IDs must be single path segments, not paths.
|
||||
|
||||
Session config lookup:
|
||||
|
||||
@@ -76,7 +80,7 @@ Session config lookup:
|
||||
- if no local session file is found, a positional `<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`
|
||||
- local discovery always runs before remote fallback.
|
||||
- local files in the current working directory are used only when passed explicitly, for example `--config ./pipeline.yml --campaign ./campaign.yml --session ./session.yml`.
|
||||
- local files in the current working directory are used only when passed explicitly, for example `--config ./pipeline.yml --campaign-file ./campaign.yml --session ./session.yml`.
|
||||
|
||||
## 3. Session templating
|
||||
|
||||
@@ -109,6 +113,9 @@ Template behavior for `narratio session init`:
|
||||
### `pipeline.yml`
|
||||
|
||||
```yaml
|
||||
campaigns:
|
||||
root: /usr/local/share/narratio/campaigns
|
||||
default_campaign_id: sample-campaign
|
||||
whisperx:
|
||||
transcribe_url: "https://transcription.example.com/transcribe"
|
||||
```
|
||||
@@ -116,13 +123,14 @@ whisperx:
|
||||
Why this is sufficient:
|
||||
|
||||
- `whisperx.transcribe_url` is required.
|
||||
- `campaigns.default_campaign_id` selects the default campaign when `--campaign` is omitted.
|
||||
- `workspace.root` defaults to `/var/lib/narratio`.
|
||||
- optional sections (`seriatim`, `audita`, `archive`, `scriptorium`, `trim`, `normalize`, etc.) receive defaults or stay inactive.
|
||||
|
||||
### `campaign.yml`
|
||||
|
||||
```yaml
|
||||
campaign: sample-campaign
|
||||
campaign_id: sample-campaign
|
||||
session_template_file: ./session.template.yml
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
@@ -132,7 +140,7 @@ inputs:
|
||||
|
||||
Why this is sufficient:
|
||||
|
||||
- `campaign` supplies the stable campaign identity.
|
||||
- `campaign_id` supplies the stable campaign identity.
|
||||
- stable input files are required and resolve relative to `campaign.yml` when copied during `prepare`.
|
||||
|
||||
### `session.yml`
|
||||
@@ -153,7 +161,8 @@ Why this is sufficient:
|
||||
Minimal local-file usage:
|
||||
|
||||
```bash
|
||||
narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml
|
||||
narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign sample-campaign --session ./session.yml
|
||||
narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign-file ./campaign.yml --session ./session.yml
|
||||
```
|
||||
|
||||
Previous-session-enabled variant:
|
||||
@@ -166,7 +175,7 @@ inputs:
|
||||
```
|
||||
|
||||
```bash
|
||||
narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml --previous-session-id 2026-04-26
|
||||
narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign sample-campaign --session ./session.yml --previous-session-id 2026-04-26
|
||||
```
|
||||
|
||||
## 5. Production-oriented config set
|
||||
@@ -187,6 +196,10 @@ storage:
|
||||
access_key_id_env: OBJECT_STORAGE_KEY_ID
|
||||
secret_access_key_env: OBJECT_STORAGE_KEY
|
||||
|
||||
campaigns:
|
||||
root: /srv/narratio/campaigns
|
||||
default_campaign_id: forsaken
|
||||
|
||||
spool:
|
||||
root: /var/spool/narratio
|
||||
delete_audio_after_archive: true
|
||||
@@ -230,7 +243,7 @@ scriptorium:
|
||||
### `campaign.yml`
|
||||
|
||||
```yaml
|
||||
campaign: forsaken
|
||||
campaign_id: forsaken
|
||||
inputs:
|
||||
speakers_file: /srv/narratio/campaigns/forsaken/speakers.yml
|
||||
autocorrect_file: /srv/narratio/campaigns/forsaken/autocorrect.yml
|
||||
@@ -260,7 +273,7 @@ For S3-first operation, upload the same `session.yml` content to:
|
||||
Then run with explicit or discovered pipeline/campaign config and no `--session`:
|
||||
|
||||
```bash
|
||||
narratio run 2026-05-03 --config /usr/local/etc/narratio/pipeline.yml --campaign /usr/local/etc/narratio/campaign.yml --previous-session-id 2026-04-26
|
||||
narratio run 2026-05-03 --config /usr/local/etc/narratio/pipeline.yml --campaign forsaken --previous-session-id 2026-04-26
|
||||
```
|
||||
|
||||
Operational notes:
|
||||
@@ -279,6 +292,8 @@ Operational notes:
|
||||
| --- | --- | --- | --- |
|
||||
| `pipeline.workspace.root` | string | No | `/var/lib/narratio` |
|
||||
| `pipeline.workspace.cleanup_after_archive` | bool | No | `false` |
|
||||
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
|
||||
| `pipeline.campaigns.default_campaign_id` | string | No | empty |
|
||||
| `pipeline.secrets.env_dir` | string | Conditional | none |
|
||||
| `pipeline.storage.backend` | string | No | empty |
|
||||
| `pipeline.storage.s3.bucket` | string | Conditional | empty |
|
||||
@@ -434,7 +449,7 @@ Restore-related implications:
|
||||
|
||||
| Path | Type | Required | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| `campaign.campaign` | string | Yes | none |
|
||||
| `campaign.campaign_id` | string | Yes | none |
|
||||
| `campaign.session_template_file` | string | No | none |
|
||||
| `campaign.inputs.speakers_file` | string | Yes | none |
|
||||
| `campaign.inputs.autocorrect_file` | string | Yes | none |
|
||||
@@ -448,7 +463,7 @@ Campaign input paths and `campaign.session_template_file` may be absolute or rel
|
||||
| --- | --- | --- | --- |
|
||||
| `session.session_id` | string | Yes | none |
|
||||
| `session.previous_session_id` | string | No | empty |
|
||||
| `session.campaign` | string | No | `campaign.campaign` |
|
||||
| `session.campaign` | string | No | `campaign.campaign_id` |
|
||||
| `session.date` | string | No | empty |
|
||||
| `session.title` | string | No | empty |
|
||||
| `session.inputs.audio_dir` | string | Conditional | empty |
|
||||
@@ -499,8 +514,11 @@ Maintained examples:
|
||||
- `examples/pipeline.minimal.yml`
|
||||
- `examples/pipeline.production.yml`
|
||||
- `examples/pipeline.full.annotated.yml`
|
||||
- `examples/campaign.yml`
|
||||
- `examples/session.template.yml`
|
||||
- `examples/campaigns/sample-campaign/campaign.yml`
|
||||
- `examples/campaigns/sample-campaign/speakers.yml`
|
||||
- `examples/campaigns/sample-campaign/autocorrect.yml`
|
||||
- `examples/campaigns/sample-campaign/glossary.yml`
|
||||
- `examples/campaigns/sample-campaign/session.template.yml`
|
||||
- `examples/session.local-audio.yml`
|
||||
- `examples/session.s3-audio.yml`
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Define the implemented `narratio session restore` contract: committed remote-sta
|
||||
## Inputs and outputs
|
||||
Inputs:
|
||||
- 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`.
|
||||
- Configured remote object store.
|
||||
- Remote committed current-state markers (`current/run_id.txt`, `current/manifest.json`).
|
||||
|
||||
@@ -62,7 +62,7 @@ Does not own:
|
||||
- `pipeline.scriptorium.artifacts.<name>.enabled`
|
||||
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source`
|
||||
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required`
|
||||
- `campaign.campaign`
|
||||
- `campaign.campaign_id`
|
||||
- `campaign.inputs.speakers_file`
|
||||
- `campaign.inputs.autocorrect_file`
|
||||
- `campaign.inputs.glossary_file`
|
||||
|
||||
@@ -19,7 +19,7 @@ narratio run 2026-04-04
|
||||
- use `narratio session status <session_id>` for inspection.
|
||||
|
||||
Notes:
|
||||
- default config/campaign/session discovery checks system config locations unless `--config`, `--campaign`, and `--session` are passed.
|
||||
- default pipeline/session discovery checks system config locations; campaign selection uses `pipeline.campaigns.default_campaign_id` unless `--campaign <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`.
|
||||
- S3 audio mode requires `session.inputs.audio_s3.prefix` and valid object-store access.
|
||||
|
||||
@@ -29,7 +29,7 @@ Initialize a remote session skeleton:
|
||||
narratio session init 2026-04-04 --remote
|
||||
```
|
||||
|
||||
Remote init uses normal default config discovery and writes `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. If `campaign.yml` sets `session_template_file`, init renders that template from the supplied flags and writes concrete YAML. Pass `--config` and `--campaign` when testing non-system config files. It fails if the object already exists unless `--force` is passed.
|
||||
Remote init uses normal default config discovery and writes `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. If `campaign.yml` sets `session_template_file`, init renders that template from the supplied flags and writes concrete YAML. Pass `--config`, `--campaign <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:
|
||||
|
||||
|
||||
231
docs/roadmap/campaign.md
Normal file
231
docs/roadmap/campaign.md
Normal 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>`.
|
||||
@@ -9,20 +9,20 @@ Symptom:
|
||||
- `run`, `resume`, `run-stage`, `session plan`, or `session restore` fails with config/session not found.
|
||||
|
||||
Likely Cause:
|
||||
- `pipeline.yml`, `campaign.yml`, or `session.yml` is missing from system discovery paths.
|
||||
- `pipeline.yml` or `session.yml` is missing from system discovery paths.
|
||||
- the selected campaign ID does not exist under `pipeline.campaigns.root`.
|
||||
- a local working-directory config file was not passed explicitly.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
ls -l /usr/local/etc/narratio/pipeline.yml /etc/narratio/pipeline.yml
|
||||
ls -l /usr/local/etc/narratio/campaign.yml /etc/narratio/campaign.yml
|
||||
ls -l /usr/local/etc/narratio/session.yml /etc/narratio/session.yml
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- pass explicit `--config`, `--campaign`, and `--session`.
|
||||
- or place files in documented discovery paths.
|
||||
- pass explicit `--config`, `--campaign <id>`, `--campaign-file <path>`, and `--session` as appropriate.
|
||||
- or place files in documented discovery paths and set `pipeline.campaigns.default_campaign_id`.
|
||||
|
||||
Links:
|
||||
- [docs/config.md](./config.md)
|
||||
@@ -40,7 +40,7 @@ Likely Cause:
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session ./session.yml
|
||||
narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session ./session.yml
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
@@ -62,7 +62,7 @@ Likely Cause:
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml
|
||||
narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
@@ -84,7 +84,7 @@ Likely Cause:
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio run 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout
|
||||
narratio run 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
@@ -106,7 +106,7 @@ Likely Cause:
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio run-stage polish 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --artifacts session_recap
|
||||
narratio run-stage polish 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --artifacts session_recap
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
@@ -129,7 +129,7 @@ Likely Cause:
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml
|
||||
narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
@@ -154,7 +154,7 @@ Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session status 2026-04-04
|
||||
narratio run-stage analyze 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout
|
||||
narratio run-stage analyze 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
@@ -226,7 +226,7 @@ Likely Cause:
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --dry-run
|
||||
narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --dry-run
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
@@ -249,7 +249,7 @@ Likely Cause:
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --dry-run
|
||||
narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --dry-run
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
@@ -271,7 +271,7 @@ Likely Cause:
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --dry-run
|
||||
narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --dry-run
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
@@ -341,7 +341,7 @@ Likely Cause:
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio run-stage prepare 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml
|
||||
narratio run-stage prepare 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
@@ -365,7 +365,7 @@ Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session status 2026-04-04
|
||||
narratio run-stage archive 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml
|
||||
narratio run-stage archive 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
|
||||
1
examples/campaigns/sample-campaign/autocorrect.yml
Normal file
1
examples/campaigns/sample-campaign/autocorrect.yml
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -1,4 +1,4 @@
|
||||
campaign: sample-campaign
|
||||
campaign_id: sample-campaign
|
||||
session_template_file: ./session.template.yml
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
1
examples/campaigns/sample-campaign/glossary.yml
Normal file
1
examples/campaigns/sample-campaign/glossary.yml
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
3
examples/campaigns/sample-campaign/session.template.yml
Normal file
3
examples/campaigns/sample-campaign/session.template.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
session_id: "{{ session_id }}"
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
5
examples/campaigns/sample-campaign/speakers.yml
Normal file
5
examples/campaigns/sample-campaign/speakers.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
match:
|
||||
- speaker: "Eric Rakestraw"
|
||||
match:
|
||||
- "Eric_Rakestraw"
|
||||
- "Eric"
|
||||
@@ -27,6 +27,12 @@ storage:
|
||||
access_key_id_env: OBJECT_STORAGE_KEY_ID
|
||||
secret_access_key_env: OBJECT_STORAGE_KEY
|
||||
|
||||
campaigns:
|
||||
# Optional; defaults to /usr/local/share/narratio/campaigns.
|
||||
root: /usr/local/share/narratio/campaigns
|
||||
# Optional command default when --campaign is omitted.
|
||||
default_campaign_id: sample-campaign
|
||||
|
||||
spool:
|
||||
# Optional; defaults to /var/spool/narratio.
|
||||
root: /var/spool/narratio
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
campaigns:
|
||||
root: /usr/local/share/narratio/campaigns
|
||||
default_campaign_id: sample-campaign
|
||||
|
||||
whisperx:
|
||||
transcribe_url: "https://transcription.example.com/transcribe"
|
||||
|
||||
@@ -11,6 +11,10 @@ storage:
|
||||
access_key_id_env: OBJECT_STORAGE_KEY_ID
|
||||
secret_access_key_env: OBJECT_STORAGE_KEY
|
||||
|
||||
campaigns:
|
||||
root: /usr/local/share/narratio/campaigns
|
||||
default_campaign_id: sample-campaign
|
||||
|
||||
spool:
|
||||
root: /var/spool/narratio
|
||||
delete_audio_after_archive: true
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute(
|
||||
[]string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
[]string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
&stdout,
|
||||
&stderr,
|
||||
)
|
||||
@@ -57,7 +57,7 @@ func TestExecuteRunStageArchivePropagatesSelectedArtifacts(t *testing.T) {
|
||||
[]string{
|
||||
"run-stage", "archive", "2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--artifacts", "session_recap",
|
||||
},
|
||||
@@ -82,7 +82,7 @@ func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute(
|
||||
[]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
||||
[]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
||||
&stdout,
|
||||
&stderr,
|
||||
)
|
||||
@@ -109,7 +109,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
err := RunStage(
|
||||
context.Background(),
|
||||
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap"},
|
||||
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap"},
|
||||
&out,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -137,7 +137,7 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
err := Resume(
|
||||
context.Background(),
|
||||
[]string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
&out,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -172,7 +172,7 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute(
|
||||
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
|
||||
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
&stdout,
|
||||
&stderr,
|
||||
)
|
||||
@@ -211,7 +211,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
|
||||
"analyze",
|
||||
"2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--artifacts", "player_handout,session_recap",
|
||||
},
|
||||
@@ -233,7 +233,7 @@ func TestExecuteAnalyzeUnknownArtifactFailsValidation(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute(
|
||||
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
||||
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
||||
&stdout,
|
||||
&stderr,
|
||||
)
|
||||
@@ -307,7 +307,7 @@ func TestExecutePublishForceRunsArchive(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute(
|
||||
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
&stdout,
|
||||
&stderr,
|
||||
)
|
||||
@@ -359,7 +359,7 @@ func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute(
|
||||
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
||||
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
||||
&stdout,
|
||||
&stderr,
|
||||
)
|
||||
|
||||
@@ -1,49 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func resolveCampaignConfigPath(flagValue string) (string, error) {
|
||||
return resolveCampaignConfigPathWithCandidates(flagValue, config.DefaultCampaignConfigSearchPaths)
|
||||
func resolveCampaignConfigPath(pipelineCfg *config.PipelineConfig, campaignIDFlag, campaignFileFlag string) (string, error) {
|
||||
campaignID := strings.TrimSpace(campaignIDFlag)
|
||||
campaignFile := strings.TrimSpace(campaignFileFlag)
|
||||
if campaignID != "" && campaignFile != "" {
|
||||
return "", fmt.Errorf("--campaign and --campaign-file are mutually exclusive")
|
||||
}
|
||||
if campaignFile != "" {
|
||||
return filepath.Clean(campaignFile), nil
|
||||
}
|
||||
if campaignID == "" && pipelineCfg != nil {
|
||||
campaignID = strings.TrimSpace(pipelineCfg.Campaigns.DefaultCampaignID)
|
||||
}
|
||||
if campaignID == "" {
|
||||
return "", fmt.Errorf("no campaign selected; pass --campaign <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) {
|
||||
if explicit := strings.TrimSpace(flagValue); explicit != "" {
|
||||
return explicit, nil
|
||||
func validateCampaignIDToken(campaignID string) error {
|
||||
if filepath.IsAbs(campaignID) ||
|
||||
strings.Contains(campaignID, "/") ||
|
||||
strings.Contains(campaignID, `\`) ||
|
||||
campaignID == "." ||
|
||||
campaignID == ".." {
|
||||
return fmt.Errorf("campaign id %q must be a single path segment", campaignID)
|
||||
}
|
||||
|
||||
ordered := make([]string, 0, len(candidates))
|
||||
for _, raw := range candidates {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, path)
|
||||
info, err := os.Stat(path)
|
||||
if err == nil {
|
||||
if info.IsDir() {
|
||||
continue
|
||||
}
|
||||
return filepath.Clean(path), nil
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("check default campaign config %q: %w", path, err)
|
||||
}
|
||||
|
||||
if len(ordered) == 0 {
|
||||
return "", fmt.Errorf("no campaign config path provided and no default locations configured")
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"no campaign config path provided and no default campaign config found; searched: %s; pass --campaign to use an explicit path",
|
||||
strings.Join(ordered, ", "),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,49 +1,84 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestResolveCampaignConfigPathExplicitWins(t *testing.T) {
|
||||
func TestResolveCampaignConfigPathCampaignFileWins(t *testing.T) {
|
||||
explicit := filepath.Join(t.TempDir(), "custom-campaign.yml")
|
||||
got, err := resolveCampaignConfigPathWithCandidates(explicit, []string{filepath.Join(t.TempDir(), "campaign.yml")})
|
||||
got, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "", explicit)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err)
|
||||
t.Fatalf("resolveCampaignConfigPath() error = %v", err)
|
||||
}
|
||||
if got != explicit {
|
||||
t.Fatalf("path = %q, want explicit path %q", got, explicit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCampaignConfigPathUsesFirstExistingDefault(t *testing.T) {
|
||||
func TestResolveCampaignConfigPathUsesSelectedCampaignID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "missing.yml")
|
||||
found := filepath.Join(dir, "campaign.yml")
|
||||
if err := os.WriteFile(found, []byte("campaign: sample-campaign\n"), 0o644); err != nil {
|
||||
t.Fatalf("write campaign.yml: %v", err)
|
||||
}
|
||||
pipelineCfg := &config.PipelineConfig{}
|
||||
pipelineCfg.Campaigns.Root = dir
|
||||
|
||||
got, err := resolveCampaignConfigPathWithCandidates("", []string{missing, found})
|
||||
got, err := resolveCampaignConfigPath(pipelineCfg, "icewind", "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err)
|
||||
t.Fatalf("resolveCampaignConfigPath() error = %v", err)
|
||||
}
|
||||
if got != filepath.Clean(found) {
|
||||
t.Fatalf("path = %q, want %q", got, filepath.Clean(found))
|
||||
want := filepath.Join(dir, "icewind", "campaign.yml")
|
||||
if got != filepath.Clean(want) {
|
||||
t.Fatalf("path = %q, want %q", got, filepath.Clean(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCampaignConfigPathErrorIncludesSearchedPaths(t *testing.T) {
|
||||
_, err := resolveCampaignConfigPathWithCandidates("", []string{"/usr/local/etc/narratio/campaign.yml", "/etc/narratio/campaign.yml"})
|
||||
func TestResolveCampaignConfigPathUsesDefaultCampaignID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pipelineCfg := &config.PipelineConfig{}
|
||||
pipelineCfg.Campaigns.Root = dir
|
||||
pipelineCfg.Campaigns.DefaultCampaignID = "dilfs"
|
||||
|
||||
got, err := resolveCampaignConfigPath(pipelineCfg, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveCampaignConfigPath() error = %v", err)
|
||||
}
|
||||
want := filepath.Join(dir, "dilfs", "campaign.yml")
|
||||
if got != filepath.Clean(want) {
|
||||
t.Fatalf("path = %q, want %q", got, filepath.Clean(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCampaignConfigPathRejectsCampaignIDAndFile(t *testing.T) {
|
||||
_, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "dilfs", filepath.Join(t.TempDir(), "campaign.yml"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "searched") {
|
||||
t.Fatalf("error = %q, want searched paths", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pass --campaign") {
|
||||
t.Fatalf("error = %q, want explicit-campaign guidance", err.Error())
|
||||
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("error = %q, want mutual exclusion", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCampaignConfigPathRequiresCampaignSelection(t *testing.T) {
|
||||
_, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no campaign selected") {
|
||||
t.Fatalf("error = %q, want missing selection guidance", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCampaignConfigPathRejectsPathLikeCampaignID(t *testing.T) {
|
||||
pipelineCfg := &config.PipelineConfig{}
|
||||
pipelineCfg.Campaigns.Root = t.TempDir()
|
||||
|
||||
_, err := resolveCampaignConfigPath(pipelineCfg, "../icewind", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "single path segment") {
|
||||
t.Fatalf("error = %q, want path segment guidance", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCac
|
||||
if strings.TrimSpace(flags.sessionID) == "" {
|
||||
return fmt.Errorf("clean: session_id is required unless --all is set")
|
||||
}
|
||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions())
|
||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||
if err != nil {
|
||||
return fmt.Errorf("clean: %w", err)
|
||||
}
|
||||
@@ -92,10 +92,11 @@ func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCac
|
||||
|
||||
func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error {
|
||||
if strings.TrimSpace(flags.campaignPath) != "" ||
|
||||
strings.TrimSpace(flags.campaignFilePath) != "" ||
|
||||
strings.TrimSpace(flags.sessionPath) != "" ||
|
||||
strings.TrimSpace(flags.sessionID) != "" ||
|
||||
strings.TrimSpace(flags.previousSessionID) != "" {
|
||||
return fmt.Errorf("clean: --all cannot be combined with --campaign, --session, a session_id, or --previous-session-id")
|
||||
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
|
||||
}
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestExecuteCleanSessionDeletesWorkAndSpoolButPreservesCache(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -48,7 +48,7 @@ func TestExecuteCleanSessionDryRunDeletesNothing(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestExecuteCleanMissingSessionPathsSucceeds(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -105,7 +105,7 @@ inputs:
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr)
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func TestExecuteCleanLocalAudioClearCacheIsNoop(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr)
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func TestExecuteCleanAllRejectsSessionScopedFlags(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--all"}, &stdout, &stderr)
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--all"}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ func TestExecuteValidCommands(t *testing.T) {
|
||||
args []string
|
||||
wantOut string
|
||||
}{
|
||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
|
||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"},
|
||||
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
||||
{name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
|
||||
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
|
||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"},
|
||||
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
||||
{name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
|
||||
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
@@ -98,7 +98,7 @@ func TestExecuteRunStageUnknownFails(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := Execute([]string{"run-stage", "unknown", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"run-stage", "unknown", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := Execute([]string{"run-stage", "normalize", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"run-stage", "normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -140,14 +140,14 @@ func TestExecuteRunStageTranscribeUsesConfiguredWhisperXServer(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := Execute([]string{"run-stage", "prepare", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"run-stage", "prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
|
||||
code = Execute([]string{"run-stage", "transcribe", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
code = Execute([]string{"run-stage", "transcribe", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -237,7 +237,7 @@ inputs:
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"run-stage", "polish", sessionID, "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
code := Execute([]string{"run-stage", "polish", sessionID, "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -285,7 +285,7 @@ inputs:
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -304,13 +304,11 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
|
||||
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
||||
originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
||||
originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
|
||||
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
|
||||
config.DefaultCampaignConfigSearchPaths = []string{campaignPath}
|
||||
defer func() {
|
||||
config.DefaultPipelineConfigSearchPaths = originalDefaults
|
||||
config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults
|
||||
}()
|
||||
_ = campaignPath
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -323,15 +321,12 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) {
|
||||
func TestExecuteMissingCampaignConfigReportsRegistryPath(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
missingCampaignPath := filepath.Join(t.TempDir(), "campaign.yml")
|
||||
originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
|
||||
config.DefaultCampaignConfigSearchPaths = []string{missingCampaignPath}
|
||||
defer func() {
|
||||
config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults
|
||||
}()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
if err := os.Remove(campaignPath); err != nil {
|
||||
t.Fatalf("remove campaign config: %v", err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -342,11 +337,67 @@ func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) {
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "no campaign config path provided and no default campaign config found; searched:") {
|
||||
if !strings.Contains(stderr.String(), "load campaign config") {
|
||||
t.Fatalf("stderr = %q, want campaign discovery failure", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "pass --campaign") {
|
||||
t.Fatalf("stderr = %q, want explicit campaign guidance", stderr.String())
|
||||
if !strings.Contains(stderr.String(), filepath.ToSlash(filepath.Join("campaigns", "sample-campaign", "campaign.yml"))) {
|
||||
t.Fatalf("stderr = %q, want campaign registry path", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUsesPipelineDefaultCampaignID(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Campaign: sample-campaign") {
|
||||
t.Fatalf("stdout = %q, want default campaign", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteCampaignIDSelectsRegistryCampaign(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
campaignRoot := filepath.Dir(filepath.Dir(campaignPath))
|
||||
otherDir := filepath.Join(campaignRoot, "icewind")
|
||||
mustWriteTestFile(t, filepath.Join(otherDir, "campaign.yml"), `campaign_id: icewind
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`)
|
||||
mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||
mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n")
|
||||
mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n")
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", "icewind", "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Campaign: icewind") {
|
||||
t.Fatalf("stdout = %q, want selected campaign", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRejectsCampaignIDAndCampaignFile(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "mutually exclusive") {
|
||||
t.Fatalf("stderr = %q, want mutually exclusive error", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +442,9 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...
|
||||
|
||||
dir := t.TempDir()
|
||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||
campaignPath := filepath.Join(dir, "campaign.yml")
|
||||
campaignRoot := filepath.Join(dir, "campaigns")
|
||||
campaignDir := filepath.Join(campaignRoot, "sample-campaign")
|
||||
campaignPath := filepath.Join(campaignDir, "campaign.yml")
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
url := "https://example.com/transcribe"
|
||||
if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" {
|
||||
@@ -405,6 +458,9 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...
|
||||
|
||||
pipelineYAML := `workspace:
|
||||
root: ` + workspaceRoot + `
|
||||
campaigns:
|
||||
root: ` + campaignRoot + `
|
||||
default_campaign_id: sample-campaign
|
||||
cache:
|
||||
root: ` + filepath.Join(workspaceRoot, "cache") + `
|
||||
spool:
|
||||
@@ -438,7 +494,7 @@ notification:
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
`
|
||||
campaignYAML := `campaign: sample-campaign
|
||||
campaignYAML := `campaign_id: sample-campaign
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
@@ -448,6 +504,9 @@ inputs:
|
||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatalf("write pipeline config: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(campaignDir, 0o755); err != nil {
|
||||
t.Fatalf("create campaign dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
||||
t.Fatalf("write campaign config: %v", err)
|
||||
}
|
||||
@@ -455,9 +514,9 @@ inputs:
|
||||
t.Fatalf("write session config: %v", err)
|
||||
}
|
||||
|
||||
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
|
||||
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
|
||||
mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||
mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
|
||||
mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n")
|
||||
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
||||
|
||||
return pipelinePath, campaignPath, sessionPath
|
||||
@@ -466,7 +525,7 @@ inputs:
|
||||
func writeAppTestCampaignConfig(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
campaignPath := filepath.Join(dir, "campaign.yml")
|
||||
campaignYAML := `campaign: sample-campaign
|
||||
campaignYAML := `campaign_id: sample-campaign
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
|
||||
@@ -19,8 +19,8 @@ type pipelineCampaignConfig struct {
|
||||
Campaign *config.CampaignConfig
|
||||
}
|
||||
|
||||
func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) {
|
||||
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag)
|
||||
func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) {
|
||||
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
|
||||
return nil, missingSessionConfigError(discoveredSession.Searched, "remote session loading requires a session_id")
|
||||
}
|
||||
|
||||
sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, base.Campaign.Campaign, sessionID)
|
||||
sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID)
|
||||
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
|
||||
partialCfg := &config.Config{
|
||||
Pipeline: base.Pipeline,
|
||||
@@ -91,23 +91,28 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
|
||||
)
|
||||
}
|
||||
|
||||
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag string) (*pipelineCampaignConfig, error) {
|
||||
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) {
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolvedCampaignPath, err := resolveCampaignConfigPath(pipelineCfg, campaignFlag, campaignFileFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
campaignCfg, err := config.LoadCampaign(resolvedCampaignPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selectedID := strings.TrimSpace(campaignFlag); selectedID != "" && strings.TrimSpace(campaignFileFlag) == "" {
|
||||
if got := config.CampaignID(campaignCfg); got != selectedID {
|
||||
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID)
|
||||
}
|
||||
}
|
||||
return &pipelineCampaignConfig{
|
||||
PipelinePath: resolvedPipelinePath,
|
||||
CampaignPath: resolvedCampaignPath,
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
type commonConfigFlags struct {
|
||||
pipelinePath string
|
||||
campaignPath string
|
||||
campaignFilePath string
|
||||
sessionPath string
|
||||
sessionID string
|
||||
previousSessionID string
|
||||
@@ -42,7 +43,8 @@ func (e findingError) Error() string {
|
||||
|
||||
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
||||
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&flags.campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
||||
fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||
}
|
||||
@@ -133,7 +135,7 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
|
||||
}
|
||||
|
||||
findings := []finding{}
|
||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions())
|
||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||
if err != nil {
|
||||
findings = append(findings, errorFinding("config", err.Error()))
|
||||
return renderFindings(out, "", "", findings)
|
||||
@@ -213,7 +215,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
||||
if strings.TrimSpace(flags.sessionID) == "" {
|
||||
return fmt.Errorf("status: session_id is required")
|
||||
}
|
||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions())
|
||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||
if err != nil {
|
||||
return fmt.Errorf("status: %w", err)
|
||||
}
|
||||
@@ -282,10 +284,11 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
||||
positionalSessionID, args := pullLeadingSessionID(args)
|
||||
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var pipelinePath, campaignPath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
||||
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
||||
var remote, force bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier")
|
||||
fs.StringVar(&date, "date", "", "session date")
|
||||
fs.StringVar(&title, "title", "", "session title")
|
||||
@@ -319,13 +322,13 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
||||
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
|
||||
}
|
||||
|
||||
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath)
|
||||
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("session init: %w", err)
|
||||
}
|
||||
|
||||
input := sessionInitInput{
|
||||
Campaign: base.Campaign.Campaign,
|
||||
Campaign: config.CampaignID(base.Campaign),
|
||||
CampaignPath: base.CampaignPath,
|
||||
TemplateFile: base.Campaign.SessionTemplateFile,
|
||||
SessionID: sessionID,
|
||||
@@ -370,7 +373,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("session init: %w", err)
|
||||
}
|
||||
sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, base.Campaign.Campaign, sessionID)
|
||||
sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID)
|
||||
key := artifacts.S3SessionConfigKey(sessionPrefix)
|
||||
exists, err := store.Exists(ctx, key)
|
||||
if err != nil {
|
||||
@@ -606,7 +609,7 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
|
||||
}
|
||||
|
||||
func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore bool) (*config.Config, storage.ObjectStore, *effectiveLocks, *manifest.Manifest, error) {
|
||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions())
|
||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) {
|
||||
code := Execute([]string{
|
||||
"session", "init", "2026-06-07",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--title", "The Black Cabin",
|
||||
"--remote",
|
||||
}, &stdout, &stderr)
|
||||
@@ -104,7 +104,7 @@ func TestExecuteSessionInitExplicitConfigWinsOverDefaults(t *testing.T) {
|
||||
|
||||
explicitDir := t.TempDir()
|
||||
explicitCampaign := filepath.Join(explicitDir, "campaign.yml")
|
||||
if err := os.WriteFile(explicitCampaign, []byte(`campaign: explicit-campaign
|
||||
if err := os.WriteFile(explicitCampaign, []byte(`campaign_id: explicit-campaign
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
@@ -125,7 +125,7 @@ inputs:
|
||||
code := Execute([]string{
|
||||
"session", "init", "2026-06-07",
|
||||
"--config", defaultPipeline,
|
||||
"--campaign", explicitCampaign,
|
||||
"--campaign-file", explicitCampaign,
|
||||
"--remote",
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
@@ -159,12 +159,9 @@ func TestExecuteSessionInitRequiresSessionID(t *testing.T) {
|
||||
|
||||
func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T) {
|
||||
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
||||
origCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
|
||||
config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")}
|
||||
config.DefaultCampaignConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-campaign.yml")}
|
||||
t.Cleanup(func() {
|
||||
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
|
||||
config.DefaultCampaignConfigSearchPaths = origCampaignDefaults
|
||||
})
|
||||
|
||||
var stdout bytes.Buffer
|
||||
@@ -228,7 +225,7 @@ inputs:
|
||||
code := Execute([]string{
|
||||
"session", "init", "2026-06-07",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--previous-session-id", "2026-05-31",
|
||||
"--date", "2026-06-07",
|
||||
"--title", "The Black Cabin",
|
||||
@@ -276,7 +273,7 @@ inputs:
|
||||
code := Execute([]string{
|
||||
"session", "init", "2026-06-07",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--remote",
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
@@ -314,7 +311,7 @@ inputs:
|
||||
code := Execute([]string{
|
||||
"session", "init", "2026-06-07",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--output", outputPath,
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
@@ -344,7 +341,7 @@ inputs:
|
||||
code := Execute([]string{
|
||||
"session", "init", "2026-06-07",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--remote",
|
||||
}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
@@ -369,7 +366,7 @@ inputs:
|
||||
code := Execute([]string{
|
||||
"session", "init", "2026-06-07",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--title", "Unused Title",
|
||||
"--remote",
|
||||
}, &stdout, &stderr)
|
||||
@@ -396,7 +393,7 @@ inputs:
|
||||
code := Execute([]string{
|
||||
"session", "init", "2026-06-07",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--remote",
|
||||
}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
@@ -441,7 +438,7 @@ inputs:
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
@@ -462,7 +459,7 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
|
||||
code := Execute([]string{
|
||||
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--reason", "manual edit",
|
||||
}, &stdout, &stderr)
|
||||
@@ -483,7 +480,7 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
|
||||
code = Execute([]string{
|
||||
"session", "locks", "2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
@@ -498,7 +495,7 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
|
||||
code = Execute([]string{
|
||||
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
@@ -528,7 +525,7 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
|
||||
code := Execute([]string{
|
||||
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--reason", "first",
|
||||
}, &stdout, &stderr)
|
||||
@@ -541,7 +538,7 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
|
||||
code = Execute([]string{
|
||||
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--reason", "second",
|
||||
}, &stdout, &stderr)
|
||||
@@ -557,7 +554,7 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
|
||||
code = Execute([]string{
|
||||
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--reason", "second",
|
||||
"--force",
|
||||
@@ -609,7 +606,7 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
|
||||
code := Execute([]string{
|
||||
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
@@ -624,7 +621,7 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
|
||||
code = Execute([]string{
|
||||
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
@@ -655,13 +652,11 @@ func TestExecuteTopLevelLockAndUnlockAreRemoved(t *testing.T) {
|
||||
func withDefaultPipelineCampaignConfigs(t *testing.T, pipelinePath, campaignPath string) {
|
||||
t.Helper()
|
||||
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
||||
origCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
|
||||
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
|
||||
config.DefaultCampaignConfigSearchPaths = []string{campaignPath}
|
||||
t.Cleanup(func() {
|
||||
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
|
||||
config.DefaultCampaignConfigSearchPaths = origCampaignDefaults
|
||||
})
|
||||
_ = campaignPath
|
||||
}
|
||||
|
||||
func writeSessionInitTemplate(t *testing.T, campaignPath, templateYAML string) {
|
||||
@@ -711,7 +706,7 @@ func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
|
||||
code := Execute([]string{
|
||||
"session", "artifacts", "2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--remote",
|
||||
}, &stdout, &stderr)
|
||||
@@ -747,7 +742,7 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
|
||||
code := Execute([]string{
|
||||
"session", "artifacts", "2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--remote",
|
||||
}, &stdout, &stderr)
|
||||
@@ -804,7 +799,7 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
|
||||
code := Execute([]string{
|
||||
"session", "status", "2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
@@ -848,7 +843,7 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
|
||||
code := Execute([]string{
|
||||
"session", "status", "2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
@@ -884,7 +879,7 @@ func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
code := Execute([]string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
@@ -23,12 +23,14 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var campaignPath string
|
||||
var campaignFilePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var force bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||
@@ -51,7 +53,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return fmt.Errorf("plan: session_id is required")
|
||||
}
|
||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
|
||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
var out bytes.Buffer
|
||||
args := []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}
|
||||
args := []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}
|
||||
|
||||
if err := Plan(context.Background(), args, &out); err != nil {
|
||||
t.Fatalf("first Plan() error = %v", err)
|
||||
@@ -74,7 +74,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out); err != nil {
|
||||
if err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out); err != nil {
|
||||
t.Fatalf("Plan() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -127,7 +127,7 @@ inputs:
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
||||
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ inputs:
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -77,7 +77,7 @@ inputs:
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func TestExecuteExplicitLocalSessionPrecedenceSkipsRemote(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func TestExecuteLocalSessionDiscoveryPrecedenceSkipsRemote(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func TestExecuteRemoteSessionMissingObjectFailsClearly(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func TestExecuteRemoteSessionRequiresSessionID(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "plan", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "plan", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -177,7 +177,7 @@ func TestExecuteRemoteSessionStorageInitErrorFailsClearly(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -219,7 +219,7 @@ inputs:
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -238,7 +238,7 @@ func TestExecuteRemoteSessionMismatchFails(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var campaignPath string
|
||||
var campaignFilePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
@@ -36,14 +37,15 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
||||
var force bool
|
||||
var includeAudio bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||
fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files")
|
||||
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
|
||||
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
|
||||
fs.Usage = func() {
|
||||
_, _ = fmt.Fprintln(out, "Usage: narratio session restore <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, "Flags:")
|
||||
fs.PrintDefaults()
|
||||
@@ -70,7 +72,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return fmt.Errorf("restore: session_id is required")
|
||||
}
|
||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
|
||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
||||
code = Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestExecuteRestoreDryRunReportsPreviousCacheWithoutWriting(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -232,7 +232,7 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -259,7 +259,7 @@ func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
@@ -285,7 +285,7 @@ func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
||||
[]string{
|
||||
"session", "restore", "2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--dry-run",
|
||||
"--force",
|
||||
@@ -123,7 +123,7 @@ func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
|
||||
[]string{
|
||||
"session", "restore", "2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--dry-run",
|
||||
},
|
||||
@@ -313,7 +313,7 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("exit code = 0, want non-zero")
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
||||
[]string{
|
||||
"session", "restore", cfg.Session.SessionID,
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
},
|
||||
&stdout,
|
||||
@@ -86,7 +86,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
||||
[]string{
|
||||
"run-stage", "analyze", cfg.Session.SessionID,
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--force",
|
||||
"--artifacts", "player_handout",
|
||||
@@ -192,7 +192,7 @@ previous_session_id: 2026-04-26
|
||||
[]string{
|
||||
"session", "restore", cfg.Session.SessionID,
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
},
|
||||
&stdout,
|
||||
@@ -242,7 +242,7 @@ previous_session_id: 2026-04-26
|
||||
[]string{
|
||||
"run-stage", "analyze", cfg.Session.SessionID,
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--force",
|
||||
"--artifacts", "session_recap",
|
||||
|
||||
@@ -20,13 +20,15 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var campaignPath string
|
||||
var campaignFilePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var force bool
|
||||
var selectedArtifacts artifactSelectionFlag
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution")
|
||||
@@ -50,7 +52,7 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return fmt.Errorf("resume: session_id is required")
|
||||
}
|
||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
|
||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
})
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
||||
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume() error = %v", err)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestResumeNoRemainingStages(t *testing.T) {
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
||||
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume() error = %v", err)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume() error = %v", err)
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func TestRunStageExecutesOnlySelectedStage(t *testing.T) {
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
||||
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage() error = %v", err)
|
||||
}
|
||||
@@ -148,7 +148,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
||||
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage() error = %v", err)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
err = RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||
err = RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage(force) error = %v", err)
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage(force) error = %v", err)
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
err = Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
||||
err = Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume() error = %v", err)
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func TestRunStageTrimExecutes(t *testing.T) {
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
|
||||
|
||||
var out bytes.Buffer
|
||||
err := RunStage(context.Background(), []string{"trim", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
||||
err := RunStage(context.Background(), []string{"trim", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage(trim) error = %v", err)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestRunStageNormalizeExecutes(t *testing.T) {
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1},{"id":2}]}`)
|
||||
|
||||
var out bytes.Buffer
|
||||
err := RunStage(context.Background(), []string{"normalize", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
||||
err := RunStage(context.Background(), []string{"normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage(normalize) error = %v", err)
|
||||
}
|
||||
|
||||
@@ -18,13 +18,15 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var campaignPath string
|
||||
var campaignFilePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var force bool
|
||||
var selectedArtifacts artifactSelectionFlag
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||
@@ -48,7 +50,7 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return fmt.Errorf("run: session_id is required")
|
||||
}
|
||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
|
||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
})
|
||||
|
||||
@@ -25,13 +25,15 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var campaignPath string
|
||||
var campaignFilePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var force bool
|
||||
var selectedArtifacts artifactSelectionFlag
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||
@@ -70,6 +72,7 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
||||
StageName: stageName,
|
||||
PipelinePath: pipelinePath,
|
||||
CampaignPath: campaignPath,
|
||||
CampaignFilePath: campaignFilePath,
|
||||
SessionPath: sessionPath,
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
@@ -100,12 +103,14 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var campaignPath string
|
||||
var campaignFilePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var selectedArtifacts artifactSelectionFlag
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute during analyze (comma-separated or repeatable)")
|
||||
@@ -138,6 +143,7 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
|
||||
StageName: "analyze",
|
||||
PipelinePath: pipelinePath,
|
||||
CampaignPath: campaignPath,
|
||||
CampaignFilePath: campaignFilePath,
|
||||
SessionPath: sessionPath,
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
@@ -166,12 +172,14 @@ func Publish(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var campaignPath string
|
||||
var campaignFilePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var previousSessionID string
|
||||
var selectedArtifacts artifactSelectionFlag
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to publish (comma-separated or repeatable)")
|
||||
@@ -204,6 +212,7 @@ func Publish(ctx context.Context, args []string, out io.Writer) error {
|
||||
StageName: "archive",
|
||||
PipelinePath: pipelinePath,
|
||||
CampaignPath: campaignPath,
|
||||
CampaignFilePath: campaignFilePath,
|
||||
SessionPath: sessionPath,
|
||||
SessionID: sessionID,
|
||||
PreviousSessionID: previousSessionID,
|
||||
@@ -229,6 +238,7 @@ type singleStageCommand struct {
|
||||
StageName string
|
||||
PipelinePath string
|
||||
CampaignPath string
|
||||
CampaignFilePath string
|
||||
SessionPath string
|
||||
SessionID string
|
||||
PreviousSessionID string
|
||||
@@ -242,7 +252,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
|
||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||
}
|
||||
|
||||
cfg, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.SessionPath, config.SessionLoadOptions{
|
||||
cfg, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{
|
||||
SessionID: req.SessionID,
|
||||
PreviousSessionID: req.PreviousSessionID,
|
||||
})
|
||||
|
||||
@@ -1015,7 +1015,7 @@ func testConfig(t *testing.T) *config.Config {
|
||||
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
||||
|
||||
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
||||
mustWriteFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
||||
mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
||||
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
|
||||
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
||||
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
||||
@@ -1024,7 +1024,7 @@ func testConfig(t *testing.T) *config.Config {
|
||||
|
||||
return &config.Config{
|
||||
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
|
||||
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"},
|
||||
Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
|
||||
PipelinePath: pipelinePath,
|
||||
CampaignPath: campaignPath,
|
||||
SessionPath: sessionPath,
|
||||
@@ -1070,7 +1070,7 @@ whisperx:
|
||||
notification:
|
||||
timeout: 10s
|
||||
`
|
||||
campaignYAML := `campaign: sample-campaign
|
||||
campaignYAML := `campaign_id: sample-campaign
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
|
||||
@@ -35,7 +35,7 @@ inputs:
|
||||
err := Plan(context.Background(), []string{
|
||||
"2026-04-04",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--previous-session-id", "2026-03-28",
|
||||
}, &out)
|
||||
if err == nil {
|
||||
@@ -54,7 +54,7 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Plan(context.Background(), []string{"2026-04-04", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
||||
err := Plan(context.Background(), []string{"2026-04-04", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -84,7 +84,7 @@ inputs:
|
||||
err := Plan(context.Background(), []string{
|
||||
"2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--previous-session-id", "2026-04-25",
|
||||
}, &out)
|
||||
@@ -101,7 +101,7 @@ func TestRunStageAcceptsPositionalSessionIDAndParsesStageName(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
var out bytes.Buffer
|
||||
err := RunStage(context.Background(), []string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
||||
err := RunStage(context.Background(), []string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
||||
"run",
|
||||
"2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
@@ -60,7 +60,7 @@ func TestExecutePositionalSessionIDMismatchFails(t *testing.T) {
|
||||
"run",
|
||||
"2026-05-04",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
@@ -110,7 +110,7 @@ inputs:
|
||||
"run",
|
||||
"2026-06-07",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
@@ -142,25 +142,25 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "resume",
|
||||
args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
|
||||
args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
wantStage: "prepare",
|
||||
wantForce: false,
|
||||
},
|
||||
{
|
||||
name: "analyze",
|
||||
args: []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
args: []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
wantStage: "analyze",
|
||||
wantForce: true,
|
||||
},
|
||||
{
|
||||
name: "publish",
|
||||
args: []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
args: []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
wantStage: "archive",
|
||||
wantForce: true,
|
||||
},
|
||||
{
|
||||
name: "run-stage",
|
||||
args: []string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
args: []string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
wantStage: "archive",
|
||||
wantForce: false,
|
||||
},
|
||||
@@ -225,27 +225,27 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "validate",
|
||||
args: []string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
|
||||
args: []string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
want: "OK config",
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
|
||||
args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
want: "Session: 2026-05-03",
|
||||
},
|
||||
{
|
||||
name: "plan",
|
||||
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
|
||||
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
want: "narratio session plan: workdir prepared",
|
||||
},
|
||||
{
|
||||
name: "artifacts",
|
||||
args: []string{"session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
|
||||
args: []string{"session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
want: "Built-in:",
|
||||
},
|
||||
{
|
||||
name: "locks",
|
||||
args: []string{"session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
|
||||
args: []string{"session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
want: "Archive locks:",
|
||||
},
|
||||
}
|
||||
@@ -275,7 +275,7 @@ func TestExecuteSessionInitAcceptsPositionalSessionID(t *testing.T) {
|
||||
code := Execute([]string{
|
||||
"session", "init", "2026-06-07",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--output", outputPath,
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
@@ -302,7 +302,7 @@ func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) {
|
||||
code := Execute([]string{
|
||||
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--reason", "review",
|
||||
}, &stdout, &stderr)
|
||||
@@ -319,7 +319,7 @@ func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) {
|
||||
code = Execute([]string{
|
||||
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||
"--config", pipelinePath,
|
||||
"--campaign", campaignPath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
@@ -344,7 +344,7 @@ func TestExecuteCleanAcceptsPositionalSessionID(t *testing.T) {
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
@@ -7,24 +7,67 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCampaignConfigDefaultSearchOrder(t *testing.T) {
|
||||
want := []string{
|
||||
"/usr/local/etc/narratio/campaign.yml",
|
||||
"/etc/narratio/campaign.yml",
|
||||
func TestPipelineCampaignRegistryStrictDecode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||
pipelineYAML := `workspace:
|
||||
root: /tmp/narratio-work
|
||||
campaigns:
|
||||
root: /srv/narratio/campaigns
|
||||
default_campaign_id: dilfs
|
||||
whisperx:
|
||||
transcribe_url: https://example.com/transcribe
|
||||
notification:
|
||||
timeout: 10s
|
||||
`
|
||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatalf("write pipeline.yml: %v", err)
|
||||
}
|
||||
if len(DefaultCampaignConfigSearchPaths) != len(want) {
|
||||
t.Fatalf("DefaultCampaignConfigSearchPaths = %#v, want %#v", DefaultCampaignConfigSearchPaths, want)
|
||||
cfg, err := LoadPipeline(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPipeline() error = %v", err)
|
||||
}
|
||||
for i := range want {
|
||||
if DefaultCampaignConfigSearchPaths[i] != want[i] {
|
||||
t.Fatalf("DefaultCampaignConfigSearchPaths[%d] = %q, want %q", i, DefaultCampaignConfigSearchPaths[i], want[i])
|
||||
}
|
||||
if cfg.Campaigns.Root != "/srv/narratio/campaigns" {
|
||||
t.Fatalf("campaigns.root = %q", cfg.Campaigns.Root)
|
||||
}
|
||||
if cfg.Campaigns.DefaultCampaignID != "dilfs" {
|
||||
t.Fatalf("campaigns.default_campaign_id = %q", cfg.Campaigns.DefaultCampaignID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||
)
|
||||
|
||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||
}
|
||||
if CampaignID(cfg.Campaign) != "sample-campaign" {
|
||||
t.Fatalf("CampaignID() = %q, want sample-campaign", CampaignID(cfg.Campaign))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||
)
|
||||
|
||||
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected load error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "campaign file") || !strings.Contains(err.Error(), "strict decode failed") {
|
||||
t.Fatalf("error = %q, want campaign strict decode context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
"campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||
)
|
||||
|
||||
@@ -39,7 +82,7 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
||||
|
||||
func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
"campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||
)
|
||||
|
||||
@@ -54,7 +97,7 @@ func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
|
||||
|
||||
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
|
||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
|
||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||
)
|
||||
|
||||
@@ -75,7 +118,7 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
||||
|
||||
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
|
||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
|
||||
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n",
|
||||
)
|
||||
|
||||
@@ -93,7 +136,7 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
||||
|
||||
func TestCampaignSessionMismatchFails(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
"session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
|
||||
)
|
||||
|
||||
@@ -108,7 +151,7 @@ func TestCampaignSessionMismatchFails(t *testing.T) {
|
||||
|
||||
func TestLoadMissingCampaignFileFails(t *testing.T) {
|
||||
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||
)
|
||||
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")
|
||||
|
||||
@@ -17,6 +17,7 @@ type Config struct {
|
||||
// PipelineConfig contains durable pipeline-level settings.
|
||||
type PipelineConfig struct {
|
||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||
Campaigns CampaignsConfig `yaml:"campaigns"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Spool SpoolConfig `yaml:"spool"`
|
||||
Cache CacheConfig `yaml:"cache"`
|
||||
@@ -31,9 +32,15 @@ type PipelineConfig struct {
|
||||
Notification NotificationConfig `yaml:"notification"`
|
||||
}
|
||||
|
||||
// CampaignsConfig configures the local campaign registry.
|
||||
type CampaignsConfig struct {
|
||||
Root string `yaml:"root"`
|
||||
DefaultCampaignID string `yaml:"default_campaign_id"`
|
||||
}
|
||||
|
||||
// CampaignConfig contains stable campaign-level identity and input defaults.
|
||||
type CampaignConfig struct {
|
||||
Campaign string `yaml:"campaign"`
|
||||
CampaignID string `yaml:"campaign_id"`
|
||||
SessionTemplateFile string `yaml:"session_template_file"`
|
||||
Inputs CampaignInputsConfig `yaml:"inputs"`
|
||||
}
|
||||
|
||||
@@ -7,14 +7,13 @@ import "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
const (
|
||||
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
|
||||
DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml"
|
||||
DefaultCampaignConfigPathUsrLocal = "/usr/local/etc/narratio/campaign.yml"
|
||||
DefaultCampaignConfigPathEtc = "/etc/narratio/campaign.yml"
|
||||
DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml"
|
||||
DefaultSessionConfigPathEtc = "/etc/narratio/session.yml"
|
||||
DefaultS3AccessKeyIDEnv = "OBJECT_STORAGE_KEY_ID"
|
||||
DefaultS3SecretAccessKeyEnv = "OBJECT_STORAGE_KEY"
|
||||
DefaultStorageS3RootPrefix = "dnd"
|
||||
DefaultWorkspaceRoot = "/var/lib/narratio"
|
||||
DefaultCampaignsRoot = "/usr/local/share/narratio/campaigns"
|
||||
DefaultSpoolRoot = "/var/spool/narratio"
|
||||
DefaultCacheRoot = "/var/cache/narratio"
|
||||
DefaultCacheS3Audio = true
|
||||
@@ -93,16 +92,6 @@ var DefaultPipelineConfigSearchPaths = []string{
|
||||
DefaultPipelineConfigPathEtc,
|
||||
}
|
||||
|
||||
// DefaultCampaignConfigSearchPaths defines the default search order for
|
||||
// campaign.yml when callers do not provide an explicit path.
|
||||
//
|
||||
// Keep this in a variable so future defaults can be extended without changing
|
||||
// call sites.
|
||||
var DefaultCampaignConfigSearchPaths = []string{
|
||||
DefaultCampaignConfigPathUsrLocal,
|
||||
DefaultCampaignConfigPathEtc,
|
||||
}
|
||||
|
||||
// DefaultSessionConfigSearchPaths defines the default search order for
|
||||
// session.yml when callers do not provide an explicit path.
|
||||
//
|
||||
|
||||
@@ -194,7 +194,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
||||
return ResolvedStableInputs{}, fmt.Errorf("session config is required")
|
||||
}
|
||||
|
||||
campaignName := strings.TrimSpace(campaignCfg.Campaign)
|
||||
campaignName := CampaignID(campaignCfg)
|
||||
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
|
||||
if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName {
|
||||
return ResolvedStableInputs{}, fmt.Errorf(
|
||||
@@ -234,6 +234,14 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
||||
return stable, nil
|
||||
}
|
||||
|
||||
// CampaignID returns the canonical campaign identity from campaign config.
|
||||
func CampaignID(cfg *CampaignConfig) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cfg.CampaignID)
|
||||
}
|
||||
|
||||
func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath string) ResolvedInputFile {
|
||||
if strings.TrimSpace(sessionValue) != "" {
|
||||
return ResolvedInputFile{
|
||||
@@ -316,6 +324,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
|
||||
return
|
||||
}
|
||||
applyWorkspaceDefaults(&cfg.Workspace)
|
||||
applyCampaignsDefaults(&cfg.Campaigns)
|
||||
applyStorageDefaults(&cfg.Storage)
|
||||
applySpoolDefaults(&cfg.Spool)
|
||||
applyCacheDefaults(&cfg.Cache)
|
||||
@@ -331,6 +340,15 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
|
||||
applyScriptoriumDefaults(cfg.Scriptorium)
|
||||
}
|
||||
|
||||
func applyCampaignsDefaults(cfg *CampaignsConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.Root == "" {
|
||||
cfg.Root = DefaultCampaignsRoot
|
||||
}
|
||||
}
|
||||
|
||||
func applyWorkspaceDefaults(cfg *WorkspaceConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
|
||||
@@ -916,7 +916,7 @@ func TestValidateMissingAudioSource(t *testing.T) {
|
||||
Report: boolPtr(true),
|
||||
},
|
||||
},
|
||||
Campaign: &CampaignConfig{Campaign: "sample-campaign"},
|
||||
Campaign: &CampaignConfig{CampaignID: "sample-campaign"},
|
||||
Session: &SessionConfig{
|
||||
SessionID: "2026-05-03",
|
||||
Campaign: "sample-campaign",
|
||||
@@ -967,7 +967,7 @@ func TestExamplesLoadAndValidate(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pipelinePath := filepath.Join(examplesDir, tt.pipelineFile)
|
||||
campaignPath := filepath.Join(examplesDir, "campaign.yml")
|
||||
campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml")
|
||||
sessionPath := filepath.Join(examplesDir, tt.sessionFile)
|
||||
|
||||
cfg, err := Load(pipelinePath, campaignPath, sessionPath)
|
||||
@@ -1004,7 +1004,7 @@ func writeConfigFiles(t *testing.T, pipelineYAML, sessionYAML string) (string, s
|
||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatalf("write pipeline.yml: %v", err)
|
||||
}
|
||||
campaignYAML := `campaign: ` + campaignNameFromSessionYAML(sessionYAML) + `
|
||||
campaignYAML := `campaign_id: ` + campaignNameFromSessionYAML(sessionYAML) + `
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
|
||||
@@ -46,8 +46,8 @@ func validateCampaign(cfg *CampaignConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("campaign config is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Campaign) == "" {
|
||||
return fmt.Errorf("campaign.campaign is required")
|
||||
if CampaignID(cfg) == "" {
|
||||
return fmt.Errorf("campaign.campaign_id is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
||||
campaignPath := filepath.Join(cfgDir, "campaign.yml")
|
||||
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
||||
writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n")
|
||||
writeStageTestFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
||||
writeStageTestFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
||||
writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n")
|
||||
writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
||||
writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
||||
@@ -52,7 +52,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
||||
SessionPath: sessionPath,
|
||||
CampaignPath: campaignPath,
|
||||
PipelinePath: pipelinePath,
|
||||
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"},
|
||||
Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
|
||||
Pipeline: &config.PipelineConfig{
|
||||
Workspace: config.WorkspaceConfig{Root: root},
|
||||
Storage: config.StorageConfig{
|
||||
|
||||
@@ -608,7 +608,7 @@ func setupPrepareEnv(t *testing.T) (*Env, *manifest.Manifest) {
|
||||
campaignPath := filepath.Join(cfgDir, "campaign.yml")
|
||||
|
||||
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
||||
writeFile(t, campaignPath, `campaign: sample-campaign
|
||||
writeFile(t, campaignPath, `campaign_id: sample-campaign
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
@@ -624,7 +624,7 @@ inputs:
|
||||
Workspace: config.WorkspaceConfig{Root: workspace},
|
||||
Cache: config.CacheConfig{Root: filepath.Join(t.TempDir(), "cache"), S3Audio: boolPtr(true)},
|
||||
},
|
||||
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"},
|
||||
Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
|
||||
SessionPath: sessionPath,
|
||||
CampaignPath: campaignPath,
|
||||
PipelinePath: pipelinePath,
|
||||
|
||||
@@ -236,7 +236,7 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
|
||||
campaignPath := filepath.Join(cfgDir, "campaign.yml")
|
||||
writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n")
|
||||
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
||||
writeFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
||||
writeFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
||||
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
||||
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
||||
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
|
||||
@@ -247,7 +247,7 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
|
||||
PipelinePath: pipelinePath,
|
||||
CampaignPath: campaignPath,
|
||||
SessionPath: sessionPath,
|
||||
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"},
|
||||
Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
|
||||
Pipeline: &config.PipelineConfig{
|
||||
Workspace: config.WorkspaceConfig{Root: workspace},
|
||||
WhisperX: config.WhisperXConfig{
|
||||
|
||||
Reference in New Issue
Block a user