Add campaign configuration support

This commit is contained in:
2026-05-20 20:41:28 -05:00
parent dffb432537
commit b29d8eeb50
34 changed files with 865 additions and 182 deletions

View File

@@ -6,7 +6,7 @@
narratio run --session-id 2026-04-04 narratio run --session-id 2026-04-04
``` ```
This command uses default discovery for `pipeline.yml` and `session.yml`; both files must be discoverable unless you pass explicit `--config` and `--session` paths. This command uses default discovery for `pipeline.yml`, `campaign.yml`, and `session.yml`; all three files must be discoverable unless you pass explicit `--config`, `--campaign`, and `--session` paths.
## Command Overview ## Command Overview
@@ -28,6 +28,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
### `run` ### `run`
- `--config <path>`: optional explicit `pipeline.yml` path. - `--config <path>`: optional explicit `pipeline.yml` path.
- `--campaign <path>`: optional explicit `campaign.yml` path.
- `--session <path>`: optional explicit `session.yml` path. - `--session <path>`: optional explicit `session.yml` path.
- `--session-id <value>`: session template variable value. - `--session-id <value>`: session template variable value.
- `--previous-session-id <value>`: previous-session template variable value. - `--previous-session-id <value>`: previous-session template variable value.
@@ -37,6 +38,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
### `plan` ### `plan`
- `--config <path>` - `--config <path>`
- `--campaign <path>`
- `--session <path>` - `--session <path>`
- `--session-id <value>` - `--session-id <value>`
- `--previous-session-id <value>` - `--previous-session-id <value>`
@@ -45,6 +47,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
### `resume` ### `resume`
- `--config <path>` - `--config <path>`
- `--campaign <path>`
- `--session <path>` - `--session <path>`
- `--session-id <value>` - `--session-id <value>`
- `--previous-session-id <value>` - `--previous-session-id <value>`
@@ -54,6 +57,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
### `run-stage` ### `run-stage`
- `--config <path>` - `--config <path>`
- `--campaign <path>`
- `--session <path>` - `--session <path>`
- `--session-id <value>` - `--session-id <value>`
- `--previous-session-id <value>` - `--previous-session-id <value>`
@@ -76,6 +80,7 @@ Valid stage names:
### `restore` ### `restore`
- `--config <path>` - `--config <path>`
- `--campaign <path>`
- `--session <path>` - `--session <path>`
- `--session-id <value>` - `--session-id <value>`
- `--previous-session-id <value>` - `--previous-session-id <value>`
@@ -97,14 +102,14 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio run [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] narratio run [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
``` ```
Success output: Success output:
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>` - `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
Common failure cases: Common failure cases:
- missing default config/session paths when flags omitted. - missing default config/campaign/session paths when flags omitted.
- invalid template/rendered session mismatch. - invalid template/rendered session mismatch.
- unknown/invalid `--artifacts` value. - unknown/invalid `--artifacts` value.
- `--artifacts` with unknown configured artifact key. - `--artifacts` with unknown configured artifact key.
@@ -117,7 +122,7 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio plan [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] narratio plan [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force]
``` ```
Success output includes: Success output includes:
@@ -126,7 +131,7 @@ Success output includes:
- `totals: run=<n> skip=<n>` - `totals: run=<n> skip=<n>`
Common failure cases: Common failure cases:
- same config/session discovery and validation failures as `run`. - same config/campaign/session discovery and validation failures as `run`.
- secrets directory read failures when `pipeline.secrets.env_dir` is configured. - secrets directory read failures when `pipeline.secrets.env_dir` is configured.
### `resume` ### `resume`
@@ -137,7 +142,7 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio resume [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] narratio resume [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
``` ```
Success output: Success output:
@@ -177,7 +182,7 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio run-stage [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] <stage> narratio run-stage [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] <stage>
``` ```
Success output: Success output:
@@ -201,7 +206,7 @@ Purpose:
Syntax: Syntax:
```bash ```bash
narratio restore [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--dry-run] [--force] [--include-audio] narratio restore [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--dry-run] [--force] [--include-audio]
``` ```
Success output (dry-run): Success output (dry-run):

View File

@@ -2,12 +2,13 @@
## 1. Overview ## 1. Overview
Narratio loads two YAML files: Narratio loads three YAML files:
- `pipeline.yml`: pipeline-level runtime configuration. - `pipeline.yml`: pipeline-level runtime configuration.
- `campaign.yml`: stable campaign identity and campaign-level input defaults.
- `session.yml`: per-session metadata and input selection. - `session.yml`: per-session metadata and input selection.
These commands load and validate both files before running: These commands load and validate all three files before running:
- `narratio run` - `narratio run`
- `narratio plan` - `narratio plan`
@@ -20,6 +21,8 @@ Behavior:
- strict YAML decode is enabled (`KnownFields(true)`): unknown fields fail. - strict YAML decode is enabled (`KnownFields(true)`): unknown fields fail.
- session templates render before session YAML decode. - session templates render before session YAML decode.
- defaults are applied for optional pipeline fields. - defaults are applied for optional pipeline fields.
- campaign-level stable input paths fill missing session input paths.
- session-level stable input paths override campaign-level input paths.
- validation enforces required fields, value formats, and cross-field constraints. - validation enforces required fields, value formats, and cross-field constraints.
## 2. Config file discovery ## 2. Config file discovery
@@ -32,6 +35,15 @@ Pipeline config lookup for `run`, `plan`, `resume`, `run-stage`, and `restore`:
2. `/etc/narratio/pipeline.yml` 2. `/etc/narratio/pipeline.yml`
- first existing file wins. - first existing file wins.
Campaign config lookup for `run`, `plan`, `resume`, `run-stage`, and `restore`:
- if `--campaign <path>` is provided, that path is used.
- if omitted, Narratio searches in order:
1. `./campaign.yml`
2. `/usr/local/etc/narratio/campaign.yml`
3. `/etc/narratio/campaign.yml`
- first existing file wins.
## 3. Session file discovery and templating ## 3. Session file discovery and templating
Session config lookup for `run`, `plan`, `resume`, `run-stage`, and `restore`: Session config lookup for `run`, `plan`, `resume`, `run-stage`, and `restore`:
@@ -71,20 +83,28 @@ Why this is sufficient:
## 5. Minimal session template ## 5. Minimal session template
`campaign.yml`:
```yaml ```yaml
session_id: "{{ session_id }}"
campaign: sample-campaign campaign: sample-campaign
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
```
`session.yml`:
```yaml
session_id: "{{ session_id }}"
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
speakers_file: ./examples/speakers.yml
autocorrect_file: ./examples/autocorrect.yml
glossary_file: ./examples/glossary.yml
``` ```
Usage: Usage:
```bash ```bash
narratio run --config /path/to/pipeline.yml --session ./session.yml --session-id 2026-05-03 narratio run --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml --session-id 2026-05-03
``` ```
Previous-session-enabled variant: Previous-session-enabled variant:
@@ -92,16 +112,12 @@ Previous-session-enabled variant:
```yaml ```yaml
session_id: "{{ session_id }}" session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}" previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
speakers_file: ./examples/speakers.yml
autocorrect_file: ./examples/autocorrect.yml
glossary_file: ./examples/glossary.yml
``` ```
```bash ```bash
narratio run --config /path/to/pipeline.yml --session ./session.yml --session-id 2026-05-03 --previous-session-id 2026-04-26 narratio run --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml --session-id 2026-05-03 --previous-session-id 2026-04-26
``` ```
## 6. Production-oriented config ## 6. Production-oriented config
@@ -158,7 +174,7 @@ Operational notes:
- archive promotion is explicit and source-based via `archive.promote_artifacts`. - archive promotion is explicit and source-based via `archive.promote_artifacts`.
- `source` is required; `dest` is optional and derived when omitted. - `source` is required; `dest` is optional and derived when omitted.
- Narratio does not auto-promote all generated analyze artifacts. - Narratio does not auto-promote all generated analyze artifacts.
- `restore` reads the same config/session inputs and restore scope is bounded by committed archive current state. - `restore` reads the same config/campaign/session inputs and restore scope is bounded by committed archive current state.
## 7. Full pipeline reference ## 7. Full pipeline reference
@@ -299,21 +315,34 @@ Restore-related implications:
- restore remote identity requires archive S3 identity to resolve (`pipeline.storage.s3.bucket` and session prefix derivation inputs). - restore remote identity requires archive S3 identity to resolve (`pipeline.storage.s3.bucket` and session prefix derivation inputs).
- restore scope considers committed current state and durable paths (`manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`, optional `audio/**`). - restore scope considers committed current state and durable paths (`manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`, optional `audio/**`).
## 8. Full session reference ## 8. Full campaign reference
| Path | Type | Required | Default |
| --- | --- | --- | --- |
| `campaign.campaign` | string | Yes | none |
| `campaign.inputs.speakers_file` | string | Yes | none |
| `campaign.inputs.autocorrect_file` | string | Yes | none |
| `campaign.inputs.glossary_file` | string | Yes | none |
Campaign input paths may be absolute or relative. Relative paths resolve from the directory containing `campaign.yml`.
## 9. Full session reference
| Path | Type | Required | Default | | Path | Type | Required | Default |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `session.session_id` | string | Yes | none | | `session.session_id` | string | Yes | none |
| `session.previous_session_id` | string | No | empty | | `session.previous_session_id` | string | No | empty |
| `session.campaign` | string | Yes | none | | `session.campaign` | string | No | `campaign.campaign` |
| `session.date` | string | No | empty | | `session.date` | string | No | empty |
| `session.title` | string | No | empty | | `session.title` | string | No | empty |
| `session.inputs.audio_dir` | string | Conditional | empty | | `session.inputs.audio_dir` | string | Conditional | empty |
| `session.inputs.audio_files[]` | list[string] | Conditional | empty | | `session.inputs.audio_files[]` | list[string] | Conditional | empty |
| `session.inputs.audio_s3.prefix` | string | Conditional | none | | `session.inputs.audio_s3.prefix` | string | Conditional | none |
| `session.inputs.speakers_file` | string | Yes | none | | `session.inputs.speakers_file` | string | No | `campaign.inputs.speakers_file` |
| `session.inputs.autocorrect_file` | string | Yes | none | | `session.inputs.autocorrect_file` | string | No | `campaign.inputs.autocorrect_file` |
| `session.inputs.glossary_file` | string | Yes | none | | `session.inputs.glossary_file` | string | No | `campaign.inputs.glossary_file` |
Session input paths may be absolute or relative. Relative audio paths and session-level stable input overrides resolve from the directory containing `session.yml`. If both `campaign.yml` and `session.yml` specify campaign identity, the values must match.
Audio-source rule: Audio-source rule:
@@ -328,7 +357,7 @@ Previous-session rule:
- if `session.previous_session_id` is set, it must not equal `session.session_id`. - if `session.previous_session_id` is set, it must not equal `session.session_id`.
- canonical previous-session sources (`narratio.previous_session.artifact.<name>`) are hydrated during `prepare` from archive current state when required by enabled configured artifacts. - canonical previous-session sources (`narratio.previous_session.artifact.<name>`) are hydrated during `prepare` from archive current state when required by enabled configured artifacts.
## 9. Secrets ## 10. Secrets
Narratio supports filesystem-based secret injection via `pipeline.secrets.env_dir`. Narratio supports filesystem-based secret injection via `pipeline.secrets.env_dir`.
@@ -347,13 +376,14 @@ Guidance:
- do not put secret values directly in YAML. - do not put secret values directly in YAML.
- configure env var names in config and provide values via env/secrets files. - configure env var names in config and provide values via env/secrets files.
## 10. Examples ## 11. Examples
Maintained examples: Maintained examples:
- `examples/pipeline.minimal.yml` - `examples/pipeline.minimal.yml`
- `examples/pipeline.production.yml` - `examples/pipeline.production.yml`
- `examples/pipeline.full.annotated.yml` - `examples/pipeline.full.annotated.yml`
- `examples/campaign.yml`
- `examples/session.template.yml` - `examples/session.template.yml`
- `examples/session.local-audio.yml` - `examples/session.local-audio.yml`
- `examples/session.s3-audio.yml` - `examples/session.s3-audio.yml`

View File

@@ -10,8 +10,8 @@ Prepare owns:
## Inputs and outputs ## Inputs and outputs
Inputs: Inputs:
- resolved config/session (`pipeline.yml`, `session.yml`); - resolved config/campaign/session (`pipeline.yml`, `campaign.yml`, `session.yml`);
- session-local input files (`speakers`, `autocorrect`, `glossary`); - campaign or session input files (`speakers`, `autocorrect`, `glossary`);
- audio source: - audio source:
- local: `session.inputs.audio_dir` or `session.inputs.audio_files`; - local: `session.inputs.audio_dir` or `session.inputs.audio_files`;
- S3: `session.inputs.audio_s3.prefix`; - S3: `session.inputs.audio_s3.prefix`;
@@ -19,6 +19,7 @@ Inputs:
- remote previous-session current archive state when previous hydration is required. - remote previous-session current archive state when previous hydration is required.
Outputs: Outputs:
- `inputs/campaign.yml`;
- `inputs/session.yml`; - `inputs/session.yml`;
- `inputs/pipeline.resolved.yml`; - `inputs/pipeline.resolved.yml`;
- `inputs/speakers.yml`; - `inputs/speakers.yml`;
@@ -58,6 +59,10 @@ Does not own:
- `pipeline.scriptorium.artifacts.<name>.enabled` - `pipeline.scriptorium.artifacts.<name>.enabled`
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` - `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source`
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required` - `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required`
- `campaign.campaign`
- `campaign.inputs.speakers_file`
- `campaign.inputs.autocorrect_file`
- `campaign.inputs.glossary_file`
## External adapters used ## External adapters used
- `storage.ObjectStore` for: - `storage.ObjectStore` for:
@@ -67,6 +72,8 @@ Does not own:
## State and manifest behavior ## State and manifest behavior
- Ensures workspace layout exists. - Ensures workspace layout exists.
- Materializes canonical input files and audio files. - Materializes canonical input files and audio files.
- Resolves campaign-provided stable input paths relative to `campaign.yml`.
- Resolves session-provided stable input overrides relative to `session.yml`.
- Scans enabled configured artifact inputs for canonical sources: - Scans enabled configured artifact inputs for canonical sources:
- `narratio.previous_session.artifact.<artifact_key>` - `narratio.previous_session.artifact.<artifact_key>`
- If one or more canonical previous-session requirements exist: - If one or more canonical previous-session requirements exist:

View File

@@ -18,7 +18,7 @@ narratio run --session-id 2026-04-04
- use `manifest=<path>` with `status` for inspection. - use `manifest=<path>` with `status` for inspection.
Notes: Notes:
- default config/session discovery applies unless `--config` and `--session` are passed. - default config/campaign/session discovery applies unless `--config`, `--campaign`, and `--session` are passed.
- S3 audio mode requires `session.inputs.audio_s3.prefix` and valid object-store access. - S3 audio mode requires `session.inputs.audio_s3.prefix` and valid object-store access.
## Restore workflow ## Restore workflow
@@ -61,6 +61,7 @@ Primary state:
- `manifest.json`: session-level stage state. - `manifest.json`: session-level stage state.
- `runs/{run_id}/manifest.json`: invocation-level state. - `runs/{run_id}/manifest.json`: invocation-level state.
- `.lock`: session lock while a modifying command is active. - `.lock`: session lock while a modifying command is active.
- `inputs/campaign.yml`, `inputs/session.yml`, and `inputs/pipeline.resolved.yml`: materialized config inputs for the run.
Canonical session directories: Canonical session directories:
- `inputs/` - `inputs/`

5
examples/campaign.yml Normal file
View File

@@ -0,0 +1,5 @@
campaign: sample-campaign
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml

View File

@@ -1,9 +1,5 @@
session_id: 2026-05-03 session_id: 2026-05-03
campaign: sample-campaign
date: 2026-05-03 date: 2026-05-03
title: Sample Session title: Sample Session
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
speakers_file: ./examples/speakers.yml
autocorrect_file: ./examples/autocorrect.yml
glossary_file: ./examples/glossary.yml

View File

@@ -1,10 +1,6 @@
session_id: 2026-05-03 session_id: 2026-05-03
campaign: sample-campaign
date: 2026-05-03 date: 2026-05-03
title: Sample Session title: Sample Session
inputs: inputs:
audio_s3: audio_s3:
prefix: audio/ prefix: audio/
speakers_file: ./examples/speakers.yml
autocorrect_file: ./examples/autocorrect.yml
glossary_file: ./examples/glossary.yml

View File

@@ -1,7 +1,3 @@
session_id: "{{ session_id }}" session_id: "{{ session_id }}"
campaign: sample-campaign
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
speakers_file: ./examples/speakers.yml
autocorrect_file: ./examples/autocorrect.yml
glossary_file: ./examples/glossary.yml

View File

@@ -14,12 +14,12 @@ import (
func TestExecuteRunStageArtifactsNonAnalyzeFails(t *testing.T) { func TestExecuteRunStageArtifactsNonAnalyzeFails(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( code := Execute(
[]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "--artifacts", "session_recap", "polish"}, []string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap", "polish"},
&stdout, &stdout,
&stderr, &stderr,
) )
@@ -33,12 +33,12 @@ func TestExecuteRunStageArtifactsNonAnalyzeFails(t *testing.T) {
func TestExecuteUnknownArtifactsFailValidation(t *testing.T) { func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( code := Execute(
[]string{"run", "--config", pipelinePath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, []string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
&stdout, &stdout,
&stderr, &stderr,
) )
@@ -52,7 +52,7 @@ func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) { func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
@@ -65,7 +65,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
err := RunStage( err := RunStage(
context.Background(), context.Background(),
[]string{"--config", pipelinePath, "--session", sessionPath, "--artifacts", "session_recap,session_recap", "analyze"}, []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap", "analyze"},
&out, &out,
) )
if err != nil { if err != nil {
@@ -78,7 +78,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) { func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
@@ -93,7 +93,7 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
err := Resume( err := Resume(
context.Background(), context.Background(),
[]string{"--config", pipelinePath, "--session", sessionPath, "--artifacts", "session_recap"}, []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&out, &out,
) )
if err != nil { if err != nil {
@@ -104,10 +104,10 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
} }
} }
func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string) { func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string, string) {
t.Helper() t.Helper()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
f, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0) f, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0)
if err != nil { if err != nil {
t.Fatalf("open pipeline config for append: %v", err) t.Fatalf("open pipeline config for append: %v", err)
@@ -136,5 +136,5 @@ scriptorium:
if _, err := f.WriteString(extra); err != nil { if _, err := f.WriteString(extra); err != nil {
t.Fatalf("append scriptorium config: %v", err) t.Fatalf("append scriptorium config: %v", err)
} }
return pipelinePath, sessionPath return pipelinePath, campaignPath, sessionPath
} }

View File

@@ -0,0 +1,49 @@
package app
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func resolveCampaignConfigPath(flagValue string) (string, error) {
return resolveCampaignConfigPathWithCandidates(flagValue, config.DefaultCampaignConfigSearchPaths)
}
func resolveCampaignConfigPathWithCandidates(flagValue string, candidates []string) (string, error) {
if explicit := strings.TrimSpace(flagValue); explicit != "" {
return explicit, nil
}
ordered := make([]string, 0, len(candidates))
for _, raw := range candidates {
path := strings.TrimSpace(raw)
if path == "" {
continue
}
ordered = append(ordered, path)
info, err := os.Stat(path)
if err == nil {
if info.IsDir() {
continue
}
return filepath.Clean(path), nil
}
if errors.Is(err, os.ErrNotExist) {
continue
}
return "", fmt.Errorf("check default campaign config %q: %w", path, err)
}
if len(ordered) == 0 {
return "", fmt.Errorf("no campaign config path provided and no default locations configured")
}
return "", fmt.Errorf(
"no campaign config path provided and no default campaign config found; searched: %s; pass --campaign to use an explicit path",
strings.Join(ordered, ", "),
)
}

View File

@@ -0,0 +1,49 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolveCampaignConfigPathExplicitWins(t *testing.T) {
explicit := filepath.Join(t.TempDir(), "custom-campaign.yml")
got, err := resolveCampaignConfigPathWithCandidates(explicit, []string{filepath.Join(t.TempDir(), "campaign.yml")})
if err != nil {
t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err)
}
if got != explicit {
t.Fatalf("path = %q, want explicit path %q", got, explicit)
}
}
func TestResolveCampaignConfigPathUsesFirstExistingDefault(t *testing.T) {
dir := t.TempDir()
missing := filepath.Join(dir, "missing.yml")
found := filepath.Join(dir, "campaign.yml")
if err := os.WriteFile(found, []byte("campaign: sample-campaign\n"), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err)
}
got, err := resolveCampaignConfigPathWithCandidates("", []string{missing, found})
if err != nil {
t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err)
}
if got != filepath.Clean(found) {
t.Fatalf("path = %q, want %q", got, filepath.Clean(found))
}
}
func TestResolveCampaignConfigPathErrorIncludesSearchedPaths(t *testing.T) {
_, err := resolveCampaignConfigPathWithCandidates("", []string{"./campaign.yml", "/usr/local/etc/narratio/campaign.yml", "/etc/narratio/campaign.yml"})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "searched") {
t.Fatalf("error = %q, want searched paths", err.Error())
}
if !strings.Contains(err.Error(), "pass --campaign") {
t.Fatalf("error = %q, want explicit-campaign guidance", err.Error())
}
}

View File

@@ -24,7 +24,7 @@ func TestExecuteValidCommands(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
manifestPath := writeManifestPathForExecute(t) manifestPath := writeManifestPathForExecute(t)
cases := []struct { cases := []struct {
@@ -32,11 +32,11 @@ func TestExecuteValidCommands(t *testing.T) {
args []string args []string
wantOut string wantOut string
}{ }{
{name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="}, {name: "run", args: []string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
{name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"}, {name: "plan", args: []string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"},
{name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"}, {name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"},
{name: "resume", args: []string{"resume", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, {name: "resume", args: []string{"resume", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
{name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, {name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -94,12 +94,12 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
func TestExecuteRunStageUnknownFails(t *testing.T) { func TestExecuteRunStageUnknownFails(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "unknown"}, &stdout, &stderr) code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "unknown"}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -110,14 +110,14 @@ func TestExecuteRunStageUnknownFails(t *testing.T) {
func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) { func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe") pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1}]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1}]}`)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "normalize"}, &stdout, &stderr) code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "normalize"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -136,19 +136,19 @@ func TestExecuteRunStageTranscribeUsesConfiguredWhisperXServer(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "prepare"}, &stdout, &stderr) code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "prepare"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
stdout.Reset() stdout.Reset()
stderr.Reset() stderr.Reset()
code = Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "--force", "transcribe"}, &stdout, &stderr) code = Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "transcribe"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -188,6 +188,7 @@ func TestExecuteRunStagePolishLoadsCredentialFromSecretsDir(t *testing.T) {
t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1") t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1")
pipelinePath := filepath.Join(configDir, "pipeline.yml") pipelinePath := filepath.Join(configDir, "pipeline.yml")
campaignPath := writeAppTestCampaignConfig(t, configDir)
sessionPath := filepath.Join(configDir, "session.yml") sessionPath := filepath.Join(configDir, "session.yml")
pipelineYAML := `workspace: pipelineYAML := `workspace:
root: ` + workspaceRoot + ` root: ` + workspaceRoot + `
@@ -239,7 +240,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "--force", "polish"}, &stdout, &stderr) code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -252,6 +253,7 @@ func TestExecuteRunFailsWhenConfiguredSecretsDirMissing(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
configDir := t.TempDir() configDir := t.TempDir()
pipelinePath := filepath.Join(configDir, "pipeline.yml") pipelinePath := filepath.Join(configDir, "pipeline.yml")
campaignPath := writeAppTestCampaignConfig(t, configDir)
sessionPath := filepath.Join(configDir, "session.yml") sessionPath := filepath.Join(configDir, "session.yml")
pipelineYAML := `workspace: pipelineYAML := `workspace:
@@ -288,7 +290,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -305,11 +307,14 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
})) }))
defer srv.Close() defer srv.Close()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath} config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
config.DefaultCampaignConfigSearchPaths = []string{campaignPath}
defer func() { defer func() {
config.DefaultPipelineConfigSearchPaths = originalDefaults config.DefaultPipelineConfigSearchPaths = originalDefaults
config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults
}() }()
var stdout bytes.Buffer var stdout bytes.Buffer
@@ -323,6 +328,33 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
} }
} }
func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot)
missingCampaignPath := filepath.Join(t.TempDir(), "campaign.yml")
originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
config.DefaultCampaignConfigSearchPaths = []string{missingCampaignPath}
defer func() {
config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults
}()
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"run", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String())
}
if !strings.Contains(stderr.String(), "no campaign config path provided and no default campaign config found; searched:") {
t.Fatalf("stderr = %q, want campaign discovery failure", stderr.String())
}
if !strings.Contains(stderr.String(), "pass --campaign") {
t.Fatalf("stderr = %q, want explicit campaign guidance", stderr.String())
}
}
func TestExecuteInvalidCommand(t *testing.T) { func TestExecuteInvalidCommand(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -359,11 +391,12 @@ func TestExecuteMissingCommand(t *testing.T) {
} }
} }
func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...string) (string, string) { func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...string) (string, string, string) {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml") pipelinePath := filepath.Join(dir, "pipeline.yml")
campaignPath := filepath.Join(dir, "campaign.yml")
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
url := "https://example.com/transcribe" url := "https://example.com/transcribe"
if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" { if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" {
@@ -407,9 +440,11 @@ notification:
` `
sessionYAML := `session_id: 2026-05-03 sessionYAML := `session_id: 2026-05-03
campaign: sample-campaign
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
`
campaignYAML := `campaign: sample-campaign
inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
@@ -418,6 +453,9 @@ inputs:
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline config: %v", err) t.Fatalf("write pipeline config: %v", err)
} }
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign config: %v", err)
}
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session config: %v", err) t.Fatalf("write session config: %v", err)
} }
@@ -427,7 +465,22 @@ inputs:
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, sessionPath return pipelinePath, campaignPath, sessionPath
}
func writeAppTestCampaignConfig(t *testing.T, dir string) string {
t.Helper()
campaignPath := filepath.Join(dir, "campaign.yml")
campaignYAML := `campaign: sample-campaign
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err)
}
return campaignPath
} }
func writeManifestPathForExecute(t *testing.T) string { func writeManifestPathForExecute(t *testing.T) string {

View File

@@ -20,11 +20,13 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var pipelinePath string
var campaignPath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
@@ -40,12 +42,16 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("plan: %w", err) return fmt.Errorf("plan: %w", err)
} }
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignPath)
if err != nil {
return fmt.Errorf("plan: %w", err)
}
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
if err != nil { if err != nil {
return fmt.Errorf("plan: %w", err) return fmt.Errorf("plan: %w", err)
} }
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })

View File

@@ -15,10 +15,10 @@ import (
func TestPlanCreatesAndReusesWorkdir(t *testing.T) { func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer var out bytes.Buffer
args := []string{"--config", pipelinePath, "--session", sessionPath} args := []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}
if err := Plan(context.Background(), args, &out); err != nil { if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("first Plan() error = %v", err) t.Fatalf("first Plan() error = %v", err)
@@ -62,7 +62,7 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
func TestPlanShowsRunAndSkipFromManifest(t *testing.T) { func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
@@ -74,7 +74,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
} }
var out bytes.Buffer var out bytes.Buffer
if err := Plan(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out); err != nil { if err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out); err != nil {
t.Fatalf("Plan() error = %v", err) t.Fatalf("Plan() error = %v", err)
} }
got := out.String() got := out.String()
@@ -93,6 +93,7 @@ func TestPlanFailsWhenConfiguredSecretsDirMissing(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
configDir := t.TempDir() configDir := t.TempDir()
pipelinePath := filepath.Join(configDir, "pipeline.yml") pipelinePath := filepath.Join(configDir, "pipeline.yml")
campaignPath := writeAppTestCampaignConfig(t, configDir)
sessionPath := filepath.Join(configDir, "session.yml") sessionPath := filepath.Join(configDir, "session.yml")
pipelineYAML := `workspace: pipelineYAML := `workspace:
@@ -128,7 +129,7 @@ inputs:
} }
var out bytes.Buffer var out bytes.Buffer
err := Plan(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out) err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }

View File

@@ -26,6 +26,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
fs.SetOutput(out) fs.SetOutput(out)
var pipelinePath string var pipelinePath string
var campaignPath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
@@ -33,6 +34,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
var force bool var force bool
var includeAudio bool var includeAudio bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
@@ -40,7 +42,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state") fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects") fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
fs.Usage = func() { fs.Usage = func() {
_, _ = fmt.Fprintln(out, "Usage: narratio restore [--config <path>] [--session <path>] [--session-id <value>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]") _, _ = fmt.Fprintln(out, "Usage: narratio restore [--config <path>] [--campaign <path>] [--session <path>] [--session-id <value>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]")
_, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintln(out, "Flags:") _, _ = fmt.Fprintln(out, "Flags:")
fs.PrintDefaults() fs.PrintDefaults()
@@ -59,12 +61,16 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("restore: %w", err) return fmt.Errorf("restore: %w", err)
} }
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignPath)
if err != nil {
return fmt.Errorf("restore: %w", err)
}
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
if err != nil { if err != nil {
return fmt.Errorf("restore: %w", err) return fmt.Errorf("restore: %w", err)
} }
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })

View File

@@ -18,10 +18,10 @@ import (
func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) { func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`)) seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`))
seedRestoreObject(fake, sessionPrefix+"artifacts/session_recap.md", []byte("# recap\n")) seedRestoreObject(fake, sessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio")) seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio"))
@@ -32,7 +32,7 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -64,17 +64,17 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) { func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio")) seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio"))
restoreWithStoreAndRealPhases(t, fake) restoreWithStoreAndRealPhases(t, fake)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -89,10 +89,10 @@ func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) { func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`)) seedRestoreObject(fake, sessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`))
seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n")) seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
@@ -100,7 +100,7 @@ func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -116,10 +116,10 @@ func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) { func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript")) seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
@@ -129,7 +129,7 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -148,10 +148,10 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) { func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript")) seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
@@ -161,7 +161,7 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -174,10 +174,10 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) { func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# remote previous recap\n")) seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# remote previous recap\n"))
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
@@ -187,7 +187,7 @@ func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -196,10 +196,10 @@ func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) { func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript")) seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
store := artifacts.NewLocalStore(workspaceRoot) store := artifacts.NewLocalStore(workspaceRoot)
@@ -213,7 +213,7 @@ func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -229,10 +229,10 @@ func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.T) { func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
base := &storage.FakeBackend{} base := &storage.FakeBackend{}
cfg, sessionPrefix, manifestKey, _ := seedRestoreCommittedState(t, base, pipelinePath, sessionPath) cfg, sessionPrefix, manifestKey, _ := seedRestoreCommittedState(t, base, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(base, sessionPrefix+"transcripts/full.json", []byte("remote-transcript")) seedRestoreObject(base, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
toggled := &stagedManifestDownloadStore{ toggled := &stagedManifestDownloadStore{
@@ -260,7 +260,7 @@ func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -344,9 +344,9 @@ func restoreWithStoreAndRealPhases(t *testing.T, objectStore storage.ObjectStore
executeRestorePlanFn = executeRestorePlan executeRestorePlanFn = executeRestorePlan
} }
func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipelinePath, sessionPath string) (*config.Config, string, string, string) { func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipelinePath, campaignPath, sessionPath string) (*config.Config, string, string, string) {
t.Helper() t.Helper()
cfg, err := config.LoadWithSessionOptions(pipelinePath, sessionPath, config.SessionLoadOptions{}) cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
if err != nil { if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err) t.Fatalf("LoadWithSessionOptions() error = %v", err)
} }

View File

@@ -32,6 +32,9 @@ func TestExecuteRestoreHelp(t *testing.T) {
if !strings.Contains(out, "--include-audio") { if !strings.Contains(out, "--include-audio") {
t.Fatalf("stdout = %q, want --include-audio flag", out) t.Fatalf("stdout = %q, want --include-audio flag", out)
} }
if !strings.Contains(out, "--campaign") {
t.Fatalf("stdout = %q, want --campaign flag", out)
}
} }
func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) { func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
@@ -70,7 +73,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
} }
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -78,6 +81,7 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
[]string{ []string{
"restore", "restore",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03", "--session-id", "2026-05-03",
"--dry-run", "--dry-run",
@@ -116,11 +120,11 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) { func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "extra"}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -138,11 +142,11 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
}) })
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -166,11 +170,11 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
} }
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -208,7 +212,7 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
restoreEnv(secretKeyEnv) restoreEnv(secretKeyEnv)
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
secretsDir := filepath.Join(t.TempDir(), "secrets") secretsDir := filepath.Join(t.TempDir(), "secrets")
mustWriteTestFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-access-key-id\n") mustWriteTestFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-access-key-id\n")
mustWriteTestFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret-key\n") mustWriteTestFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret-key\n")
@@ -261,6 +265,7 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
[]string{ []string{
"restore", "restore",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03", "--session-id", "2026-05-03",
"--dry-run", "--dry-run",
@@ -307,10 +312,10 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
} }
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -359,10 +364,10 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
} }
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr) code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -374,11 +379,12 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
} }
} }
func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) { func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string, string) {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml") pipelinePath := filepath.Join(dir, "pipeline.yml")
campaignPath := writeAppTestCampaignConfig(t, dir)
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
pipelineYAML := `workspace: pipelineYAML := `workspace:
@@ -405,5 +411,5 @@ inputs:
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, sessionPath return pipelinePath, campaignPath, sessionPath
} }

View File

@@ -19,10 +19,10 @@ import (
func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) { func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath) cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n")) seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
seedRestoreObject(fake, manifestKey, restoreWorkflowManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)) seedRestoreObject(fake, manifestKey, restoreWorkflowManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`+"\n")) seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`+"\n"))
@@ -48,6 +48,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
[]string{ []string{
"restore", "restore",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", cfg.Session.SessionID, "--session-id", cfg.Session.SessionID,
}, },
@@ -86,6 +87,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
[]string{ []string{
"run-stage", "run-stage",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", cfg.Session.SessionID, "--session-id", cfg.Session.SessionID,
"--force", "--force",
@@ -157,7 +159,7 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
func TestRestoreThenAnalyzeUsesRestoredPreviousCacheWithoutObjectStore(t *testing.T) { func TestRestoreThenAnalyzeUsesRestoredPreviousCacheWithoutObjectStore(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
appendRestoreWorkflowScriptoriumConfig(t, pipelinePath, ` appendRestoreWorkflowScriptoriumConfig(t, pipelinePath, `
scriptorium: scriptorium:
binary: scriptorium binary: scriptorium
@@ -176,7 +178,7 @@ scriptorium:
`) `)
fakeStore := &storage.FakeBackend{} fakeStore := &storage.FakeBackend{}
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fakeStore, pipelinePath, sessionPath) cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fakeStore, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fakeStore, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n")) seedRestoreObject(fakeStore, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
seedRestoreObject(fakeStore, manifestKey, restoreWorkflowManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)) seedRestoreObject(fakeStore, manifestKey, restoreWorkflowManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
seedRestoreObject(fakeStore, sessionPrefix+"transcripts/trimmed.json", []byte(`{"segments":[]}`+"\n")) seedRestoreObject(fakeStore, sessionPrefix+"transcripts/trimmed.json", []byte(`{"segments":[]}`+"\n"))
@@ -191,6 +193,7 @@ scriptorium:
[]string{ []string{
"restore", "restore",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", cfg.Session.SessionID, "--session-id", cfg.Session.SessionID,
}, },
@@ -235,6 +238,7 @@ scriptorium:
[]string{ []string{
"run-stage", "run-stage",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", cfg.Session.SessionID, "--session-id", cfg.Session.SessionID,
"--force", "--force",

View File

@@ -17,12 +17,14 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var pipelinePath string
var campaignPath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
@@ -39,12 +41,16 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("resume: %w", err) return fmt.Errorf("resume: %w", err)
} }
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignPath)
if err != nil {
return fmt.Errorf("resume: %w", err)
}
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
if err != nil { if err != nil {
return fmt.Errorf("resume: %w", err) return fmt.Errorf("resume: %w", err)
} }
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })

View File

@@ -15,7 +15,7 @@ import (
func TestResumeStartsAfterCompletedStages(t *testing.T) { func TestResumeStartsAfterCompletedStages(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
@@ -32,7 +32,7 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer var out bytes.Buffer
err := Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out) err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Resume() error = %v", err)
} }
@@ -51,7 +51,7 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
func TestResumeNoRemainingStages(t *testing.T) { func TestResumeNoRemainingStages(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
@@ -64,7 +64,7 @@ func TestResumeNoRemainingStages(t *testing.T) {
} }
var out bytes.Buffer var out bytes.Buffer
err := Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out) err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Resume() error = %v", err)
} }
@@ -80,7 +80,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
_, _ = w.Write([]byte(`{"source":"resume-force-test","segments":[{"speaker":"alice"}]}`)) _, _ = w.Write([]byte(`{"source":"resume-force-test","segments":[{"speaker":"alice"}]}`))
})) }))
defer srv.Close() defer srv.Close()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
@@ -93,7 +93,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
} }
var out bytes.Buffer var out bytes.Buffer
err := Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--force"}, &out) err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out)
if err != nil { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Resume() error = %v", err)
} }
@@ -104,14 +104,14 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
func TestRunStageExecutesOnlySelectedStage(t *testing.T) { func TestRunStageExecutesOnlySelectedStage(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`)
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "polish"}, &out) err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage() error = %v", err) t.Fatalf("RunStage() error = %v", err)
} }
@@ -134,7 +134,7 @@ func TestRunStageExecutesOnlySelectedStage(t *testing.T) {
func TestRunStageSkipAndForce(t *testing.T) { func TestRunStageSkipAndForce(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`)
@@ -148,7 +148,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
} }
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "polish"}, &out) err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage() error = %v", err) t.Fatalf("RunStage() error = %v", err)
} }
@@ -157,7 +157,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
} }
out.Reset() out.Reset()
err = RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--force", "polish"}, &out) err = RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage(force) error = %v", err) t.Fatalf("RunStage(force) error = %v", err)
} }
@@ -168,7 +168,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing.T) { func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`)
@@ -184,7 +184,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
} }
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--force", "polish"}, &out) err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage(force) error = %v", err) t.Fatalf("RunStage(force) error = %v", err)
} }
@@ -203,7 +203,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
} }
out.Reset() out.Reset()
err = Resume(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath}, &out) err = Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Resume() error = %v", err)
} }
@@ -214,13 +214,13 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
func TestRunStageTrimExecutes(t *testing.T) { func TestRunStageTrimExecutes(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`)
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "trim"}, &out) err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "trim"}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage(trim) error = %v", err) t.Fatalf("RunStage(trim) error = %v", err)
} }
@@ -243,13 +243,13 @@ func TestRunStageTrimExecutes(t *testing.T) {
func TestRunStageNormalizeExecutes(t *testing.T) { func TestRunStageNormalizeExecutes(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1},{"id":2}]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1},{"id":2}]}`)
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "normalize"}, &out) err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "normalize"}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage(normalize) error = %v", err) t.Fatalf("RunStage(normalize) error = %v", err)
} }

View File

@@ -15,12 +15,14 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var pipelinePath string
var campaignPath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
@@ -37,12 +39,16 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("run: %w", err) return fmt.Errorf("run: %w", err)
} }
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignPath)
if err != nil {
return fmt.Errorf("run: %w", err)
}
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
if err != nil { if err != nil {
return fmt.Errorf("run: %w", err) return fmt.Errorf("run: %w", err)
} }
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })

View File

@@ -15,12 +15,14 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var pipelinePath string
var campaignPath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
@@ -50,12 +52,16 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("run-stage: %w", err) return fmt.Errorf("run-stage: %w", err)
} }
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignPath)
if err != nil {
return fmt.Errorf("run-stage: %w", err)
}
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath) resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
if err != nil { if err != nil {
return fmt.Errorf("run-stage: %w", err) return fmt.Errorf("run-stage: %w", err)
} }
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{ cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })

View File

@@ -994,10 +994,12 @@ func testConfig(t *testing.T) *config.Config {
workspace := t.TempDir() workspace := t.TempDir()
cfgDir := t.TempDir() cfgDir := t.TempDir()
sessionPath := filepath.Join(cfgDir, "session.yml") sessionPath := filepath.Join(cfgDir, "session.yml")
campaignPath := filepath.Join(cfgDir, "campaign.yml")
pipelinePath := filepath.Join(cfgDir, "pipeline.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n") mustWriteFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
@@ -1005,8 +1007,27 @@ func testConfig(t *testing.T) *config.Config {
return &config.Config{ return &config.Config{
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"},
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath,
SessionPath: sessionPath, SessionPath: sessionPath,
StableInputs: config.ResolvedStableInputs{
SpeakersFile: config.ResolvedInputFile{
Path: "./speakers.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
AutocorrectFile: config.ResolvedInputFile{
Path: "./autocorrect.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
GlossaryFile: config.ResolvedInputFile{
Path: "./glossary.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
},
Session: &config.SessionConfig{ Session: &config.SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",
Campaign: "sample-campaign", Campaign: "sample-campaign",
@@ -1023,6 +1044,7 @@ func testConfig(t *testing.T) *config.Config {
func TestBuildDefaultRunnersWithOmittedToolSections(t *testing.T) { func TestBuildDefaultRunnersWithOmittedToolSections(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml") pipelinePath := filepath.Join(dir, "pipeline.yml")
campaignPath := filepath.Join(dir, "campaign.yml")
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
pipelineYAML := `workspace: pipelineYAML := `workspace:
root: ` + t.TempDir() + ` root: ` + t.TempDir() + `
@@ -1032,6 +1054,12 @@ analyzer:
timeout: 20m timeout: 20m
notification: notification:
timeout: 10s timeout: 10s
`
campaignYAML := `campaign: sample-campaign
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
` `
sessionYAML := `session_id: 2026-05-03 sessionYAML := `session_id: 2026-05-03
campaign: sample-campaign campaign: sample-campaign
@@ -1042,6 +1070,7 @@ inputs:
glossary_file: ./glossary.yml glossary_file: ./glossary.yml
` `
mustWriteFile(t, pipelinePath, pipelineYAML) mustWriteFile(t, pipelinePath, pipelineYAML)
mustWriteFile(t, campaignPath, campaignYAML)
mustWriteFile(t, sessionPath, sessionYAML) mustWriteFile(t, sessionPath, sessionYAML)
cfg, err := config.Load(pipelinePath, sessionPath) cfg, err := config.Load(pipelinePath, sessionPath)

View File

@@ -11,7 +11,7 @@ import (
func TestPlanUsesDiscoveredSessionTemplateWithSessionIDs(t *testing.T) { func TestPlanUsesDiscoveredSessionTemplateWithSessionIDs(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot)
sessionTemplate := `session_id: "{{ session_id }}" sessionTemplate := `session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}" previous_session_id: "{{ previous_session_id }}"
@@ -51,10 +51,10 @@ inputs:
func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) { func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer var out bytes.Buffer
err := Plan(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--session-id", "2026-04-04"}, &out) err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-04-04"}, &out)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
@@ -65,7 +65,7 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
func TestPlanFailsWhenPreviousSessionIDMismatchesConcreteSession(t *testing.T) { func TestPlanFailsWhenPreviousSessionIDMismatchesConcreteSession(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
sessionYAML := `session_id: 2026-05-03 sessionYAML := `session_id: 2026-05-03
previous_session_id: 2026-04-26 previous_session_id: 2026-04-26
@@ -83,6 +83,7 @@ inputs:
var out bytes.Buffer var out bytes.Buffer
err := Plan(context.Background(), []string{ err := Plan(context.Background(), []string{
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03", "--session-id", "2026-05-03",
"--previous-session-id", "2026-04-25", "--previous-session-id", "2026-04-25",
@@ -97,10 +98,10 @@ inputs:
func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) { func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--session-id", "2026-05-03", "prepare"}, &out) err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "prepare"}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage() error = %v", err) t.Fatalf("RunStage() error = %v", err)
} }

View File

@@ -0,0 +1,138 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCampaignConfigDefaultSearchOrder(t *testing.T) {
want := []string{
"./campaign.yml",
"/usr/local/etc/narratio/campaign.yml",
"/etc/narratio/campaign.yml",
}
if len(DefaultCampaignConfigSearchPaths) != len(want) {
t.Fatalf("DefaultCampaignConfigSearchPaths = %#v, want %#v", DefaultCampaignConfigSearchPaths, want)
}
for i := range want {
if DefaultCampaignConfigSearchPaths[i] != want[i] {
t.Fatalf("DefaultCampaignConfigSearchPaths[%d] = %q, want %q", i, DefaultCampaignConfigSearchPaths[i], want[i])
}
}
}
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err == nil {
t.Fatal("expected load error, got nil")
}
if !strings.Contains(err.Error(), "campaign file") || !strings.Contains(err.Error(), "strict decode failed") {
t.Fatalf("error = %q, want campaign strict decode context", err.Error())
}
}
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
if cfg.Session.Campaign != "sample-campaign" {
t.Fatalf("session campaign = %q, want campaign config value", cfg.Session.Campaign)
}
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
}
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n",
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config")
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
}
func TestCampaignSessionMismatchFails(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
)
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err == nil {
t.Fatal("expected load error, got nil")
}
if !strings.Contains(err.Error(), "does not match campaign config") {
t.Fatalf("error = %q, want campaign mismatch context", err.Error())
}
}
func TestLoadMissingCampaignFileFails(t *testing.T) {
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")
_, err := LoadWithSessionOptions(pipelinePath, missingCampaignPath, sessionPath, SessionLoadOptions{})
if err == nil {
t.Fatal("expected load error, got nil")
}
if !strings.Contains(err.Error(), "load campaign config") {
t.Fatalf("error = %q, want campaign load context", err.Error())
}
}
func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string) (string, string, string) {
t.Helper()
dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml")
campaignPath := filepath.Join(dir, "campaign.yml")
sessionPath := filepath.Join(dir, "session.yml")
pipelineYAML := "workspace:\n root: " + filepath.ToSlash(filepath.Join(dir, "work")) + "\nwhisperx:\n transcribe_url: https://example.com/transcribe\nanalyzer:\n timeout: 20m\nnotification:\n timeout: 10s\n"
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err)
}
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err)
}
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
return pipelinePath, campaignPath, sessionPath
}
func assertResolvedStableInput(t *testing.T, got ResolvedInputFile, wantPath, wantConfigPath, wantSource string) {
t.Helper()
if got.Path != wantPath || got.ConfigPath != wantConfigPath || got.Source != wantSource {
t.Fatalf("resolved input = %#v, want path=%q config_path=%q source=%q", got, wantPath, wantConfigPath, wantSource)
}
}

View File

@@ -1,11 +1,16 @@
package config package config
// Config is the resolved combined configuration from pipeline.yml and session.yml. // Config is the resolved combined configuration from pipeline.yml,
// campaign.yml, and session.yml.
type Config struct { type Config struct {
Pipeline *PipelineConfig Pipeline *PipelineConfig
Campaign *CampaignConfig
Session *SessionConfig Session *SessionConfig
PipelinePath string PipelinePath string
CampaignPath string
SessionPath string SessionPath string
StableInputs ResolvedStableInputs
} }
// PipelineConfig contains durable pipeline-level settings. // PipelineConfig contains durable pipeline-level settings.
@@ -25,6 +30,19 @@ type PipelineConfig struct {
Notification NotificationConfig `yaml:"notification"` Notification NotificationConfig `yaml:"notification"`
} }
// CampaignConfig contains stable campaign-level identity and input defaults.
type CampaignConfig struct {
Campaign string `yaml:"campaign"`
Inputs CampaignInputsConfig `yaml:"inputs"`
}
// CampaignInputsConfig contains stable campaign-level input file references.
type CampaignInputsConfig struct {
SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"`
}
// SessionConfig contains per-session inputs and metadata. // SessionConfig contains per-session inputs and metadata.
type SessionConfig struct { type SessionConfig struct {
SessionID string `yaml:"session_id"` SessionID string `yaml:"session_id"`
@@ -229,3 +247,18 @@ type SessionInputsConfig struct {
type SessionAudioS3Input struct { type SessionAudioS3Input struct {
Prefix string `yaml:"prefix"` Prefix string `yaml:"prefix"`
} }
// ResolvedStableInputs records where stable input file paths came from after
// campaign/session merge.
type ResolvedStableInputs struct {
SpeakersFile ResolvedInputFile
AutocorrectFile ResolvedInputFile
GlossaryFile ResolvedInputFile
}
// ResolvedInputFile records one merged config path and its source config file.
type ResolvedInputFile struct {
Path string
ConfigPath string
Source string
}

View File

@@ -5,6 +5,9 @@ package config
const ( const (
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml" DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml" DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml"
DefaultCampaignConfigPathLocal = "./campaign.yml"
DefaultCampaignConfigPathUsrLocal = "/usr/local/etc/narratio/campaign.yml"
DefaultCampaignConfigPathEtc = "/etc/narratio/campaign.yml"
DefaultSessionConfigPathLocal = "./session.yml" DefaultSessionConfigPathLocal = "./session.yml"
DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml" DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml"
DefaultSessionConfigPathEtc = "/etc/narratio/session.yml" DefaultSessionConfigPathEtc = "/etc/narratio/session.yml"
@@ -88,6 +91,17 @@ var DefaultPipelineConfigSearchPaths = []string{
DefaultPipelineConfigPathEtc, DefaultPipelineConfigPathEtc,
} }
// DefaultCampaignConfigSearchPaths defines the default search order for
// campaign.yml when callers do not provide an explicit path.
//
// Keep this in a variable so future defaults can be extended without changing
// call sites.
var DefaultCampaignConfigSearchPaths = []string{
DefaultCampaignConfigPathLocal,
DefaultCampaignConfigPathUsrLocal,
DefaultCampaignConfigPathEtc,
}
// DefaultSessionConfigSearchPaths defines the default search order for // DefaultSessionConfigSearchPaths defines the default search order for
// session.yml when callers do not provide an explicit path. // session.yml when callers do not provide an explicit path.
// //

View File

@@ -22,6 +22,15 @@ func LoadPipeline(path string) (*PipelineConfig, error) {
return &cfg, nil return &cfg, nil
} }
// LoadCampaign loads campaign configuration from a YAML file with strict field checking.
func LoadCampaign(path string) (*CampaignConfig, error) {
var cfg CampaignConfig
if err := decodeStrictYAML("campaign", path, &cfg); err != nil {
return nil, fmt.Errorf("load campaign config: %w", err)
}
return &cfg, nil
}
// LoadSession loads session configuration from a YAML file with strict field checking. // LoadSession loads session configuration from a YAML file with strict field checking.
func LoadSession(path string) (*SessionConfig, error) { func LoadSession(path string) (*SessionConfig, error) {
return LoadSessionWithOptions(path, SessionLoadOptions{}) return LoadSessionWithOptions(path, SessionLoadOptions{})
@@ -71,32 +80,128 @@ func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfi
return &cfg, nil return &cfg, nil
} }
// Load loads and resolves combined pipeline and session configuration. // Load loads and resolves combined pipeline, campaign, and session configuration.
func Load(pipelinePath, sessionPath string) (*Config, error) { // Passing only a session path is supported for package-internal compatibility;
return LoadWithSessionOptions(pipelinePath, sessionPath, SessionLoadOptions{}) // in that form campaign.yml is expected next to the session file.
func Load(pipelinePath string, paths ...string) (*Config, error) {
campaignPath, sessionPath, err := campaignSessionPaths(paths...)
if err != nil {
return nil, err
}
return LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
} }
// LoadWithSessionOptions loads and resolves combined pipeline and session // LoadWithSessionOptions loads and resolves combined pipeline, campaign, and
// configuration with session template options. // session configuration with session template options.
func LoadWithSessionOptions(pipelinePath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) { func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
pipelineCfg, err := LoadPipeline(pipelinePath) pipelineCfg, err := LoadPipeline(pipelinePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
campaignCfg, err := LoadCampaign(campaignPath)
if err != nil {
return nil, err
}
sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts) sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
stableInputs, err := mergeCampaignSession(campaignCfg, sessionCfg, campaignPath, sessionPath)
if err != nil {
return nil, err
}
return &Config{ return &Config{
Pipeline: pipelineCfg, Pipeline: pipelineCfg,
Campaign: campaignCfg,
Session: sessionCfg, Session: sessionCfg,
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath,
SessionPath: sessionPath, SessionPath: sessionPath,
StableInputs: stableInputs,
}, nil }, nil
} }
func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) {
switch len(paths) {
case 1:
sessionPath = paths[0]
campaignPath = filepath.Join(filepath.Dir(sessionPath), "campaign.yml")
case 2:
campaignPath = paths[0]
sessionPath = paths[1]
default:
return "", "", fmt.Errorf("load config: expected session path or campaign and session paths")
}
return campaignPath, sessionPath, nil
}
func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig, campaignPath, sessionPath string) (ResolvedStableInputs, error) {
if campaignCfg == nil {
return ResolvedStableInputs{}, fmt.Errorf("campaign config is required")
}
if sessionCfg == nil {
return ResolvedStableInputs{}, fmt.Errorf("session config is required")
}
campaignName := strings.TrimSpace(campaignCfg.Campaign)
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName {
return ResolvedStableInputs{}, fmt.Errorf(
"campaign/session config invalid: session campaign %q does not match campaign config %q",
sessionCampaign,
campaignName,
)
}
if sessionCampaign == "" {
sessionCfg.Campaign = campaignName
}
stable := ResolvedStableInputs{
SpeakersFile: selectStableInput(
campaignCfg.Inputs.SpeakersFile,
sessionCfg.Inputs.SpeakersFile,
campaignPath,
sessionPath,
),
AutocorrectFile: selectStableInput(
campaignCfg.Inputs.AutocorrectFile,
sessionCfg.Inputs.AutocorrectFile,
campaignPath,
sessionPath,
),
GlossaryFile: selectStableInput(
campaignCfg.Inputs.GlossaryFile,
sessionCfg.Inputs.GlossaryFile,
campaignPath,
sessionPath,
),
}
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
return stable, nil
}
func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath string) ResolvedInputFile {
if strings.TrimSpace(sessionValue) != "" {
return ResolvedInputFile{
Path: sessionValue,
ConfigPath: sessionPath,
Source: "session_config",
}
}
return ResolvedInputFile{
Path: campaignValue,
ConfigPath: campaignPath,
Source: "campaign_config",
}
}
func decodeStrictYAML(kind, path string, out any) error { func decodeStrictYAML(kind, path string, out any) error {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {

View File

@@ -904,6 +904,7 @@ func TestValidateMissingAudioSource(t *testing.T) {
Report: boolPtr(true), Report: boolPtr(true),
}, },
}, },
Campaign: &CampaignConfig{Campaign: "sample-campaign"},
Session: &SessionConfig{ Session: &SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",
Campaign: "sample-campaign", Campaign: "sample-campaign",
@@ -963,6 +964,7 @@ func TestExamplesLoadAndValidate(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
pipelinePath := filepath.Join(examplesDir, tt.pipelineFile) pipelinePath := filepath.Join(examplesDir, tt.pipelineFile)
campaignPath := filepath.Join(examplesDir, "campaign.yml")
sessionPath := filepath.Join(examplesDir, tt.sessionFile) sessionPath := filepath.Join(examplesDir, tt.sessionFile)
var ( var (
@@ -972,7 +974,7 @@ func TestExamplesLoadAndValidate(t *testing.T) {
if strings.TrimSpace(tt.sessionOpts.SessionID) == "" { if strings.TrimSpace(tt.sessionOpts.SessionID) == "" {
cfg, err = Load(pipelinePath, sessionPath) cfg, err = Load(pipelinePath, sessionPath)
} else { } else {
cfg, err = LoadWithSessionOptions(pipelinePath, sessionPath, tt.sessionOpts) cfg, err = LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, tt.sessionOpts)
} }
if err != nil { if err != nil {
t.Fatalf("load example config error = %v", err) t.Fatalf("load example config error = %v", err)
@@ -1001,14 +1003,34 @@ func writeConfigFiles(t *testing.T, pipelineYAML, sessionYAML string) (string, s
dir := t.TempDir() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml") pipelinePath := filepath.Join(dir, "pipeline.yml")
campaignPath := filepath.Join(dir, "campaign.yml")
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)
} }
campaignYAML := `campaign: ` + campaignNameFromSessionYAML(sessionYAML) + `
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err)
}
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil { if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)
} }
return pipelinePath, sessionPath return pipelinePath, sessionPath
} }
func campaignNameFromSessionYAML(sessionYAML string) string {
for _, line := range strings.Split(sessionYAML, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "campaign:") {
return strings.Trim(strings.TrimSpace(strings.TrimPrefix(trimmed, "campaign:")), `"'`)
}
}
return "sample-campaign"
}

View File

@@ -17,6 +17,9 @@ func Validate(cfg *Config) error {
if cfg.Pipeline == nil { if cfg.Pipeline == nil {
return fmt.Errorf("pipeline config is required") return fmt.Errorf("pipeline config is required")
} }
if cfg.Campaign == nil {
return fmt.Errorf("campaign config is required")
}
if cfg.Session == nil { if cfg.Session == nil {
return fmt.Errorf("session config is required") return fmt.Errorf("session config is required")
} }
@@ -24,6 +27,9 @@ func Validate(cfg *Config) error {
if err := validatePipeline(cfg.Pipeline); err != nil { if err := validatePipeline(cfg.Pipeline); err != nil {
return fmt.Errorf("pipeline config %q invalid: %w", shortName(cfg.PipelinePath, "pipeline.yml"), err) return fmt.Errorf("pipeline config %q invalid: %w", shortName(cfg.PipelinePath, "pipeline.yml"), err)
} }
if err := validateCampaign(cfg.Campaign); err != nil {
return fmt.Errorf("campaign config %q invalid: %w", shortName(cfg.CampaignPath, "campaign.yml"), err)
}
if err := validateSession(cfg.Session); err != nil { if err := validateSession(cfg.Session); err != nil {
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err) return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
} }
@@ -34,6 +40,16 @@ func Validate(cfg *Config) error {
return nil return nil
} }
func validateCampaign(cfg *CampaignConfig) error {
if cfg == nil {
return fmt.Errorf("campaign config is required")
}
if strings.TrimSpace(cfg.Campaign) == "" {
return fmt.Errorf("campaign.campaign is required")
}
return nil
}
func validatePipeline(cfg *PipelineConfig) error { func validatePipeline(cfg *PipelineConfig) error {
if strings.TrimSpace(cfg.Workspace.Root) == "" { if strings.TrimSpace(cfg.Workspace.Root) == "" {
return fmt.Errorf("pipeline.workspace.root is required") return fmt.Errorf("pipeline.workspace.root is required")

View File

@@ -30,8 +30,10 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
store := artifacts.NewLocalStore(root) store := artifacts.NewLocalStore(root)
cfgDir := t.TempDir() cfgDir := t.TempDir()
sessionPath := filepath.Join(cfgDir, "session.yml") sessionPath := filepath.Join(cfgDir, "session.yml")
campaignPath := filepath.Join(cfgDir, "campaign.yml")
pipelinePath := filepath.Join(cfgDir, "pipeline.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n") writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n")
writeStageTestFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n") writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n")
writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
@@ -48,7 +50,9 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
env := &Env{ env := &Env{
Config: &config.Config{ Config: &config.Config{
SessionPath: sessionPath, SessionPath: sessionPath,
CampaignPath: campaignPath,
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"},
Pipeline: &config.PipelineConfig{ Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: root}, Workspace: config.WorkspaceConfig{Root: root},
Storage: config.StorageConfig{ Storage: config.StorageConfig{
@@ -62,14 +66,28 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
UploadRun: boolPtr(true), UploadRun: boolPtr(true),
}, },
}, },
StableInputs: config.ResolvedStableInputs{
SpeakersFile: config.ResolvedInputFile{
Path: "./speakers.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
AutocorrectFile: config.ResolvedInputFile{
Path: "./autocorrect.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
GlossaryFile: config.ResolvedInputFile{
Path: "./glossary.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
},
Session: &config.SessionConfig{ Session: &config.SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",
Campaign: "sample-campaign", Campaign: "sample-campaign",
Inputs: config.SessionInputsConfig{ Inputs: config.SessionInputsConfig{
AudioDir: "./audio", AudioDir: "./audio",
SpeakersFile: "./speakers.yml",
AutocorrectFile: "./autocorrect.yml",
GlossaryFile: "./glossary.yml",
}, },
}, },
}, },

View File

@@ -25,6 +25,7 @@ func (prepareStage) Name() string { return "prepare" }
func (prepareStage) Declares() IODecl { func (prepareStage) Declares() IODecl {
return IODecl{ return IODecl{
Inputs: []artifacts.Ref{ Inputs: []artifacts.Ref{
{Kind: "config", Category: "inputs", RelativePath: "campaign.yml"},
{Kind: "config", Category: "inputs", RelativePath: "session.yml"}, {Kind: "config", Category: "inputs", RelativePath: "session.yml"},
{Kind: "config", Category: "inputs", RelativePath: "pipeline.resolved.yml"}, {Kind: "config", Category: "inputs", RelativePath: "pipeline.resolved.yml"},
{Kind: "config", Category: "inputs", RelativePath: "speakers.yml"}, {Kind: "config", Category: "inputs", RelativePath: "speakers.yml"},
@@ -59,21 +60,30 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("prepare: ensure workdir layout: %w", err) return nil, fmt.Errorf("prepare: ensure workdir layout: %w", err)
} }
campaignSrc := env.Config.CampaignPath
if err := requireFile(campaignSrc, "campaign.yml"); err != nil {
return nil, fmt.Errorf("prepare: %w", err)
}
sessionSrc := env.Config.SessionPath sessionSrc := env.Config.SessionPath
if err := requireFile(sessionSrc, "session.yml"); err != nil { if err := requireFile(sessionSrc, "session.yml"); err != nil {
return nil, fmt.Errorf("prepare: %w", err) return nil, fmt.Errorf("prepare: %w", err)
} }
sessionDir := filepath.Dir(sessionSrc) sessionDir := filepath.Dir(sessionSrc)
speakersSrc, err := resolvePath(sessionDir, env.Config.Session.Inputs.SpeakersFile) speakersInput := stableInputSource(env.Config.StableInputs.SpeakersFile, env.Config.Session.Inputs.SpeakersFile, sessionSrc)
autocorrectInput := stableInputSource(env.Config.StableInputs.AutocorrectFile, env.Config.Session.Inputs.AutocorrectFile, sessionSrc)
glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc)
speakersSrc, err := resolveConfigRelativePath(speakersInput)
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare: speakers path: %w", err) return nil, fmt.Errorf("prepare: speakers path: %w", err)
} }
autocorrectSrc, err := resolvePath(sessionDir, env.Config.Session.Inputs.AutocorrectFile) autocorrectSrc, err := resolveConfigRelativePath(autocorrectInput)
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare: autocorrect path: %w", err) return nil, fmt.Errorf("prepare: autocorrect path: %w", err)
} }
glossarySrc, err := resolvePath(sessionDir, env.Config.Session.Inputs.GlossaryFile) glossarySrc, err := resolveConfigRelativePath(glossaryInput)
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare: glossary path: %w", err) return nil, fmt.Errorf("prepare: glossary path: %w", err)
} }
@@ -96,17 +106,27 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err) return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err)
} }
inputs := make([]manifest.InputRecord, 0, 5+len(resolvedLocalAudio)) inputs := make([]manifest.InputRecord, 0, 6+len(resolvedLocalAudio))
registerInput := func(kind, path, checksum string) { registerInput := func(kind, path, checksum string) {
inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum}) inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum})
} }
registerConfigInput := func(kind, path, checksum, source string) {
inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum, Source: source})
}
campaignDst := filepath.Join(paths.InputsDir, "campaign.yml")
campaignChecksum, err := copyFileIfChanged(env.ArtifactStore, campaignSrc, campaignDst)
if err != nil {
return nil, fmt.Errorf("prepare: materialize campaign.yml: %w", err)
}
registerConfigInput("campaign_config", campaignDst, campaignChecksum, "campaign_config")
sessionDst := filepath.Join(paths.InputsDir, "session.yml") sessionDst := filepath.Join(paths.InputsDir, "session.yml")
sessionChecksum, err := copyFileIfChanged(env.ArtifactStore, sessionSrc, sessionDst) sessionChecksum, err := copyFileIfChanged(env.ArtifactStore, sessionSrc, sessionDst)
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare: materialize session.yml: %w", err) return nil, fmt.Errorf("prepare: materialize session.yml: %w", err)
} }
registerInput("session_config", sessionDst, sessionChecksum) registerConfigInput("session_config", sessionDst, sessionChecksum, "session_config")
pipelineResolvedBytes, err := renderResolvedPipeline(env.Config.Pipeline) pipelineResolvedBytes, err := renderResolvedPipeline(env.Config.Pipeline)
if err != nil { if err != nil {
@@ -123,16 +143,17 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
kind string kind string
src string src string
dst string dst string
source string
}{ }{
{kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml")}, {kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source},
{kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml")}, {kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source},
{kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml")}, {kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source},
} { } {
checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst) checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst)
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare: materialize %s: %w", cfgFile.kind, err) return nil, fmt.Errorf("prepare: materialize %s: %w", cfgFile.kind, err)
} }
registerInput(cfgFile.kind, cfgFile.dst, checksum) registerConfigInput(cfgFile.kind, cfgFile.dst, checksum, cfgFile.source)
} }
if useS3Audio { if useS3Audio {
@@ -195,6 +216,31 @@ func renderResolvedPipeline(cfg *config.PipelineConfig) ([]byte, error) {
return yaml.Marshal(cfg) return yaml.Marshal(cfg)
} }
func stableInputSource(resolved config.ResolvedInputFile, fallbackPath, fallbackConfigPath string) config.ResolvedInputFile {
if strings.TrimSpace(resolved.Path) != "" || strings.TrimSpace(resolved.ConfigPath) != "" || strings.TrimSpace(resolved.Source) != "" {
if strings.TrimSpace(resolved.ConfigPath) == "" {
resolved.ConfigPath = fallbackConfigPath
}
if strings.TrimSpace(resolved.Source) == "" {
resolved.Source = "session_config"
}
return resolved
}
return config.ResolvedInputFile{
Path: fallbackPath,
ConfigPath: fallbackConfigPath,
Source: "session_config",
}
}
func resolveConfigRelativePath(input config.ResolvedInputFile) (string, error) {
basePath := strings.TrimSpace(input.ConfigPath)
if basePath == "" {
return "", fmt.Errorf("source config path is required")
}
return resolvePath(filepath.Dir(basePath), input.Path)
}
func resolveAudioInputs(sessionDir string, inputs config.SessionInputsConfig) ([]string, bool, error) { func resolveAudioInputs(sessionDir string, inputs config.SessionInputsConfig) ([]string, bool, error) {
hasLocal := strings.TrimSpace(inputs.AudioDir) != "" || len(inputs.AudioFiles) > 0 hasLocal := strings.TrimSpace(inputs.AudioDir) != "" || len(inputs.AudioFiles) > 0
if inputs.AudioS3 != nil { if inputs.AudioS3 != nil {

View File

@@ -35,6 +35,7 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) {
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
for _, p := range []string{ for _, p := range []string{
filepath.Join(paths.InputsDir, "campaign.yml"),
filepath.Join(paths.InputsDir, "session.yml"), filepath.Join(paths.InputsDir, "session.yml"),
filepath.Join(paths.InputsDir, "pipeline.resolved.yml"), filepath.Join(paths.InputsDir, "pipeline.resolved.yml"),
filepath.Join(paths.InputsDir, "speakers.yml"), filepath.Join(paths.InputsDir, "speakers.yml"),
@@ -48,8 +49,8 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) {
} }
} }
if len(m.Inputs) != 7 { if len(m.Inputs) != 8 {
t.Fatalf("manifest inputs len = %d, want 7", len(m.Inputs)) t.Fatalf("manifest inputs len = %d, want 8", len(m.Inputs))
} }
for _, in := range m.Inputs { for _, in := range m.Inputs {
if in.Checksum == "" { if in.Checksum == "" {
@@ -473,8 +474,15 @@ func setupPrepareEnv(t *testing.T) (*Env, *manifest.Manifest) {
sessionPath := filepath.Join(cfgDir, "session.yml") sessionPath := filepath.Join(cfgDir, "session.yml")
pipelinePath := filepath.Join(cfgDir, "pipeline.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
campaignPath := filepath.Join(cfgDir, "campaign.yml")
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
writeFile(t, campaignPath, `campaign: sample-campaign
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`)
writeFile(t, sessionPath, "session_id: 2026-05-03\n") writeFile(t, sessionPath, "session_id: 2026-05-03\n")
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
@@ -482,16 +490,32 @@ func setupPrepareEnv(t *testing.T) (*Env, *manifest.Manifest) {
cfg := &config.Config{ cfg := &config.Config{
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"},
SessionPath: sessionPath, SessionPath: sessionPath,
CampaignPath: campaignPath,
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
Session: &config.SessionConfig{ Session: &config.SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",
Campaign: "sample-campaign", Campaign: "sample-campaign",
Inputs: config.SessionInputsConfig{ Inputs: config.SessionInputsConfig{
AudioDir: "./audio", AudioDir: "./audio",
SpeakersFile: "./speakers.yml", },
AutocorrectFile: "./autocorrect.yml", },
GlossaryFile: "./glossary.yml", StableInputs: config.ResolvedStableInputs{
SpeakersFile: config.ResolvedInputFile{
Path: "./speakers.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
AutocorrectFile: config.ResolvedInputFile{
Path: "./autocorrect.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
GlossaryFile: config.ResolvedInputFile{
Path: "./glossary.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
}, },
}, },
} }

View File

@@ -233,8 +233,10 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
sessionPath := filepath.Join(cfgDir, "session.yml") sessionPath := filepath.Join(cfgDir, "session.yml")
pipelinePath := filepath.Join(cfgDir, "pipeline.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
campaignPath := filepath.Join(cfgDir, "campaign.yml")
writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n") writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n")
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
writeFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n") writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
@@ -243,7 +245,9 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
concurrency := 2 concurrency := 2
cfg := &config.Config{ cfg := &config.Config{
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath,
SessionPath: sessionPath, SessionPath: sessionPath,
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"},
Pipeline: &config.PipelineConfig{ Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: workspace}, Workspace: config.WorkspaceConfig{Root: workspace},
WhisperX: config.WhisperXConfig{ WhisperX: config.WhisperXConfig{
@@ -265,6 +269,11 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
GlossaryFile: "./glossary.yml", GlossaryFile: "./glossary.yml",
}, },
}, },
StableInputs: config.ResolvedStableInputs{
SpeakersFile: config.ResolvedInputFile{Path: "./speakers.yml", ConfigPath: campaignPath, Source: "campaign_config"},
AutocorrectFile: config.ResolvedInputFile{Path: "./autocorrect.yml", ConfigPath: campaignPath, Source: "campaign_config"},
GlossaryFile: config.ResolvedInputFile{Path: "./glossary.yml", ConfigPath: campaignPath, Source: "campaign_config"},
},
} }
env := &Env{ env := &Env{