10 Commits

116 changed files with 5682 additions and 2411 deletions

View File

@@ -5,7 +5,7 @@ Narratio is a Go orchestration application that turns D&D session audio into pol
It coordinates transcription, merge/polish/normalize/trim processing, artifact generation, archive publishing, and resumable run state in one operator workflow. It coordinates transcription, merge/polish/normalize/trim processing, artifact generation, archive publishing, and resumable run state in one operator workflow.
```bash ```bash
narratio run --session-id 2026-04-04 narratio run 2026-04-04
``` ```
This command requires discoverable `pipeline.yml` and `session.yml` files (or explicit `--config` and `--session` flags). This command requires discoverable `pipeline.yml` and `session.yml` files (or explicit `--config` and `--session` flags).

View File

@@ -3,86 +3,95 @@
## Shortest Useful Command ## Shortest Useful Command
```bash ```bash
narratio run --session-id 2026-04-04 narratio run 2026-04-04
``` ```
This command uses default system discovery for `pipeline.yml`, `campaign.yml`, and local `session.yml`. If local session discovery misses and S3 storage is configured, `--session-id` can load remote `session.yml` from the canonical session prefix. This command uses default system discovery for `pipeline.yml`, the pipeline default campaign ID, and local `session.yml`. If local session discovery misses and S3 storage is configured, the positional session ID loads remote `session.yml` from the canonical session prefix.
Default discovery checks system config locations only. Pass `--config`, `--campaign`, and `--session` to use files from the current working directory. Default pipeline and session discovery checks system config locations only. Pass `--config`, `--campaign-file`, and `--session` to use files from the current working directory. Pass `--campaign <id>` to select a campaign from `pipeline.campaigns.root`.
Ordinary local and remote `session.yml` files must be concrete YAML. Templates belong to `narratio session init`, which renders a configured campaign template before writing the concrete file.
## Command Overview ## Command Overview
Implemented commands: Top-level commands:
- `run`: execute pipeline stages and persist manifest state. - `run <session_id>`: execute pipeline stages and persist manifest state.
- `plan`: validate config, prepare workspace layout, and print stage run/skip decisions. - `run-stage <stage> <session_id>`: execute exactly one stage.
- `resume`: continue from first non-succeeded stage unless forced. - `resume <session_id>`: continue from first non-succeeded stage unless forced.
- `status`: read an existing manifest or inspect local/remote state for a session. - `analyze <session_id>`: force-rerun the analyze stage.
- `run-stage`: execute exactly one stage. - `publish <session_id>`: force-rerun the archive stage.
- `analyze`: force-rerun the analyze stage. - `clean <session_id>|--all`: remove local workspace/spool state.
- `restore`: restore durable local session state from the committed remote archive state. - `session <subcommand>`: session-scoped helper commands.
- `session validate`: run read-only preflight checks for a session.
- `session init`: create local or remote `session.yml`. Session subcommands:
- `artifacts list`: list effective artifact source IDs.
- `locks`: list, add, and remove archive promotion locks. - `session init <session_id>`: create local or remote `session.yml`.
- `clean`: remove local workspace/spool state for one session or all local sessions. - `session validate <session_id>`: run read-only preflight checks.
- `session status <session_id>`: inspect local/remote session state.
- `session plan <session_id>`: validate config, prepare workspace layout, and print stage run/skip decisions.
- `session restore <session_id>`: restore durable local state from committed remote archive state.
- `session artifacts <session_id>`: list effective artifact source IDs.
- `session locks <session_id>`: list archive promotion locks.
- `session locks add <session_id> <source>`: add or update a remote lock.
- `session locks remove <session_id> <source>`: remove a remote lock.
Unknown commands print usage and exit non-zero. Unknown commands print usage and exit non-zero.
For config semantics, see [docs/config.md](./config.md). For operator lifecycle and recovery, see [docs/operations.md](./operations.md). For config semantics, see [docs/config.md](./config.md). For operator lifecycle and recovery, see [docs/operations.md](./operations.md).
## Complete Flag Reference ## Common Flags
Most session-aware commands accept:
- `--config <path>`: optional explicit `pipeline.yml` path.
- `--campaign <id>`: optional campaign ID selector.
- `--campaign-file <path>`: optional explicit `campaign.yml` path.
- `--session <path>`: optional explicit concrete `session.yml` path.
- `--previous-session-id <value>`: expected previous session identifier.
The positional `<session_id>` is required even when `--session` is provided. It is used as the expected session identity and as the remote session lookup value when local session discovery misses.
## Command Reference
### `run` ### `run`
- `--config <path>`: optional explicit `pipeline.yml` path. ```bash
- `--campaign <path>`: optional explicit `campaign.yml` path. narratio run <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
- `--session <path>`: optional explicit `session.yml` path. ```
- `--session-id <value>`: session template variable value.
- `--previous-session-id <value>`: previous-session template variable value.
- `--force`: force stage execution.
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
### `plan` Purpose:
- Execute configured stages in canonical order.
- `--config <path>` Success output:
- `--campaign <path>` - `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
- `--session <path>`
- `--session-id <value>` Common failure cases:
- `--previous-session-id <value>` - missing system default config/session paths when flags are omitted.
- `--force` - missing selected campaign under `pipeline.campaigns.root`.
- missing local session plus missing/unavailable remote `session.yml`.
- templated `session.yml`; run `narratio session init` to generate concrete YAML.
- concrete session identity mismatch.
- unknown configured artifact key in `--artifacts`.
### `resume` ### `resume`
- `--config <path>` ```bash
- `--campaign <path>` narratio resume <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
- `--session <path>` ```
- `--session-id <value>`
- `--previous-session-id <value>` Purpose:
- `--force` - Continue from session-manifest stage status.
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
Success output:
- `narratio resume: session <session_id> has no remaining stages`
- or `narratio resume: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
### `run-stage` ### `run-stage`
- `--config <path>` ```bash
- `--campaign <path>` narratio run-stage <stage> <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
- `--session <path>` ```
- `--session-id <value>`
- `--previous-session-id <value>`
- `--force`
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
- positional `<stage>`: required stage name.
### `analyze`
- `--config <path>`
- `--campaign <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
`analyze` is force-by-design and does not accept `--force`.
Valid stage names: Valid stage names:
@@ -96,342 +105,39 @@ Valid stage names:
- `archive` - `archive`
- `notify` - `notify`
### `restore`
- `--config <path>`
- `--campaign <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--dry-run`: plan restore actions without writing local files.
- `--force`: overwrite local conflicting files with remote archive files.
- `--include-audio`: include durable archived `audio/**` files in restore scope.
### `clean`
- `--session-id <value>`: required for session cleanup unless `--all` is set.
- `--config <path>`
- `--campaign <path>`
- `--session <path>`
- `--previous-session-id <value>`
- `--all`: clean all local session work/spool state using pipeline config only.
- `--dry-run`: print cleanup targets without deleting.
- `--clear-cache`: also remove matching S3 audio cache entries.
### `status`
- `--manifest <path>`: inspect one manifest file.
- `--config <path>`
- `--campaign <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
### `session validate`
- `--config <path>`
- `--campaign <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
### `session init`
- `--config <path>`: required.
- `--campaign <path>`: required.
- `--session-id <value>`: required.
- `--output <path>`: local `session.yml` target; mutually exclusive with `--remote`.
- `--remote`: write remote `session.yml` to the canonical session prefix; mutually exclusive with `--output`.
- `--previous-session-id <value>`
- `--date <value>`
- `--title <value>`
- `--audio-s3-prefix <prefix>`: defaults to `audio/` when neither audio flag is provided.
- `--audio-dir <path>`: local audio directory; mutually exclusive with `--audio-s3-prefix`.
- `--force`: overwrite existing local or remote target.
### `artifacts list`
- `--config <path>`
- `--campaign <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--remote`: check remote availability for configured archive promotion destinations.
### `locks`
- `--session-id <value>`: required for list, add, and remove.
- `--config <path>`: optional explicit `pipeline.yml` path.
- `--campaign <path>`: optional explicit `campaign.yml` path.
- `--session <path>`: optional explicit `session.yml` path.
- `--previous-session-id <value>`: optional session template value.
- `add <source>`: add a remote lock for one artifact or transcript source.
- `add --reason <text>`: record an optional remote lock reason.
- `add --force`: update the reason for an existing remote lock.
- `remove <source>`: remove one remote lock.
## Command Reference
### `run`
Purpose:
- Execute configured stages in canonical order.
Syntax:
```bash
narratio run [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
```
Success output:
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
Common failure cases:
- missing system default config/campaign/session paths when flags omitted.
- missing local session plus missing/unavailable remote `session.yml`.
- invalid template/rendered session mismatch.
- unknown/invalid `--artifacts` value.
- `--artifacts` with unknown configured artifact key.
### `plan`
Purpose:
- Validate config, load secrets (if configured), prepare workdir, and print stage run/skip decisions.
Syntax:
```bash
narratio plan [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force]
```
Success output includes:
- `narratio plan: workdir prepared at <path>`
- one line per stage (`<stage>: run|skip`)
- `totals: run=<n> skip=<n>`
Common failure cases:
- same config/campaign/session discovery and validation failures as `run`.
- remote session fallback failures when local session discovery misses.
- secrets directory read failures when `pipeline.secrets.env_dir` is configured.
### `resume`
Purpose:
- Continue from session-manifest stage status.
Syntax:
```bash
narratio resume [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
```
Success output:
- `narratio resume: session <session_id> has no remaining stages`
- or `narratio resume: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
Common failure cases:
- same discovery/template/validation failures as `run`.
- manifest load errors when existing manifest is unreadable.
- invalid or unknown artifact selections.
### `status`
Purpose:
- Inspect one manifest file, or inspect configured local/remote state for a session.
Syntax:
```bash
narratio status --manifest <manifest.json>
narratio status [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>]
```
Manifest output includes:
- `session_id: <id>`
- `updated_at: <timestamp>`
- `stages:` entries (`- <stage>: <status>`)
Session output includes:
- session ID, campaign, workspace, session config source.
- local manifest state when present.
- remote current archive state when storage is configured.
- catalog-based remote output availability for expected transcript and artifact sources.
- effective archive locks and conservative next actions.
Common failure cases:
- missing `--manifest` when no config/session flags are provided.
- unreadable or invalid manifest path.
- invalid config or remote session fallback failure in session mode.
### `session validate`
Purpose:
- Run read-only preflight checks for a session.
Syntax:
```bash
narratio session validate [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>]
```
Checks include:
- effective config and session source.
- stable input files.
- local or remote audio availability.
- previous-session requirements.
- archive promotions and effective locks.
Warnings do not fail the command. Any `ERROR` finding exits non-zero.
### `session init`
Purpose:
- Create a strict-decoded session skeleton locally or in object storage.
Syntax:
```bash
narratio session init --config <pipeline.yml> --campaign <campaign.yml> --session-id <id> --output ./session.yml
narratio session init --config <pipeline.yml> --campaign <campaign.yml> --session-id <id> --remote
```
Behavior:
- exactly one of `--output` or `--remote` is required.
- remote writes target `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`.
- existing local or remote targets fail unless `--force` is passed.
- remote writes use existence checks, not compare-and-swap.
### `artifacts list`
Purpose:
- List built-in, configured, previous-session, promoted, and locked artifact sources.
Syntax:
```bash
narratio artifacts list [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--remote]
```
`--remote` checks promoted top-level object availability through the storage adapter. Remote markers appear only in the `Promoted` section, which reports each configured archive promotion destination and includes `dest=<path>` when that destination differs from the source's canonical path.
### `locks`
Purpose:
- Inspect and mutate source-based archive promotion locks for one session.
Syntax:
```bash
narratio locks --session-id <id>
narratio locks add --session-id <id> [--reason <text>] [--force] <source>
narratio locks remove --session-id <id> <source>
```
Behavior:
- `--session-id` is required for list, add, and remove.
- optional `--config`, `--campaign`, and `--session` override default config discovery.
- list mode prints effective locks from static `pipeline.archive.locks` and remote `{session_prefix}/locks.yml`.
- `locks add` writes only the remote lock store and fails if the source is already locked by pipeline config.
- `locks remove` removes only remote locks and cannot remove static pipeline locks.
- `locks add --force` is required to update an existing remote lock reason.
Examples:
```bash
narratio locks --session-id 2026-04-04
narratio locks add --session-id 2026-04-04 --reason "manual transcript review" narratio.transcript.trimmed
narratio locks remove --session-id 2026-04-04 narratio.transcript.trimmed
```
### `run-stage`
Purpose:
- Execute exactly one stage.
Syntax:
```bash
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:
- `narratio run-stage: stage=<name> executed=<n> skipped=<n> force=<true|false>; manifest=<path>` - `narratio run-stage: stage=<name> executed=<n> skipped=<n> force=<true|false>; manifest=<path>`
`--artifacts` behavior: `--artifacts` is accepted only for `analyze` and `archive`.
- accepted only when `<stage>` is `analyze`.
- names are normalized (trimmed, deduplicated, sorted).
- unknown configured artifact keys fail.
Common failure cases:
- missing stage positional arg.
- unknown stage name.
- using `--artifacts` with any non-`analyze` stage.
### `analyze` ### `analyze`
```bash
narratio analyze <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--artifacts <name[,name...]>]
```
Purpose: Purpose:
- Force-rerun the analyze stage. - Force-rerun the analyze stage.
- Provide a shorter equivalent for `narratio run-stage --force analyze`. - Shorter equivalent for `narratio run-stage analyze <session_id> --force`.
Syntax: `analyze` is force-by-design and does not accept `--force`.
### `publish`
```bash ```bash
narratio analyze [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--artifacts <name[,name...]>] narratio publish <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--artifacts <name[,name...]>]
``` ```
Success output:
- `narratio analyze: executed=<n> skipped=<n> force=true; manifest=<path>`
Common failure cases:
- positional arguments.
- `--force`, because force is implicit.
- unknown configured artifact keys.
### `restore`
Purpose: Purpose:
- Restore durable session state (`manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`, and optional `audio/**`) from the committed remote archive current state. - Force-rerun the archive stage.
- Shorter equivalent for `narratio run-stage archive <session_id> --force`.
Syntax: `publish` is force-by-design and does not accept `--force` or a stage positional argument.
```bash
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):
- `Restore plan for <campaign>/<session_id>`
- `Remote run: <run_id>`
- `Would download: <n>`
- `Would skip unchanged: <n>`
- `Conflicts: <n>`
Success output (non-dry-run):
- `Restored session archive for <campaign>/<session_id>`
- `Remote run: <run_id>`
- `Downloaded: <n>`
- `Skipped unchanged: <n>`
- `Conflicts: <n>`
Common failure cases:
- storage backend is not configured.
- remote `current/run_id.txt` missing/empty.
- remote `current/manifest.json` missing or invalid.
- remote manifest session/campaign mismatch.
- local conflicts without `--force`.
- session lock conflict.
When `--include-audio` is set, S3 audio files are restored through the shared audio cache. Cache hits avoid re-downloading large audio objects.
### `clean` ### `clean`
Purpose:
- Remove local Narratio work/spool state for testing, reruns, or recovery from corrupted local files.
- Preserve durable S3 audio cache state unless `--clear-cache` is passed.
Syntax:
```bash ```bash
narratio clean --session-id <id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--dry-run] [--clear-cache] narratio clean <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--dry-run] [--clear-cache]
narratio clean --all [--config <pipeline.yml>] [--dry-run] [--clear-cache] narratio clean --all [--config <pipeline.yml>] [--dry-run] [--clear-cache]
``` ```
@@ -449,62 +155,176 @@ Cache behavior:
- `--all --clear-cache` removes the configured Narratio S3 audio cache namespace for the configured bucket/root prefix. - `--all --clear-cache` removes the configured Narratio S3 audio cache namespace for the configured bucket/root prefix.
- `--clear-cache` does not delete arbitrary files under `pipeline.cache.root`. - `--clear-cache` does not delete arbitrary files under `pipeline.cache.root`.
Common failure cases: ### `session plan`
- missing `--session-id` when `--all` is not set.
- combining `--all` with `--campaign`, `--session`, `--session-id`, or `--previous-session-id`. ```bash
- unsafe cleanup target, such as a symlink, a non-directory session target, a configured root directory, or a path outside the configured root. narratio session plan <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force]
```
Purpose:
- Validate config, load secrets if configured, prepare workdir, and print stage run/skip decisions.
Success output includes:
- `narratio session plan: workdir prepared at <path>`
- one line per stage (`<stage>: run|skip`)
- `totals: run=<n> skip=<n>`
### `session status`
```bash
narratio session status <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
```
Output includes:
- session ID, campaign, workspace, and session config source.
- local manifest state when present.
- remote current archive state when storage is configured.
- catalog-based promoted output availability for expected transcript and artifact sources.
- effective archive locks and conservative next actions.
### `session validate`
```bash
narratio session validate <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
```
Checks include:
- effective config and session source.
- stable input files.
- local or remote audio availability.
- previous-session requirements.
- archive promotions and effective locks.
Warnings do not fail the command. Any `ERROR` finding exits non-zero.
### `session init`
```bash
narratio session init <session_id> --output ./session.yml
narratio session init <session_id> --remote
narratio session init <session_id> --config <pipeline.yml> --campaign icewind --remote
narratio session init <session_id> --config <pipeline.yml> --campaign-file ./campaign.yml --remote
```
Additional flags:
- `--previous-session-id <value>`
- `--date <value>`
- `--title <value>`
- `--audio-s3-prefix <prefix>`: defaults to `audio/` when neither audio flag is provided.
- `--audio-dir <path>`: local audio directory; mutually exclusive with `--audio-s3-prefix`.
- `--force`: overwrite existing local or remote target.
Behavior:
- exactly one of `--output` or `--remote` is required.
- `--config`, `--campaign`, and `--campaign-file` are optional overrides; omitted campaign selection uses `pipeline.campaigns.default_campaign_id`.
- `--campaign <id>` selects a campaign under `pipeline.campaigns.root`.
- `--campaign-file <path>` loads an explicit campaign file.
- if `campaign.yml` sets `session_template_file`, the template path is resolved relative to `campaign.yml` and rendered from init flags.
- if no session template is configured, a minimal concrete session file is generated directly.
- template variables must be supplied by matching flags, and supplied template-related flags must be used by the template.
- remote writes target `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`.
- existing local or remote targets fail unless `--force` is passed.
- remote writes use existence checks, not compare-and-swap.
### `session restore`
```bash
narratio session restore <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--dry-run] [--force] [--include-audio]
```
Purpose:
- Restore durable session state from the committed remote archive current state.
- Default restore installs `manifest.json`, `transcripts/**`, and `artifacts/**` from the current session archive.
- When configured previous-session inputs require it, restore reconstructs `previous/**` from the previous session's committed current archive.
- `audio/**` is restored only with `--include-audio`.
Dry-run output may include planned previous-cache downloads. Existing differing files under `previous/**` follow the normal restore conflict policy and require `--force` to overwrite.
When `--include-audio` is set, S3 audio files are restored through the shared audio cache. Cache hits avoid re-downloading large audio objects.
### `session artifacts`
```bash
narratio session artifacts <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--remote]
```
Purpose:
- List built-in, configured, previous-session, promoted, and locked artifact sources.
`--remote` checks promoted top-level object availability through the storage adapter. Remote markers appear only in the `Promoted` section, which reports each configured archive promotion destination and includes `dest=<path>` when that destination differs from the source's canonical path.
### `session locks`
```bash
narratio session locks <session_id> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
narratio session locks add <session_id> <source> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--reason <text>] [--force]
narratio session locks remove <session_id> <source> [--config <pipeline.yml>] [--campaign <id>] [--campaign-file <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
```
Behavior:
- list mode prints effective locks from static `pipeline.archive.locks` and remote `{session_prefix}/locks.yml`.
- `locks add` writes only the remote lock store and fails if the source is already locked by pipeline config.
- `locks remove` removes only remote locks and cannot remove static pipeline locks.
- `locks add --force` is required to update an existing remote lock reason.
## Common Workflows ## Common Workflows
Default-discovery run: Default-discovery run:
```bash ```bash
narratio run --session-id 2026-04-04 narratio run 2026-04-04
``` ```
Run only selected analyze artifacts: Run only selected analyze artifacts:
```bash ```bash
narratio run --session-id 2026-04-04 --artifacts session_recap,player_handout narratio run 2026-04-04 --artifacts session_recap,player_handout
``` ```
Resume with selected analyze artifacts: Resume with selected analyze artifacts:
```bash ```bash
narratio resume --session-id 2026-04-04 --artifacts player_handout narratio resume 2026-04-04 --artifacts player_handout
``` ```
Force-rerun analyze with selected artifacts: Force-rerun analyze with selected artifacts:
```bash ```bash
narratio analyze --session-id 2026-04-04 --artifacts player_handout narratio analyze 2026-04-04 --artifacts player_handout
```
Force-rerun archive publishing:
```bash
narratio publish 2026-04-04
``` ```
Preview restore actions without writes: Preview restore actions without writes:
```bash ```bash
narratio restore --session-id 2026-04-04 --dry-run narratio session restore 2026-04-04 --dry-run
``` ```
Restore and then force analyze: Restore and then force analyze:
```bash ```bash
narratio restore --session-id 2026-04-04 narratio session restore 2026-04-04
narratio analyze --session-id 2026-04-04 narratio analyze 2026-04-04
``` ```
Rehydrate canonical previous-session inputs after artifact-input changes: Rehydrate canonical previous-session inputs after artifact-input changes:
```bash ```bash
narratio run-stage --session-id 2026-04-04 --force prepare narratio run-stage prepare 2026-04-04 --force
``` ```
Reset local state before testing restore: Reset local state before testing restore:
```bash ```bash
narratio clean --session-id 2026-04-04 --dry-run narratio clean 2026-04-04 --dry-run
narratio clean --session-id 2026-04-04 narratio clean 2026-04-04
narratio restore --session-id 2026-04-04 --include-audio narratio session restore 2026-04-04 --include-audio
``` ```
Clean all local sessions while keeping cached S3 audio: Clean all local sessions while keeping cached S3 audio:
@@ -513,19 +333,9 @@ Clean all local sessions while keeping cached S3 audio:
narratio clean --all narratio clean --all
``` ```
## Diagnostic / Recovery Commands
Inspect stage status:
```bash
narratio status --manifest <manifest.json>
```
Get manifest path from previous output:
- `run`, `resume`, `run-stage`, and `analyze` print `manifest=<path>` on success.
## `--artifacts` and `--force` ## `--artifacts` and `--force`
- `--artifacts` filters which configured artifacts are executable when analyze runs. - `--artifacts` filters which configured artifacts are executable when analyze runs and which configured artifact promotions archive publishes.
- `--artifacts` does not imply `--force`. - `--artifacts` does not imply `--force`.
- if analyze is already `succeeded` and `--force` is not set, runner-level skip still applies. - if analyze is already `succeeded` and `--force` is not set, runner-level skip still applies.
- `--artifacts` does not suppress built-in transcript or bounds promotions.

View File

@@ -11,19 +11,27 @@ Narratio loads three YAML files:
These commands load and validate all three files before running: These commands load and validate all three files before running:
- `narratio run` - `narratio run`
- `narratio plan`
- `narratio resume` - `narratio resume`
- `narratio run-stage` - `narratio run-stage`
- `narratio restore` - `narratio analyze`
- `narratio publish`
- `narratio session plan`
- `narratio session status`
- `narratio session validate`
- `narratio session restore`
- `narratio session artifacts`
- `narratio session locks`
- `narratio clean <session_id>`
Behavior: 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. - ordinary local and remote `session.yml` files must be concrete YAML; template placeholders are rejected.
- remote `session.yml` uses the same strict decode and template behavior as local `session.yml`.
- defaults are applied for optional pipeline fields. - defaults are applied for optional pipeline fields.
- campaign identity is selected by ID from the pipeline campaign registry unless `--campaign-file` is used.
- campaign-level stable input paths fill missing session input paths. - campaign-level stable input paths fill missing session input paths.
- session-level stable input paths override campaign-level input paths. - session-level stable input paths override campaign-level input paths.
- campaign config may point `session init` to a session template.
- 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
@@ -31,10 +39,17 @@ Behavior:
These commands use the same config discovery behavior: These commands use the same config discovery behavior:
- `narratio run` - `narratio run`
- `narratio plan`
- `narratio resume` - `narratio resume`
- `narratio run-stage` - `narratio run-stage`
- `narratio restore` - `narratio analyze`
- `narratio publish`
- `narratio session plan`
- `narratio session status`
- `narratio session validate`
- `narratio session restore`
- `narratio session artifacts`
- `narratio session locks`
- `narratio clean <session_id>`
Pipeline config lookup: Pipeline config lookup:
@@ -46,11 +61,14 @@ Pipeline config lookup:
Campaign config lookup: Campaign config lookup:
- if `--campaign <path>` is provided, that path is used. - pipeline config is loaded first.
- if omitted, Narratio searches in order: - if `--campaign-file <path>` is provided, that path is used.
1. `/usr/local/etc/narratio/campaign.yml` - otherwise, if `--campaign <id>` is provided, Narratio loads:
2. `/etc/narratio/campaign.yml` - `{pipeline.campaigns.root}/{id}/campaign.yml`
- first existing file wins. - otherwise, Narratio uses `pipeline.campaigns.default_campaign_id` and loads:
- `{pipeline.campaigns.root}/{default_campaign_id}/campaign.yml`
- `--campaign` and `--campaign-file` are mutually exclusive.
- campaign IDs must be single path segments, not paths.
Session config lookup: Session config lookup:
@@ -59,31 +77,45 @@ Session config lookup:
1. `/usr/local/etc/narratio/session.yml` 1. `/usr/local/etc/narratio/session.yml`
2. `/etc/narratio/session.yml` 2. `/etc/narratio/session.yml`
- first existing local file wins. - first existing local file wins.
- if no local session file is found, `--session-id <value>` is present, storage is configured, and campaign identity is resolved, Narratio loads remote `session.yml` from: - if no local session file is found, a positional `<session_id>` is present, storage is configured, and campaign identity is resolved, Narratio loads remote `session.yml` from:
- `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml` - `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`
- local discovery always runs before remote fallback. - local discovery always runs before remote fallback.
- local files in the current working directory are used only when passed explicitly, for example `--config ./pipeline.yml --campaign ./campaign.yml --session ./session.yml`. - local files in the current working directory are used only when passed explicitly, for example `--config ./pipeline.yml --campaign-file ./campaign.yml --session ./session.yml`.
## 3. Session templating ## 3. Session templating
Template behavior for local and remote `session.yml`: Template behavior for local and remote `session.yml` loaded by downstream commands:
- supported placeholders: - downstream commands do not render templates.
- `{{session_id}}` - local and remote `session.yml` must be concrete.
- any `{{ ... }}` placeholder in loaded `session.yml` fails with guidance to run `narratio session init`.
- if concrete `session_id` mismatches the positional `<session_id>`, load fails.
- if concrete `previous_session_id` mismatches `--previous-session-id`, load fails.
Template behavior for `narratio session init`:
- `campaign.yml` may set `session_template_file`.
- relative template paths resolve relative to `campaign.yml`.
- supported init template variables:
- `{{ session_id }}` - `{{ session_id }}`
- `{{previous_session_id}}`
- `{{ previous_session_id }}` - `{{ previous_session_id }}`
- `--session-id <value>` supplies the placeholder value. - `{{ date }}`
- `--previous-session-id <value>` supplies the previous-session placeholder value. - `{{ title }}`
- unresolved placeholders fail load. - `{{ audio_s3_prefix }}`
- if rendered `session_id` mismatches `--session-id`, load fails. - `{{ audio_dir }}`
- if rendered `previous_session_id` mismatches `--previous-session-id`, load fails. - each template variable must be supplied by the matching `session init` flag.
- template-related flags such as `--date`, `--title`, `--audio-s3-prefix`, `--audio-dir`, and `--previous-session-id` fail if the configured template does not use them.
- rendered output is strict-decoded and validated before it is written locally or remotely.
- if `session_template_file` is omitted, `session init` generates the minimal concrete session YAML directly.
## 4. Minimal config set ## 4. Minimal config set
### `pipeline.yml` ### `pipeline.yml`
```yaml ```yaml
campaigns:
root: /usr/local/share/narratio/campaigns
default_campaign_id: sample-campaign
whisperx: whisperx:
transcribe_url: "https://transcription.example.com/transcribe" transcribe_url: "https://transcription.example.com/transcribe"
``` ```
@@ -91,13 +123,15 @@ whisperx:
Why this is sufficient: Why this is sufficient:
- `whisperx.transcribe_url` is required. - `whisperx.transcribe_url` is required.
- `campaigns.default_campaign_id` selects the default campaign when `--campaign` is omitted.
- `workspace.root` defaults to `/var/lib/narratio`. - `workspace.root` defaults to `/var/lib/narratio`.
- optional sections (`seriatim`, `audita`, `archive`, `scriptorium`, `trim`, `normalize`, etc.) receive defaults or stay inactive. - optional sections (`seriatim`, `audita`, `archive`, `scriptorium`, `trim`, `normalize`, etc.) receive defaults or stay inactive.
### `campaign.yml` ### `campaign.yml`
```yaml ```yaml
campaign: sample-campaign campaign_id: sample-campaign
session_template_file: ./session.template.yml
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
@@ -106,20 +140,20 @@ inputs:
Why this is sufficient: Why this is sufficient:
- `campaign` supplies the stable campaign identity. - `campaign_id` supplies the stable campaign identity.
- stable input files are required and resolve relative to `campaign.yml` when copied during `prepare`. - stable input files are required and resolve relative to `campaign.yml` when copied during `prepare`.
### `session.yml` ### `session.yml`
```yaml ```yaml
session_id: "{{ session_id }}" session_id: 2026-05-03
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
``` ```
Why this is sufficient: Why this is sufficient:
- `session_id` is required and can be rendered from `--session-id`. - `session_id` is required.
- `campaign` can be omitted because it is supplied by `campaign.yml`. - `campaign` can be omitted because it is supplied by `campaign.yml`.
- stable input paths can be omitted because `campaign.yml` supplies defaults. - stable input paths can be omitted because `campaign.yml` supplies defaults.
- local `audio_dir` resolves relative to `session.yml`. - local `audio_dir` resolves relative to `session.yml`.
@@ -127,20 +161,21 @@ Why this is sufficient:
Minimal local-file usage: Minimal local-file usage:
```bash ```bash
narratio run --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml --session-id 2026-05-03 narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign sample-campaign --session ./session.yml
narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign-file ./campaign.yml --session ./session.yml
``` ```
Previous-session-enabled variant: Previous-session-enabled variant:
```yaml ```yaml
session_id: "{{ session_id }}" session_id: 2026-05-03
previous_session_id: "{{ previous_session_id }}" previous_session_id: 2026-04-26
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
``` ```
```bash ```bash
narratio run --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml --session-id 2026-05-03 --previous-session-id 2026-04-26 narratio run 2026-05-03 --config /path/to/pipeline.yml --campaign sample-campaign --session ./session.yml --previous-session-id 2026-04-26
``` ```
## 5. Production-oriented config set ## 5. Production-oriented config set
@@ -161,6 +196,10 @@ storage:
access_key_id_env: OBJECT_STORAGE_KEY_ID access_key_id_env: OBJECT_STORAGE_KEY_ID
secret_access_key_env: OBJECT_STORAGE_KEY secret_access_key_env: OBJECT_STORAGE_KEY
campaigns:
root: /srv/narratio/campaigns
default_campaign_id: forsaken
spool: spool:
root: /var/spool/narratio root: /var/spool/narratio
delete_audio_after_archive: true delete_audio_after_archive: true
@@ -173,8 +212,8 @@ archive:
enabled: true enabled: true
upload_run: true upload_run: true
promote_artifacts: promote_artifacts:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/trimmed.json dest: transcripts/final.trimmed.json
required: true required: true
- source: narratio.artifact.session_recap - source: narratio.artifact.session_recap
dest: artifacts/session_recap.md dest: artifacts/session_recap.md
@@ -194,7 +233,7 @@ scriptorium:
output_path: artifacts/session_recap.md output_path: artifacts/session_recap.md
inputs: inputs:
transcript: transcript:
source: narratio.transcript.trimmed source: narratio.transcript.final_trimmed
required: true required: true
previous_recap: previous_recap:
source: narratio.previous_session.artifact.session_recap source: narratio.previous_session.artifact.session_recap
@@ -204,7 +243,7 @@ scriptorium:
### `campaign.yml` ### `campaign.yml`
```yaml ```yaml
campaign: forsaken campaign_id: forsaken
inputs: inputs:
speakers_file: /srv/narratio/campaigns/forsaken/speakers.yml speakers_file: /srv/narratio/campaigns/forsaken/speakers.yml
autocorrect_file: /srv/narratio/campaigns/forsaken/autocorrect.yml autocorrect_file: /srv/narratio/campaigns/forsaken/autocorrect.yml
@@ -214,8 +253,8 @@ inputs:
### Local `session.yml` ### Local `session.yml`
```yaml ```yaml
session_id: "{{ session_id }}" session_id: 2026-05-03
previous_session_id: "{{ previous_session_id }}" previous_session_id: 2026-04-26
date: 2026-05-03 date: 2026-05-03
title: The Black Cabin title: The Black Cabin
inputs: inputs:
@@ -234,7 +273,7 @@ For S3-first operation, upload the same `session.yml` content to:
Then run with explicit or discovered pipeline/campaign config and no `--session`: Then run with explicit or discovered pipeline/campaign config and no `--session`:
```bash ```bash
narratio run --config /usr/local/etc/narratio/pipeline.yml --campaign /usr/local/etc/narratio/campaign.yml --session-id 2026-05-03 --previous-session-id 2026-04-26 narratio run 2026-05-03 --config /usr/local/etc/narratio/pipeline.yml --campaign forsaken --previous-session-id 2026-04-26
``` ```
Operational notes: Operational notes:
@@ -253,10 +292,10 @@ Operational notes:
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `pipeline.workspace.root` | string | No | `/var/lib/narratio` | | `pipeline.workspace.root` | string | No | `/var/lib/narratio` |
| `pipeline.workspace.cleanup_after_archive` | bool | No | `false` | | `pipeline.workspace.cleanup_after_archive` | bool | No | `false` |
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
| `pipeline.campaigns.default_campaign_id` | string | No | empty |
| `pipeline.secrets.env_dir` | string | Conditional | none | | `pipeline.secrets.env_dir` | string | Conditional | none |
| `pipeline.storage.backend` | string | No | empty | | `pipeline.storage.backend` | string | No | empty |
| `pipeline.storage.bucket` | string | No | empty |
| `pipeline.storage.prefix` | string | No | empty |
| `pipeline.storage.s3.bucket` | string | Conditional | empty | | `pipeline.storage.s3.bucket` | string | Conditional | empty |
| `pipeline.storage.s3.root_prefix` | string | No | `dnd` | | `pipeline.storage.s3.root_prefix` | string | No | `dnd` |
| `pipeline.storage.s3.region` | string | No | empty | | `pipeline.storage.s3.region` | string | No | empty |
@@ -270,7 +309,7 @@ Operational notes:
| `pipeline.cache.s3_audio` | bool | No | `true` | | `pipeline.cache.s3_audio` | bool | No | `true` |
| `pipeline.archive.enabled` | bool | No | `true` | | `pipeline.archive.enabled` | bool | No | `true` |
| `pipeline.archive.upload_run` | bool | No | `true` | | `pipeline.archive.upload_run` | bool | No | `true` |
| `pipeline.archive.promote_artifacts[]` | list | No | trimmed transcript rule | | `pipeline.archive.promote_artifacts[]` | list | No | final-trimmed transcript rule |
| `pipeline.archive.promote_artifacts[].source` | string | Yes (per rule) | none | | `pipeline.archive.promote_artifacts[].source` | string | Yes (per rule) | none |
| `pipeline.archive.promote_artifacts[].dest` | string | No | derived from source | | `pipeline.archive.promote_artifacts[].dest` | string | No | derived from source |
| `pipeline.archive.promote_artifacts[].required` | bool | No | `true` | | `pipeline.archive.promote_artifacts[].required` | bool | No | `true` |
@@ -307,7 +346,7 @@ Operational notes:
| `pipeline.audita.output_schema` | string | No | empty | | `pipeline.audita.output_schema` | string | No | empty |
| `pipeline.audita.work_dir_retention` | string | No | empty | | `pipeline.audita.work_dir_retention` | string | No | empty |
| `pipeline.audita.report` | bool | No | `true` | | `pipeline.audita.report` | bool | No | `true` |
| `pipeline.normalize.output_path` | string | No | `transcripts/normalized.json` | | `pipeline.normalize.output_path` | string | No | `transcripts/final.json` |
| `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` | | `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` |
| `pipeline.normalize.report` | bool | No | `true` | | `pipeline.normalize.report` | bool | No | `true` |
| `pipeline.trim.enabled` | bool | No | `false` | | `pipeline.trim.enabled` | bool | No | `false` |
@@ -337,10 +376,6 @@ Operational notes:
| `pipeline.scriptorium.artifacts.<name>.inputs.<key>.path` | string | No | empty | | `pipeline.scriptorium.artifacts.<name>.inputs.<key>.path` | string | No | empty |
| `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required` | bool | No | `false` | | `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required` | bool | No | `false` |
| `pipeline.scriptorium.artifacts.<name>.vars.<key>` | map value | No | empty | | `pipeline.scriptorium.artifacts.<name>.vars.<key>` | map value | No | empty |
| `pipeline.analyzer.binary_path` | string | No | empty |
| `pipeline.analyzer.timeout` | duration string | No | empty |
| `pipeline.analyzer.artifacts.output_dir` | string | No | empty |
| `pipeline.analyzer.artifacts.types[]` | list[string] | No | empty |
| `pipeline.notification.backend` | string | No | empty | | `pipeline.notification.backend` | string | No | empty |
| `pipeline.notification.recipient` | string | No | empty | | `pipeline.notification.recipient` | string | No | empty |
| `pipeline.notification.timeout` | duration string | No | empty | | `pipeline.notification.timeout` | duration string | No | empty |
@@ -360,20 +395,19 @@ Scriptorium artifact-key and dependency rules:
Allowed `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` values: Allowed `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` values:
- `narratio.previous_session.artifact.<configured_artifact_key>` - `narratio.previous_session.artifact.<configured_artifact_key>`
- `narratio.transcript.merged` - `narratio.transcript.base`
- `narratio.transcript.polished` - `narratio.transcript.polished`
- `narratio.transcript.full` - `narratio.transcript.final`
- `narratio.transcript.trimmed` - `narratio.transcript.final_trimmed`
- `narratio.bounds.session` - `narratio.bounds.session`
- `narratio.artifact.<configured_artifact_key>` - `narratio.artifact.<configured_artifact_key>`
- `previous_session_artifact` (legacy path-based source; uses `inputs.<key>.path`)
`pipeline.archive.promote_artifacts[].source` values: `pipeline.archive.promote_artifacts[].source` values:
- `narratio.transcript.merged` - `narratio.transcript.base`
- `narratio.transcript.polished` - `narratio.transcript.polished`
- `narratio.transcript.full` - `narratio.transcript.final`
- `narratio.transcript.trimmed` - `narratio.transcript.final_trimmed`
- `narratio.bounds.session` - `narratio.bounds.session`
- `narratio.artifact.<configured_artifact_key>` - `narratio.artifact.<configured_artifact_key>`
@@ -401,7 +435,7 @@ Remote mutable lock store:
- path: `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/locks.yml`. - path: `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/locks.yml`.
- strict YAML shape: top-level `locks`, each with `source` and optional `reason`. - strict YAML shape: top-level `locks`, each with `source` and optional `reason`.
- `narratio locks add` and `narratio locks remove` mutate only the remote lock store. - `narratio session locks add` and `narratio session locks remove` mutate only the remote lock store.
- writes use existence checks plus `--force` for updates; they are not compare-and-swap atomic. - writes use existence checks plus `--force` for updates; they are not compare-and-swap atomic.
Restore-related implications: Restore-related implications:
@@ -415,12 +449,13 @@ Restore-related implications:
| Path | Type | Required | Default | | Path | Type | Required | Default |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `campaign.campaign` | string | Yes | none | | `campaign.campaign_id` | string | Yes | none |
| `campaign.session_template_file` | string | No | none |
| `campaign.inputs.speakers_file` | string | Yes | none | | `campaign.inputs.speakers_file` | string | Yes | none |
| `campaign.inputs.autocorrect_file` | string | Yes | none | | `campaign.inputs.autocorrect_file` | string | Yes | none |
| `campaign.inputs.glossary_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`. Campaign input paths and `campaign.session_template_file` may be absolute or relative. Relative paths resolve from the directory containing `campaign.yml`.
## 8. Full session reference ## 8. Full session reference
@@ -428,7 +463,7 @@ Campaign input paths may be absolute or relative. Relative paths resolve from th
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `session.session_id` | string | Yes | none | | `session.session_id` | string | Yes | none |
| `session.previous_session_id` | string | No | empty | | `session.previous_session_id` | string | No | empty |
| `session.campaign` | string | No | `campaign.campaign` | | `session.campaign` | string | No | `campaign.campaign_id` |
| `session.date` | string | No | empty | | `session.date` | string | No | empty |
| `session.title` | string | No | empty | | `session.title` | string | No | empty |
| `session.inputs.audio_dir` | string | Conditional | empty | | `session.inputs.audio_dir` | string | Conditional | empty |
@@ -479,8 +514,11 @@ Maintained examples:
- `examples/pipeline.minimal.yml` - `examples/pipeline.minimal.yml`
- `examples/pipeline.production.yml` - `examples/pipeline.production.yml`
- `examples/pipeline.full.annotated.yml` - `examples/pipeline.full.annotated.yml`
- `examples/campaign.yml` - `examples/campaigns/sample-campaign/campaign.yml`
- `examples/session.template.yml` - `examples/campaigns/sample-campaign/speakers.yml`
- `examples/campaigns/sample-campaign/autocorrect.yml`
- `examples/campaigns/sample-campaign/glossary.yml`
- `examples/campaigns/sample-campaign/session.template.yml`
- `examples/session.local-audio.yml` - `examples/session.local-audio.yml`
- `examples/session.s3-audio.yml` - `examples/session.s3-audio.yml`

View File

@@ -5,9 +5,9 @@ Define Narratio's adapter contract for transcript polishing via Audita CLI subpr
## Inputs and Outputs ## Inputs and Outputs
Inputs (`audita.PolishRequest`): Inputs (`audita.PolishRequest`):
- merged transcript path - base transcript path
- glossary path - glossary path
- output processed transcript path - output polished transcript path
- optional report path (required when report enabled) - optional report path (required when report enabled)
- work dir - work dir
- generated config path - generated config path
@@ -15,7 +15,7 @@ Inputs (`audita.PolishRequest`):
- optional module/model/base URL and concurrency knobs - optional module/model/base URL and concurrency knobs
Outputs (`audita.PolishResult`): Outputs (`audita.PolishResult`):
- processed transcript path - polished transcript path
- optional report path - optional report path
- generated config path - generated config path
- stdout/stderr log paths - stdout/stderr log paths
@@ -27,7 +27,7 @@ Owns:
- Deterministic CLI argument construction for `audita process` - Deterministic CLI argument construction for `audita process`
- Environment bridging for API credentials - Environment bridging for API credentials
- Invocation config emission - Invocation config emission
- Output validation for processed transcript and report - Output validation for polished transcript and report
Does not own: Does not own:
- Upstream/downstream stage orchestration - Upstream/downstream stage orchestration
@@ -52,7 +52,7 @@ Via `pipeline.audita.*` mapped in app/stage wiring:
## Failure Behavior ## Failure Behavior
- Constructor validation fails on invalid binary/timeout/schema/concurrency/URL values. - Constructor validation fails on invalid binary/timeout/schema/concurrency/URL values.
- Run fails on missing required paths, missing required credential env var, subprocess errors, invalid processed JSON shape, or invalid report JSON. - Run fails on missing required paths, missing required credential env var, subprocess errors, invalid polished JSON shape, or invalid report JSON.
- Failures preserve stdout/stderr paths in returned result metadata. - Failures preserve stdout/stderr paths in returned result metadata.
## Tests to Inspect Before Changing ## Tests to Inspect Before Changing
@@ -61,6 +61,6 @@ Via `pipeline.audita.*` mapped in app/stage wiring:
- `internal/stage/polish_test.go` - `internal/stage/polish_test.go`
## Architectural Invariants ## Architectural Invariants
- Processed output must be valid JSON with top-level `segments` array. - Polished output must be valid JSON with top-level `segments` array.
- When report is enabled, report output must be valid JSON. - When report is enabled, report output must be valid JSON.
- If `llm_api_key_env` is configured, credential must be present in environment. - If `llm_api_key_env` is configured, credential must be present in environment.

View File

@@ -5,7 +5,7 @@ Define Narratio's adapter contract for merge, normalize, and trim subprocess inv
## Inputs and Outputs ## Inputs and Outputs
Inputs: Inputs:
- `MergeRequest`: raw/normalized transcript inputs, output path, optional report, speaker/autocorrect paths, logs/config - `MergeRequest`: raw/per-speaker normalized transcript inputs, base output path, optional report, speaker/autocorrect paths, logs/config
- `NormalizeRequest`: input transcript, output path, schema, optional report, timeout/log/config - `NormalizeRequest`: input transcript, output path, schema, optional report, timeout/log/config
- `TrimRequest`: input transcript, output path, keep selector, timeout/log/config - `TrimRequest`: input transcript, output path, keep selector, timeout/log/config
@@ -44,8 +44,8 @@ Via `pipeline.seriatim.*` mapped in app/stage wiring:
## Failure Behavior ## Failure Behavior
- Constructor fails for invalid binary/timeout/output-schema/coalesce-gap. - Constructor fails for invalid binary/timeout/output-schema/coalesce-gap.
- Merge fails on missing output path/inputs/report path (if enabled), subprocess errors, invalid merged output JSON, invalid report JSON. - Merge fails on missing output path/inputs/report path (if enabled), subprocess errors, invalid merged output JSON, invalid report JSON.
- Normalize fails on missing input/output, invalid schema, subprocess errors, invalid normalized output JSON shape, invalid report JSON. - Normalize fails on missing input/output, invalid schema, subprocess errors, invalid final output JSON shape, invalid report JSON.
- Trim fails on missing input/output/keep selector, subprocess errors, invalid trimmed output JSON shape. - Trim fails on missing input/output/keep selector, subprocess errors, invalid final-trimmed output JSON shape.
## Tests to Inspect Before Changing ## Tests to Inspect Before Changing
- `internal/adapters/seriatim/subprocess_test.go` - `internal/adapters/seriatim/subprocess_test.go`
@@ -56,5 +56,5 @@ Via `pipeline.seriatim.*` mapped in app/stage wiring:
## Architectural Invariants ## Architectural Invariants
- Supported output schemas are limited to `seriatim-minimal`, `seriatim-intermediate`, `seriatim-full`. - Supported output schemas are limited to `seriatim-minimal`, `seriatim-intermediate`, `seriatim-full`.
- Normalize/trim outputs must include `segments` arrays. - Final and final-trimmed outputs must include `segments` arrays.
- Merge/normalize/trim all route through deterministic subprocess invocation. - Merge/normalize/trim all route through deterministic subprocess invocation.

View File

@@ -39,11 +39,10 @@ Runtime env boundary fields (`internal/stage.Env`):
- `scriptorium.Runner` - `scriptorium.Runner`
- `storage.ObjectStore` - `storage.ObjectStore`
- `notify.Sender` - `notify.Sender`
- `analyzer.Runner`
Current execution usage: Current execution usage:
- Actively used by implemented stages: `WhisperX`, `Seriatim`, `Audita`, `Scriptorium`, `ObjectStore`, `Notifier`. - Actively used by implemented stages: `WhisperX`, `Seriatim`, `Audita`, `Scriptorium`, `ObjectStore`, `Notifier`.
- Present but not used by implemented stage set: `Analyzer`, legacy `storage.Backend`. - Present but not used by implemented stage set: legacy `storage.Backend`.
Default construction in app runner: Default construction in app runner:
- Auto-constructed when not injected: WhisperX HTTP client, Seriatim subprocess runner, Audita subprocess runner, Scriptorium subprocess runner, object store (only when needed), and `notify.NoopSender`. - Auto-constructed when not injected: WhisperX HTTP client, Seriatim subprocess runner, Audita subprocess runner, Scriptorium subprocess runner, object store (only when needed), and `notify.NoopSender`.
@@ -71,7 +70,6 @@ Default construction in app runner:
- `internal/adapters/scriptorium/subprocess_test.go` - `internal/adapters/scriptorium/subprocess_test.go`
- `internal/adapters/storage/*_test.go` - `internal/adapters/storage/*_test.go`
- `internal/adapters/notify/fake_test.go` - `internal/adapters/notify/fake_test.go`
- `internal/adapters/analyzer/fake_test.go`
- `internal/app/runner_test.go` - `internal/app/runner_test.go`
## Architectural invariants ## Architectural invariants

View File

@@ -33,10 +33,10 @@ Does not own:
## Built-in IDs ## Built-in IDs
| Artifact ID | Canonical file | Producer stage | Output kind | | Artifact ID | Canonical file | Producer stage | Output kind |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `narratio.transcript.merged` | `transcripts/merged.json` | `merge` | `transcript_merged` | | `narratio.transcript.base` | `transcripts/base.json` | `merge` | `transcript_base` |
| `narratio.transcript.polished` | `transcripts/processed.json` | `polish` | `transcript_processed` | | `narratio.transcript.polished` | `transcripts/polished.json` | `polish` | `transcript_polished` |
| `narratio.transcript.full` | `transcripts/normalized.json` | `normalize` | `transcript_normalized` | | `narratio.transcript.final` | `transcripts/final.json` | `normalize` | `transcript_final` |
| `narratio.transcript.trimmed` | `transcripts/trimmed.json` | `trim` | `transcript_trimmed` | | `narratio.transcript.final_trimmed` | `transcripts/final.trimmed.json` | `trim` | `transcript_final_trimmed` |
| `narratio.bounds.session` | `artifacts/session_bounds.json` | `trim` | `session_bounds` | | `narratio.bounds.session` | `artifacts/session_bounds.json` | `trim` | `session_bounds` |
## Source families ## Source families

View File

@@ -1,11 +1,12 @@
# Internal: Command Restore # Internal: Command Restore
## Purpose ## Purpose
Define the implemented `narratio restore` contract: committed remote-state discovery, deterministic plan classification, safe file install semantics, and restore reporting. Define the implemented `narratio session restore` contract: committed remote-state discovery, deterministic plan classification, safe file install semantics, and restore reporting.
## Inputs and outputs ## Inputs and outputs
Inputs: Inputs:
- CLI flags: `--config`, `--session`, `--session-id`, `--previous-session-id`, `--dry-run`, `--force`, `--include-audio`. - CLI syntax: `narratio session restore <session_id>`.
- CLI flags: `--config`, `--campaign`, `--campaign-file`, `--session`, `--previous-session-id`, `--dry-run`, `--force`, `--include-audio`.
- Resolved/validated `pipeline.yml` and `session.yml`. - Resolved/validated `pipeline.yml` and `session.yml`.
- Configured remote object store. - Configured remote object store.
- Remote committed current-state markers (`current/run_id.txt`, `current/manifest.json`). - Remote committed current-state markers (`current/run_id.txt`, `current/manifest.json`).

View File

@@ -12,8 +12,7 @@ Inputs:
Source types used by analyze: Source types used by analyze:
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`; - built-ins: `narratio.transcript.*`, `narratio.bounds.session`;
- configured artifacts: `narratio.artifact.<artifact_key>`; - configured artifacts: `narratio.artifact.<artifact_key>`;
- canonical previous-session artifacts: `narratio.previous_session.artifact.<artifact_key>`; - canonical previous-session artifacts: `narratio.previous_session.artifact.<artifact_key>`.
- legacy path-based previous-session source: `previous_session_artifact` (uses `inputs.*.path`).
Outputs: Outputs:
- promoted configured artifact files at each configured `output_path`; - promoted configured artifact files at each configured `output_path`;

View File

@@ -52,10 +52,12 @@ Does not own:
- Resolves bucket/prefix from manifest identity first, then config fallback. - Resolves bucket/prefix from manifest identity first, then config fallback.
- Uploads session `previous/**` files as durable session state when the local `previous/` directory exists. - Uploads session `previous/**` files as durable session state when the local `previous/` directory exists.
- Skips top-level promotion uploads for effective locked sources; run-local uploads still publish. - Skips top-level promotion uploads for effective locked sources; run-local uploads still publish.
- When selected configured artifact keys are supplied, skips promotion rules for unselected `narratio.artifact.<key>` sources; built-in transcript and bounds promotions still publish.
- Effective locks are the union of `pipeline.archive.locks` and remote `{session_prefix}/locks.yml`; static pipeline locks win on duplicate sources. - Effective locks are the union of `pipeline.archive.locks` and remote `{session_prefix}/locks.yml`; static pipeline locks win on duplicate sources.
- Writes metadata including: - Writes metadata including:
- upload counts/paths - upload counts/paths
- `previous_files_uploaded` and `previous_uploaded_paths` - `previous_files_uploaded` and `previous_uploaded_paths`
- `skipped_unselected_promotions`
- `locked_promotion_count` and `locked_promotions` - `locked_promotion_count` and `locked_promotions`
- `current_manifest_key` - `current_manifest_key`
- `current_run_id_key` - `current_run_id_key`

View File

@@ -1,7 +1,7 @@
# Stage: merge # Stage: merge
## Purpose ## Purpose
Normalize per-speaker raw transcripts and merge them into one merged transcript via Seriatim. Normalize per-speaker raw transcripts and merge them into the base transcript via Seriatim.
## Inputs and Outputs ## Inputs and Outputs
Inputs: Inputs:
@@ -10,7 +10,7 @@ Inputs:
- `inputs/autocorrect.yml` - `inputs/autocorrect.yml`
Outputs: Outputs:
- `transcripts/merged.json` - `transcripts/base.json`
- optional `artifacts/seriatim.report.json` (when report enabled) - optional `artifacts/seriatim.report.json` (when report enabled)
## Boundaries ## Boundaries
@@ -19,7 +19,7 @@ Owns:
- Per-input normalize calls to Seriatim - Per-input normalize calls to Seriatim
- Final merge call to Seriatim - Final merge call to Seriatim
- Run-local log/config/report path wiring - Run-local log/config/report path wiring
- Promotion of merged/report outputs to canonical paths - Promotion of base/report outputs to canonical paths
Does not own: Does not own:
- Transcript polishing or downstream artifact generation - Transcript polishing or downstream artifact generation
@@ -43,7 +43,7 @@ Does not own:
## State and Manifest Behavior ## State and Manifest Behavior
- Reads transcript inputs from transcribe stage outputs in manifest when present; falls back to canonical raw directory. - Reads transcript inputs from transcribe stage outputs in manifest when present; falls back to canonical raw directory.
- Writes run-local outputs/logs/config under `runs/{run_id}/merge/...` when enabled. - Writes run-local outputs/logs/config under `runs/{run_id}/merge/...` when enabled.
- Promotes canonical merged transcript and optional report. - Promotes canonical base transcript and optional report.
- Records normalized-input provenance and adapter metadata in stage metadata. - Records normalized-input provenance and adapter metadata in stage metadata.
## Skip and Resume Behavior ## Skip and Resume Behavior
@@ -51,7 +51,7 @@ Does not own:
- Forced rerun of this or upstream stages can stale downstream succeeded stages via runner invalidation. - Forced rerun of this or upstream stages can stale downstream succeeded stages via runner invalidation.
## Failure Behavior ## Failure Behavior
- Fails on missing/invalid raw transcripts, missing speakers/autocorrect files, normalize failure, merge failure, invalid merged output JSON, or invalid report JSON when enabled. - Fails on missing/invalid raw transcripts, missing speakers/autocorrect files, normalize failure, merge failure, invalid base output JSON, or invalid report JSON when enabled.
## Tests to Inspect Before Changing ## Tests to Inspect Before Changing
- `internal/stage/merge_test.go` - `internal/stage/merge_test.go`
@@ -59,5 +59,5 @@ Does not own:
## Architectural Invariants ## Architectural Invariants
- Merge consumes normalized forms of each raw transcript. - Merge consumes normalized forms of each raw transcript.
- Merged transcript must validate before promotion. - Base transcript must validate before promotion.
- Report output is optional and gated by config. - Report output is optional and gated by config.

View File

@@ -1,22 +1,22 @@
# Stage: normalize # Stage: normalize
## Purpose ## Purpose
Normalize the processed transcript into a deterministic intermediate schema for trim and optionally emit a normalize report. Normalize the polished transcript into the full final transcript and optionally emit a normalize report.
## Inputs and Outputs ## Inputs and Outputs
Inputs: Inputs:
- `transcripts/processed.json` - `transcripts/polished.json`
Outputs: Outputs:
- `transcripts/normalized.json` (or configured normalize output path) - `transcripts/final.json` (or configured normalize output path)
- optional `artifacts/seriatim.normalize.report.json` - optional `artifacts/seriatim.normalize.report.json`
## Boundaries ## Boundaries
Owns: Owns:
- Processed transcript discovery/validation - Polished transcript discovery/validation
- Normalize request construction and invocation - Normalize request construction and invocation
- Optional normalize report wiring - Optional normalize report wiring
- Promotion of normalized transcript and optional report - Promotion of final transcript and optional report
Does not own: Does not own:
- Bounds detection or segment trimming - Bounds detection or segment trimming
@@ -35,9 +35,9 @@ Does not own:
- Seriatim adapter (`Normalize`). - Seriatim adapter (`Normalize`).
## State and Manifest Behavior ## State and Manifest Behavior
- Reads processed transcript from polish outputs in manifest when present; falls back to canonical path. - Reads polished transcript from polish outputs in manifest when present; falls back to canonical path.
- Uses run-local output/report/log/config paths when run layout is enabled. - Uses run-local output/report/log/config paths when run layout is enabled.
- Promotes canonical normalized transcript and optional normalize report. - Promotes canonical final transcript and optional normalize report.
- Records adapter/result metadata including source path selection. - Records adapter/result metadata including source path selection.
## Skip and Resume Behavior ## Skip and Resume Behavior
@@ -45,12 +45,12 @@ Does not own:
- Forced reruns can stale downstream succeeded stages. - Forced reruns can stale downstream succeeded stages.
## Failure Behavior ## Failure Behavior
- Fails on missing/invalid processed transcript, adapter error, invalid normalized output, or invalid report output when report enabled. - Fails on missing/invalid polished transcript, adapter error, invalid final output, or invalid report output when report enabled.
## Tests to Inspect Before Changing ## Tests to Inspect Before Changing
- `internal/stage/normalize_test.go` - `internal/stage/normalize_test.go`
- `internal/adapters/seriatim/subprocess_test.go` - `internal/adapters/seriatim/subprocess_test.go`
## Architectural Invariants ## Architectural Invariants
- Normalized output must validate as processed-transcript-compatible JSON (`segments` array required). - Final output must validate as transcript-compatible JSON (`segments` array required).
- Default normalize config is applied when `pipeline.normalize` is unset. - Default normalize config is applied when `pipeline.normalize` is unset.

View File

@@ -1,23 +1,23 @@
# Stage: polish # Stage: polish
## Purpose ## Purpose
Polish merged transcript with Audita and produce a processed transcript for downstream normalization/analyze. Polish the base transcript with Audita and produce a polished transcript for downstream normalization/analyze.
## Inputs and Outputs ## Inputs and Outputs
Inputs: Inputs:
- `transcripts/merged.json` - `transcripts/base.json`
- `inputs/glossary.yml` - `inputs/glossary.yml`
Outputs: Outputs:
- `transcripts/processed.json` - `transcripts/polished.json`
- optional `artifacts/audita.report.json` (when report enabled) - optional `artifacts/audita.report.json` (when report enabled)
## Boundaries ## Boundaries
Owns: Owns:
- Merged transcript discovery/validation - Base transcript discovery/validation
- Audita invocation request construction - Audita invocation request construction
- Run-local logs/config/work-dir/report wiring - Run-local logs/config/work-dir/report wiring
- Promotion of processed transcript and optional report - Promotion of polished transcript and optional report
Does not own: Does not own:
- Upstream merge normalization - Upstream merge normalization
@@ -47,9 +47,9 @@ Does not own:
- Audita adapter (`env.Audita.Run`). - Audita adapter (`env.Audita.Run`).
## State and Manifest Behavior ## State and Manifest Behavior
- Reads merged transcript from merge manifest outputs when available; falls back to canonical merged path. - Reads base transcript from merge manifest outputs when available; falls back to canonical base path.
- Uses run-local output/report/log/config/scratch paths when run layout is enabled. - Uses run-local output/report/log/config/scratch paths when run layout is enabled.
- Promotes canonical `transcripts/processed.json` and optional report. - Promotes canonical `transcripts/polished.json` and optional report.
- Records adapter invocation metadata, credential presence signal, and output provenance in stage metadata. - Records adapter invocation metadata, credential presence signal, and output provenance in stage metadata.
## Skip and Resume Behavior ## Skip and Resume Behavior
@@ -57,13 +57,13 @@ Does not own:
- Forced rerun can stale downstream succeeded stages via runner invalidation. - Forced rerun can stale downstream succeeded stages via runner invalidation.
## Failure Behavior ## Failure Behavior
- Fails on missing/invalid merged transcript, missing glossary, adapter error, invalid processed output shape (`segments` array required), or invalid report JSON when enabled. - Fails on missing/invalid base transcript, missing glossary, adapter error, invalid polished output shape (`segments` array required), or invalid report JSON when enabled.
## Tests to Inspect Before Changing ## Tests to Inspect Before Changing
- `internal/stage/polish_test.go` - `internal/stage/polish_test.go`
- `internal/adapters/audita/subprocess_test.go` - `internal/adapters/audita/subprocess_test.go`
## Architectural Invariants ## Architectural Invariants
- Processed transcript must contain a top-level `segments` array. - Polished transcript must contain a top-level `segments` array.
- Report behavior is strictly config-gated. - Report behavior is strictly config-gated.
- Stage output canonicalization always ends at `transcripts/processed.json`. - Stage output canonicalization always ends at `transcripts/polished.json`.

View File

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

View File

@@ -1,21 +1,21 @@
# Stage: trim # Stage: trim
## Purpose ## Purpose
Optionally trim the normalized transcript to session bounds; always produce a durable trimmed transcript. Optionally trim the final transcript to session bounds; always produce a durable final-trimmed transcript.
## Inputs and Outputs ## Inputs and Outputs
Inputs: Inputs:
- `transcripts/normalized.json` - `transcripts/final.json`
Outputs: Outputs:
- `transcripts/trimmed.json` (or configured trim output path) - `transcripts/final.trimmed.json` (or configured trim output path)
- when trim enabled: `artifacts/session_bounds.json` - when trim enabled: `artifacts/session_bounds.json`
## Boundaries ## Boundaries
Owns: Owns:
- Trim-enabled switch behavior - Trim-enabled switch behavior
- Bounds generation via Scriptorium artifact run - Bounds generation via Scriptorium artifact run
- Bounds validation against normalized transcript - Bounds validation against final transcript
- Keep-selector derivation and Seriatim trim invocation - Keep-selector derivation and Seriatim trim invocation
- Copy-through behavior when disabled or bounds indicate unchanged transcript - Copy-through behavior when disabled or bounds indicate unchanged transcript
@@ -50,19 +50,19 @@ Does not own:
- `Trim` when bounds indicate trimming is required - `Trim` when bounds indicate trimming is required
## State and Manifest Behavior ## State and Manifest Behavior
- Reads normalized transcript from normalize manifest outputs when available; falls back to canonical path. - Reads final transcript from normalize manifest outputs when available; falls back to canonical path.
- Uses run-local outputs/logs/reports/config/scratch paths when run layout is enabled. - Uses run-local outputs/logs/reports/config/scratch paths when run layout is enabled.
- Promotes canonical trimmed transcript; promotes session bounds when trim enabled. - Promotes canonical final-trimmed transcript; promotes session bounds when trim enabled.
- Records bounds diagnostics, trim action, keep selector, and adapter metadata. - Records bounds diagnostics, trim action, keep selector, and adapter metadata.
## Skip and Resume Behavior ## Skip and Resume Behavior
- Runner-level skip applies when already succeeded and not forced. - Runner-level skip applies when already succeeded and not forced.
- Forced reruns can stale downstream succeeded stages. - Forced reruns can stale downstream succeeded stages.
- When `trim.enabled=false`, stage still succeeds by copying normalized to trimmed output. - When `trim.enabled=false`, stage still succeeds by copying final to final-trimmed output.
## Failure Behavior ## Failure Behavior
- Fails on missing/invalid normalized transcript. - Fails on missing/invalid final transcript.
- With trim enabled, fails on missing adapters/config, bounds generation/validation errors, invalid bounds JSON, invalid range/segment ids, trim adapter failures, or invalid trimmed output. - With trim enabled, fails on missing adapters/config, bounds generation/validation errors, invalid bounds JSON, invalid range/segment ids, trim adapter failures, or invalid final-trimmed output.
## Tests to Inspect Before Changing ## Tests to Inspect Before Changing
- `internal/stage/trim_test.go` - `internal/stage/trim_test.go`
@@ -70,6 +70,6 @@ Does not own:
- `internal/adapters/seriatim/subprocess_test.go` - `internal/adapters/seriatim/subprocess_test.go`
## Architectural Invariants ## Architectural Invariants
- Trim never falls back to processed transcript; normalized transcript is required input. - Trim never falls back to polished transcript; final transcript is required input.
- `session_bounds` output exists only for enabled trim path. - `session_bounds` output exists only for enabled trim path.
- Render-debug artifacts are diagnostics and not declared stage outputs. - Render-debug artifacts are diagnostics and not declared stage outputs.

View File

@@ -18,7 +18,7 @@ Outputs:
## Boundaries ## Boundaries
Owns: Owns:
- Session-level path layout (`inputs/`, `audio/`, `transcripts/`, `artifacts/`, `reports/`, `logs/`, `config/`, `current/`, `runs/`, `previous/`) - Session-level path layout (`inputs/`, `audio/`, `transcripts/`, `artifacts/`, `reports/`, `logs/`, `config/`, `current/`, `runs/`, `previous/`)
- `previous/manifest.json` and `previous/artifacts/**` are reserved for prepared previous-session state - `previous/manifest.json` and `previous/artifacts/**` are reserved for previous-session cache state materialized by `prepare` or `restore`
- Run-local stage sandbox layout under `runs/{run_id}/{stage}/` - Run-local stage sandbox layout under `runs/{run_id}/{stage}/`
- Session lock acquisition/release (`.lock`) - Session lock acquisition/release (`.lock`)
@@ -46,8 +46,9 @@ None directly in this subsystem. Stages may use object storage adapters and then
- During each run, stage outputs are often written run-local first (`runs/{run_id}/{stage}/outputs/...`) and promoted to canonical session paths after stage success. - During each run, stage outputs are often written run-local first (`runs/{run_id}/{stage}/outputs/...`) and promoted to canonical session paths after stage success.
- `manifest.Artifacts` entries record `ProducerRunID` for durable outputs. - `manifest.Artifacts` entries record `ProducerRunID` for durable outputs.
- For S3 audio sessions, `prepare` records work/cache paths, S3 provenance, and spool path when the invocation downloaded the object. - For S3 audio sessions, `prepare` records work/cache paths, S3 provenance, and spool path when the invocation downloaded the object.
- `previous/**` is reconstructed from configured previous-session requirements; restore uses the previous session's committed current archive rather than treating current-session archived `previous/**` as authoritative.
- Durable cache state under `pipeline.cache.root` is not workspace state and is preserved by default by `narratio clean`. - Durable cache state under `pipeline.cache.root` is not workspace state and is preserved by default by `narratio clean`.
- `narratio clean --session-id <id>` removes the session work root and session spool root. - `narratio clean <id>` removes the session work root and session spool root.
- `narratio clean --all` removes all local session work under `workspace.root/work` and spool children under `spool.root`. - `narratio clean --all` removes all local session work under `workspace.root/work` and spool children under `spool.root`.
- `narratio clean --clear-cache` is the explicit opt-in for deleting matching S3 audio cache entries. - `narratio clean --clear-cache` is the explicit opt-in for deleting matching S3 audio cache entries.

View File

@@ -11,30 +11,30 @@ For field-level configuration, see [docs/config.md](./config.md). For full comma
3. Run Narratio: 3. Run Narratio:
```bash ```bash
narratio run --session-id 2026-04-04 narratio run 2026-04-04
``` ```
4. Read success output: 4. Read success output:
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>` - `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
- use `manifest=<path>` with `status` for inspection. - use `narratio session status <session_id>` for inspection.
Notes: Notes:
- default config/campaign/session discovery checks system config locations unless `--config`, `--campaign`, and `--session` are passed. - default pipeline/session discovery checks system config locations; campaign selection uses `pipeline.campaigns.default_campaign_id` unless `--campaign <id>` or `--campaign-file <path>` is passed.
- when local `session.yml` discovery misses, `--session-id` loads remote `session.yml` from `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. - when local `session.yml` discovery misses, positional `<session_id>` loads remote `session.yml` from `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`.
- S3 audio mode requires `session.inputs.audio_s3.prefix` and valid object-store access. - S3 audio mode requires `session.inputs.audio_s3.prefix` and valid object-store access.
Initialize a remote session skeleton: Initialize a remote session skeleton:
```bash ```bash
narratio session init --config /etc/narratio/pipeline.yml --campaign /etc/narratio/campaign.yml --session-id 2026-04-04 --remote narratio session init 2026-04-04 --remote
``` ```
Remote init writes `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. It fails if the object already exists unless `--force` is passed. Remote init uses normal default config discovery and writes `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. If `campaign.yml` sets `session_template_file`, init renders that template from the supplied flags and writes concrete YAML. Pass `--config`, `--campaign <id>`, or `--campaign-file <path>` when testing non-system config files. It fails if the object already exists unless `--force` is passed.
Validate before running: Validate before running:
```bash ```bash
narratio session validate --session-id 2026-04-04 narratio session validate 2026-04-04
``` ```
## Restore workflow ## Restore workflow
@@ -44,36 +44,38 @@ Use restore when local durable session state is missing or stale and archive cur
Dry-run (no local writes): Dry-run (no local writes):
```bash ```bash
narratio restore --session-id 2026-04-04 --dry-run narratio session restore 2026-04-04 --dry-run
``` ```
Execution: Execution:
```bash ```bash
narratio restore --session-id 2026-04-04 narratio session restore 2026-04-04
``` ```
Post-restore analyze rerun pattern: Post-restore analyze rerun pattern:
```bash ```bash
narratio analyze --session-id 2026-04-04 narratio analyze 2026-04-04
``` ```
Restore source-of-truth: Restore source-of-truth:
- remote commit marker: `current/run_id.txt` - remote commit marker: `current/run_id.txt`
- remote current manifest: `current/manifest.json` - remote current manifest: `current/manifest.json`
- configured previous-session requirements are reconstructed from the previous session's remote `current/` state, not from archived `previous/**` objects in the current session.
Restore default scope: Restore default scope:
- includes `manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**` - includes `manifest.json`, `transcripts/**`, and `artifacts/**` from the current session archive.
- includes `previous/**` only when configured previous-session artifact inputs require it; restore hydrates those files the same way `prepare` would.
- includes `audio/**` only with `--include-audio` - includes `audio/**` only with `--include-audio`
- excludes `runs/**`, `logs/**`, `reports/**`, `config/**`, `inputs/**`, and `current/**` (except remote `current/manifest.json` as source) - excludes `runs/**`, `logs/**`, `reports/**`, `config/**`, `inputs/**`, and `current/**` (except remote `current/manifest.json` as source)
Reset local state before restore testing: Reset local state before restore testing:
```bash ```bash
narratio clean --session-id 2026-04-04 --dry-run narratio clean 2026-04-04 --dry-run
narratio clean --session-id 2026-04-04 narratio clean 2026-04-04
narratio restore --session-id 2026-04-04 --include-audio narratio session restore 2026-04-04 --include-audio
``` ```
`clean` removes the local session work directory and session spool directory. It preserves the durable S3 audio cache by default, so repeated restore or forced prepare tests do not re-download large audio files. `clean` removes the local session work directory and session spool directory. It preserves the durable S3 audio cache by default, so repeated restore or forced prepare tests do not re-download large audio files.
@@ -124,18 +126,29 @@ Configured artifact source reuse:
- reused configured artifact provenance is `filesystem.disabled_artifact_output`. - reused configured artifact provenance is `filesystem.disabled_artifact_output`.
`--artifacts` behavior: `--artifacts` behavior:
- accepted on `run`, `resume`, `run-stage analyze`, and `analyze`. - accepted on `run`, `resume`, `run-stage analyze`, `run-stage archive`, `analyze`, and `publish`.
- filters analyze execution only. - filters analyze execution and configured artifact promotions.
- built-in transcript and bounds promotions are not filtered.
- does not imply force on `run`, `resume`, or `run-stage`; `narratio analyze` is force-by-design. - does not imply force on `run`, `resume`, or `run-stage`; `narratio analyze` is force-by-design.
- `publish` is force-by-design and accepts `--artifacts` for configured artifact promotions.
Canonical previous-session input behavior: Canonical previous-session input behavior:
- canonical sources use `narratio.previous_session.artifact.<artifact_key>`. - canonical sources use `narratio.previous_session.artifact.<artifact_key>`.
- these inputs are hydrated by `prepare`, not `analyze`. - these inputs are hydrated by `prepare` and by `restore`; `analyze` expects the local previous cache to already exist.
- if analyze fails due to missing canonical previous cache, rerun: - if analyze fails due to missing canonical previous cache, rerun:
- `narratio run-stage --session-id <id> --force prepare` - `narratio run-stage prepare <id> --force`
- or `narratio session restore <id>` when remote archive current state is authoritative.
## Remote archive layout and publish contract ## Remote archive layout and publish contract
Preferred manual publish command:
```bash
narratio publish <id>
```
`publish` is equivalent to `narratio run-stage archive <id> --force`; use `run-stage` when you need the general single-stage command form.
When archive is enabled and run upload is enabled, archive publishes under: When archive is enabled and run upload is enabled, archive publishes under:
- session prefix: `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/` - session prefix: `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/`
@@ -163,10 +176,10 @@ Archive promotion is explicit and source-based:
- locked required promotions are treated as intentional successful skips and are recorded in archive metadata. - locked required promotions are treated as intentional successful skips and are recorded in archive metadata.
Lock helper behavior: Lock helper behavior:
- `narratio locks --session-id <id>` lists effective static and remote locks. - `narratio session locks <id>` lists effective static and remote locks.
- `narratio locks add --session-id <id> --reason <text> <source>` writes a remote lock. - `narratio session locks add <id> <source> --reason <text>` writes a remote lock.
- `narratio locks add --session-id <id> --force --reason <text> <source>` updates an existing remote lock reason. - `narratio session locks add <id> <source> --force --reason <text>` updates an existing remote lock reason.
- `narratio locks remove --session-id <id> <source>` removes only a remote lock. - `narratio session locks remove <id> <source>` removes only a remote lock.
- `locks remove` cannot remove static pipeline locks. - `locks remove` cannot remove static pipeline locks.
- remote lock writes check whether the lock store exists, but are not compare-and-swap atomic. - remote lock writes check whether the lock store exists, but are not compare-and-swap atomic.
@@ -201,7 +214,7 @@ Automatic cleanup toggles:
- `pipeline.workspace.cleanup_after_archive=true` deletes run-scoped local run directory. - `pipeline.workspace.cleanup_after_archive=true` deletes run-scoped local run directory.
Manual cleanup: Manual cleanup:
- `narratio clean --session-id <id>` deletes `{workspace.root}/work/{campaign}/{session_id}` and `{spool.root}/{campaign}/{session_id}`. - `narratio clean <id>` deletes `{workspace.root}/work/{campaign}/{session_id}` and `{spool.root}/{campaign}/{session_id}`.
- `narratio clean --all` deletes all local session work under `{workspace.root}/work` and all spool children under `{spool.root}`. - `narratio clean --all` deletes all local session work under `{workspace.root}/work` and all spool children under `{spool.root}`.
- `--dry-run` prints targets without deleting. - `--dry-run` prints targets without deleting.
- `--clear-cache` also removes matching S3 audio cache files. Without it, cache is preserved. - `--clear-cache` also removes matching S3 audio cache files. Without it, cache is preserved.
@@ -235,25 +248,19 @@ Recommended recovery:
1. inspect state: 1. inspect state:
```bash ```bash
narratio status --session-id 2026-04-04 narratio session status 2026-04-04
``` ```
This reports local manifest state, committed remote current state, expected remote transcript/artifact availability, and archive locks. This reports local manifest state, committed remote current state, expected remote transcript/artifact availability, and archive locks.
2. for one manifest file, run: 2. for restore-specific checks, run:
```bash ```bash
narratio status --manifest <manifest-path> narratio session restore 2026-04-04 --dry-run
``` ```
3. for restore-specific checks, run: 3. fix root cause (config/input/credentials/storage/service availability).
4. continue with `resume`, or targeted `run-stage <stage> <id> --force` followed by `resume`.
```bash
narratio restore --session-id 2026-04-04 --dry-run
```
4. fix root cause (config/input/credentials/storage/service availability).
5. continue with `resume`, or targeted `run-stage --force` followed by `resume`.
## Restore report ## Restore report
@@ -270,10 +277,9 @@ Dry-run does not write restore report files.
## Operational caveats ## Operational caveats
- `status` with no config/session flags still requires explicit `--manifest`. - `session status <session_id>` uses normal config/session loading, including remote session fallback.
- `status --session-id <id>` uses normal config/session loading, including remote session fallback. - `session status <session_id>` includes the same promoted remote output availability view as `session artifacts <session_id> --remote` when storage is configured.
- `status --session-id <id>` includes the same promoted remote output availability view as `artifacts list --remote` when storage is configured.
- local and S3 audio input modes are mutually exclusive. - local and S3 audio input modes are mutually exclusive.
- archive publish requires upstream stages through `analyze` to be `succeeded`. - archive publish requires upstream stages through `analyze` to be `succeeded`.
- required promotion rules can fail when selected analyze artifacts did not generate a required file path. - required configured artifact promotions for unselected `--artifacts` keys are skipped intentionally; selected required promotions still fail if their files are missing.
- restore requires configured remote object storage and committed remote current state. - restore requires configured remote object storage and committed remote current state.

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

@@ -0,0 +1,231 @@
# Roadmap: Campaign Registry
Status: Implemented
## Problem
Narratio currently treats campaign configuration as one selected
`campaign.yml` file:
- command flags use `--campaign <path>`;
- default discovery searches fixed system file locations;
- `campaign.yml` uses `campaign:` as the identity field.
That model works for a single campaign, but it is awkward for installations
that manage multiple campaigns. Operators need to pass file paths or maintain a
single global campaign config, while the newer session-oriented CLI already
uses concise positional session IDs and remote session lookup.
The campaign selection model should become ID-based and pipeline-owned.
Pipeline config should describe where campaigns live, commands should select a
campaign by ID, and each campaign directory should contain its stable campaign
materials.
## Target Model
`pipeline.yml` owns the campaign registry:
campaigns:
root: /usr/local/share/narratio/campaigns
default_campaign_id: dilfs
Campaign files live at the conventional path:
{campaigns.root}/{campaign_id}/campaign.yml
The first implementation should use only the conventional path. Recursive
discovery of every `campaign.yml` under `campaigns.root` is deferred to a
future stage.
Each campaign file uses `campaign_id` as the canonical identity field:
campaign_id: dilfs
session_template_file: ./session.template.yml
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
Campaign-relative files continue to resolve relative to the selected
`campaign.yml`, including stable input files and `session_template_file`.
The public CLI changes from path-based campaign selection to ID-based campaign
selection:
- `--campaign <id>` selects a campaign ID.
- `--campaign-file <path>` explicitly loads one campaign file for
development, tests, and unusual local workflows.
- `--campaign` and `--campaign-file` are mutually exclusive.
If neither `--campaign` nor `--campaign-file` is passed, Narratio uses
`pipeline.campaigns.default_campaign_id`. If no campaign can be selected,
commands fail clearly before session loading or stage execution.
Resolved campaign ID remains the campaign segment used for:
- workspace paths;
- spool paths;
- S3 session prefixes;
- remote `session.yml` lookup;
- archive locks and promoted output keys;
- session/campaign mismatch validation;
- status, plan, restore, and helper output.
## Compatibility Policy
This is a breaking public/config contract change.
After the cutover:
- `--campaign` no longer accepts a filesystem path;
- default fixed campaign file discovery is removed;
- `campaign:` is no longer accepted in `campaign.yml`;
- `campaign_id:` is required.
Keep `--campaign-file` as the only explicit file override. Do not retain hidden
aliases for the old `--campaign <path>` behavior.
## Implementation Stages
### Stage 1: Add Campaign Registry Selection
Status: Implemented
Add the registry model and switch command loading to resolve campaigns through
pipeline config.
Implementation requirements:
- Add `pipeline.campaigns.root`.
- Add `pipeline.campaigns.default_campaign_id`.
- Add `campaign_id` to campaign config and make it the canonical identity.
- Resolve pipeline config first, then campaign selection.
- Use this selection order:
1. explicit `--campaign-file <path>`;
2. explicit `--campaign <id>`;
3. `pipeline.campaigns.default_campaign_id`;
4. fail clearly.
- For ID selection, load `{campaigns.root}/{campaign_id}/campaign.yml`.
- Validate that the loaded `campaign_id` matches the selected ID.
- Reject `--campaign` with `--campaign-file`.
- Preserve strict YAML decoding.
- Preserve campaign-relative stable input and session template resolution.
- Keep storage details behind the existing storage adapter and object-store
helper.
- Keep remote session lookup and archive key construction based on the
resolved campaign ID.
Acceptance criteria:
- Commands can run with only a pipeline config and the pipeline default
campaign ID.
- Commands can select another campaign with `--campaign <id>`.
- Commands can load a specific file with `--campaign-file <path>`.
- Existing session loading, remote session fallback, prepare materialization,
restore, archive, locks, clean, analyze, and publish behavior continue to use
the same resolved campaign identity.
- No generic config registry framework is introduced.
### Stage 2: Remove Old Single-File Campaign Behavior
Status: Implemented
Remove the old public campaign file model after registry selection is in
place.
Implementation requirements:
- Remove fixed default campaign config discovery from command loading.
- Remove `DefaultCampaignConfigSearchPaths` and related path-only resolution if
no current tests or helpers still need them.
- Remove support for `campaign:` from `campaign.yml`.
- Update validation errors to refer to `campaign_id`.
- Update examples to use campaign directories and `campaign_id`.
- Update current-behavior docs to document:
- `pipeline.campaigns.root`;
- `pipeline.campaigns.default_campaign_id`;
- `campaign_id`;
- `--campaign <id>`;
- `--campaign-file <path>`.
- Update troubleshooting examples that currently pass `--campaign <path>`.
Acceptance criteria:
- `campaign.yml` files with `campaign:` fail strict decoding.
- `--campaign /path/to/campaign.yml` is treated as a campaign ID and fails
unless that ID exists under `campaigns.root`.
- `--campaign-file /path/to/campaign.yml` is the supported file override.
- User-facing docs no longer describe fixed campaign config discovery.
## Test Guidance
Focused tests:
- `go test ./internal/config -v`
- `go test ./internal/app -v`
- `go test ./internal/stage -run Prepare -v`
Full validation:
- `go test ./...`
Config tests to add or update:
- strict decode accepts `pipeline.campaigns.root`;
- strict decode accepts `pipeline.campaigns.default_campaign_id`;
- strict decode accepts `campaign_id`;
- selected campaign ID mismatch fails;
- missing campaign root fails when ID selection is needed;
- missing default campaign ID fails when no explicit campaign selector is
passed;
- old `campaign:` fails after Stage 2.
App tests to add or update:
- `--campaign <id>` resolves `{campaigns.root}/{id}/campaign.yml`;
- omitted `--campaign` uses `pipeline.campaigns.default_campaign_id`;
- `--campaign-file` loads an explicit campaign file;
- `--campaign` plus `--campaign-file` fails;
- remote session fallback uses the resolved campaign ID;
- `session init`, `run`, `run-stage`, `resume`, `analyze`, `publish`, `clean`,
and `session` subcommands all use the same campaign selection path;
- path-based `--campaign` examples and tests are removed after Stage 2.
## Documentation Guidance
Update current-behavior docs only after implementation lands:
- `docs/config.md`
- `docs/cli.md`
- `docs/operations.md`
- `docs/troubleshooting.md`
- relevant files under `docs/internal/`
- `examples/`
Planned campaign registry behavior belongs only in this roadmap until the code,
tests, examples, and current-behavior docs are updated.
## Architecture Guardrails
- Keep Narratio explicit and stage-driven.
- Do not introduce a generic configuration registry or workflow framework.
- Keep YAML decoding strict.
- Keep defaults centralized and testable.
- Keep campaign-relative path resolution centralized.
- Use centralized S3 and workspace path helpers.
- Keep storage details behind `storage.ObjectStore`.
- Keep secret-backed object-store construction in `internal/app`.
- Preserve manifest-driven resume and restore behavior.
- Do not store raw secrets in campaign configs, manifests, logs, generated
configs, or archive metadata.
## Assumptions
- The canonical pipeline schema is grouped under `campaigns`.
- The canonical campaign identity field is `campaign_id`.
- `--campaign` means campaign ID.
- `--campaign-file` is retained as an explicit override.
- Recursive discovery is planned but not part of the first implementation.
- Existing production configs can be migrated from `campaign:` to
`campaign_id:` and from `--campaign <path>` to `--campaign <id>` or
`--campaign-file <path>`.

159
docs/roadmap/cleanup.md Normal file
View File

@@ -0,0 +1,159 @@
# Roadmap: Legacy Config Cleanup
Status: Implemented
## Problem
Narratio's current pipeline config schema still accepts fields that predate the current storage, artifact, and previous-session models:
- `pipeline.storage.bucket`
- `pipeline.storage.prefix`
- `pipeline.analyzer.*`
- `previous_session_artifact`
These names make the config reference harder to trust because they suggest supported behavior that operators should no longer use. The modern interface is:
- `pipeline.storage.s3.*` for remote storage.
- Scriptorium configured artifacts under `pipeline.scriptorium.artifacts`.
- Canonical artifact source IDs such as `narratio.artifact.<configured_artifact_key>`.
- Canonical previous-session artifact sources such as `narratio.previous_session.artifact.<configured_artifact_key>`.
Strict YAML decoding should reject removed legacy fields once this cleanup lands.
## Current State
`pipeline.storage.bucket` and `pipeline.storage.prefix` were inert compatibility fields and have been removed:
- They are no longer present on `config.StorageConfig`.
- Strict decoding rejects them.
- Runtime S3 behavior uses `pipeline.storage.s3.bucket` and `pipeline.storage.s3.root_prefix`.
- No current code reads the top-level storage bucket or prefix fields.
`pipeline.analyzer.*` was legacy code surface and has been removed:
- `config.PipelineConfig` no longer includes analyzer config.
- Strict decoding rejects `pipeline.analyzer`.
- `stage.Env` no longer exposes an analyzer runner, and `internal/adapters/analyzer` has been deleted.
- Modern analyze execution is Scriptorium-backed; the analyzer adapter is not used by current stage execution.
`previous_session_artifact` was a live legacy behavior and has been removed:
- Config validation rejects it as an unsupported Scriptorium input source.
- The analyze stage no longer has path-based previous-artifact resolution through `inputs.<name>.path`.
- Tests cover canonical previous-session sources and the rejection of the legacy source.
- The canonical replacement is `narratio.previous_session.artifact.<configured_artifact_key>`, resolved through the previous-session cache/catalog model.
## Target Model
The pipeline config schema should expose only current behavior:
- Remote storage is configured only through `pipeline.storage.s3.*`.
- Generated artifacts are configured only through `pipeline.scriptorium.artifacts`.
- Scriptorium artifact inputs use canonical source IDs.
- Previous-session artifact inputs use `narratio.previous_session.artifact.<configured_artifact_key>`.
- Unknown legacy fields fail strict YAML decoding.
No compatibility aliases should remain unless a future migration requirement explicitly reintroduces them.
## Cleanup Order
### Stage 1: Remove Inert Storage Compatibility Fields
Status: Implemented
Remove `pipeline.storage.bucket` and `pipeline.storage.prefix`.
Implementation requirements:
- Delete `StorageConfig.Bucket` and `StorageConfig.Prefix`.
- Keep `StorageConfig.Backend` and `StorageConfig.S3`.
- Confirm all runtime storage paths continue to use `storage.s3.bucket` and `storage.s3.root_prefix`.
- Update examples and docs to remove top-level storage `bucket` and `prefix`.
- Add or update strict-decode tests proving `pipeline.storage.bucket` and `pipeline.storage.prefix` are rejected.
Acceptance criteria:
- Existing S3 workflows still pass with `pipeline.storage.s3.bucket`.
- Pipeline configs containing top-level `storage.bucket` or `storage.prefix` fail to load.
- No docs or examples present those fields as available.
### Stage 2: Remove Legacy Analyzer Schema and Adapter Surface
Status: Implemented
Remove the unused analyzer configuration and adapter contract.
Implementation requirements:
- Delete `PipelineConfig.Analyzer`.
- Delete `AnalyzerConfig` and `ArtifactSettings`.
- Remove analyzer timeout validation.
- Remove `stage.Env.Analyzer`.
- Delete `internal/adapters/analyzer` if no remaining code imports it.
- Remove `pipeline.analyzer.*` from tests, examples, and docs.
- Add or update strict-decode tests proving `pipeline.analyzer` is rejected.
Acceptance criteria:
- Analyze behavior remains fully Scriptorium-backed.
- No runtime code imports `internal/adapters/analyzer`.
- Pipeline configs containing `pipeline.analyzer` fail to load.
- Contributor and internal adapter docs no longer list the analyzer adapter.
### Stage 3: Remove Path-Based Previous Session Artifact Source
Status: Implemented
Remove `previous_session_artifact` and require canonical previous-session artifact sources.
Implementation requirements:
- Remove `previous_session_artifact` from supported Scriptorium input sources.
- Remove analyze-stage special-case handling that resolves `inputs.<name>.path` for previous artifacts.
- Keep canonical handling for `narratio.previous_session.artifact.<configured_artifact_key>`.
- Rewrite tests that use `previous_session_artifact` to use canonical sources and prepared previous-cache fixtures.
- Add validation tests proving `previous_session_artifact` is rejected.
- Update docs to remove the legacy path-based source and document only canonical previous-session sources.
Acceptance criteria:
- `pipeline.scriptorium.artifacts.*.inputs.*.source: previous_session_artifact` fails validation.
- Canonical previous-session sources continue to work for required and optional inputs.
- Prepare/restore previous-cache behavior remains unchanged.
- No docs or examples mention `previous_session_artifact` as supported.
## Test Guidance
Run focused tests after each stage:
- `go test ./internal/config -v`
- `go test ./internal/stage -run Analyze -v`
- `go test ./internal/app -v`
- `go test ./...`
For Stage 1, focus on config load/strict-decode and S3 workflow regression tests.
For Stage 2, focus on compile-time removal, config strict-decode tests, and full app/stage tests to catch stale adapter references.
For Stage 3, focus on Scriptorium config validation, analyze-stage input resolution, previous-cache behavior, and restore/analyze workflows.
## Documentation Updates
Update current-behavior docs only after the corresponding code removal lands:
- `docs/config.md`
- `docs/cli.md`, only if command behavior text references removed fields.
- `docs/operations.md`, only if operator workflow text references removed fields.
- `docs/internal/stage-analyze.md`
- `docs/internal/adapters.md`
- `examples/pipeline.full.annotated.yml`
- `examples/pipeline.production.yml`
Do not preserve removed fields in examples as compatibility notes. The goal is to make strict config behavior and documentation line up.
## Assumptions
- This is a hard cleanup; no backward-compatible aliases are retained.
- Current production configs can be migrated to `storage.s3.*`, Scriptorium artifacts, and canonical previous-session sources before this lands.
- Removing the unused analyzer adapter does not block any active stage behavior.
- The cleanup should be implemented in the listed order so inert schema removal is separated from behavior removal.

255
docs/roadmap/cli.md Normal file
View File

@@ -0,0 +1,255 @@
# Roadmap: Session-Oriented CLI Cleanup
Status: Implemented
## Problem
Narratio's public CLI has accumulated too many top-level commands. Several
commands are session-scoped operator helpers, but they currently appear as
independent top-level verbs:
- `plan`
- `status`
- `restore`
- `artifacts list`
- `locks`
- `session validate`
- `session init`
This makes the command surface harder to learn because the CLI does not clearly
separate primary workflow actions from session inspection, initialization,
restore, and helper operations.
## Target Model
Keep primary workflow commands at top level:
- `run`
- `run-stage`
- `resume`
- `analyze`
- `publish`
- `clean`
- `session`
Keep `clean` top-level because it can operate on one session or all local
sessions and is a workspace maintenance command, not only a session helper.
Move session-scoped helper commands under `narratio session` and use positional
session identifiers:
- `narratio session init <session_id> [--remote|--output <path>] [--flags]`
- `narratio session validate <session_id> [--flags]`
- `narratio session status <session_id> [--flags]`
- `narratio session plan <session_id> [--flags]`
- `narratio session restore <session_id> [--flags]`
- `narratio session artifacts <session_id> [--remote] [--flags]`
- `narratio session locks <session_id> [--flags]`
- `narratio session locks add <session_id> <source> [--reason <text>] [--force] [--flags]`
- `narratio session locks remove <session_id> <source> [--flags]`
Update top-level workflow commands to use positional session identifiers:
- `narratio run <session_id> [--flags]`
- `narratio resume <session_id> [--flags]`
- `narratio analyze <session_id> [--flags]`
- `narratio publish <session_id> [--flags]`
- `narratio run-stage <stage> <session_id> [--flags]`
The positional session ID replaces `--session-id` as the primary public
interface. Existing `--config`, `--campaign`, `--session`, and
`--previous-session-id` flags remain available where they are meaningful.
## Command Mapping
| Current command | Target command |
| --- | --- |
| `narratio run --session-id <id>` | `narratio run <id>` |
| `narratio resume --session-id <id>` | `narratio resume <id>` |
| `narratio analyze --session-id <id>` | `narratio analyze <id>` |
| `narratio publish --session-id <id>` | `narratio publish <id>` |
| `narratio run-stage [flags] <stage> --session-id <id>` | `narratio run-stage <stage> <id> [flags]` |
| `narratio plan --session-id <id>` | `narratio session plan <id>` |
| `narratio status --session-id <id>` | `narratio session status <id>` |
| `narratio restore --session-id <id>` | `narratio session restore <id>` |
| `narratio artifacts list --session-id <id>` | `narratio session artifacts <id>` |
| `narratio locks --session-id <id>` | `narratio session locks <id>` |
| `narratio locks add --session-id <id> <source>` | `narratio session locks add <id> <source>` |
| `narratio locks remove --session-id <id> <source>` | `narratio session locks remove <id> <source>` |
| `narratio session validate --session-id <id>` | `narratio session validate <id>` |
| `narratio session init --session-id <id>` | `narratio session init <id>` |
| `narratio clean --session-id <id>` | `narratio clean <id>` |
| `narratio clean --all` | unchanged |
`clean` remains top-level, but its session-scoped form should also move from
`--session-id` to positional `<session_id>` for consistency.
## Compatibility Policy
This is a hard public CLI cleanup after the migration step lands.
During Step 1, old forms may remain as compatibility aliases to keep the
implementation reviewable. During Step 2, remove the old forms from command
dispatch, tests, docs, and examples:
- remove top-level `plan`;
- remove top-level `status`;
- remove top-level `restore`;
- remove top-level `artifacts`;
- remove top-level `locks`;
- remove `--session-id` from the public command syntax for session-aware
commands.
Do not keep long-term deprecated aliases unless a later roadmap explicitly
chooses a compatibility window.
`status --manifest` does not fit the session-oriented command shape. Remove it
from the public CLI in this cleanup. If direct manifest inspection is needed
later, add a separate diagnostic command in a future roadmap rather than keeping
it as a special case in `session status`.
## Implementation Step 1: Add New Session-Oriented Interface
Status: Implemented
Add the target command forms while preserving current behavior internally.
Implementation requirements:
- Add positional session ID parsing helpers in `internal/app`.
- Keep the existing `loadCommandConfig` behavior and populate
`config.SessionLoadOptions.SessionID` from the positional ID.
- Add or update command wrappers:
- `Run(ctx, args, out)` parses `run <session_id>`.
- `Resume(ctx, args, out)` parses `resume <session_id>`.
- `Analyze(ctx, args, out)` parses `analyze <session_id>`.
- `Publish(ctx, args, out)` parses `publish <session_id>`.
- `RunStage(ctx, args, out)` parses `run-stage <stage> <session_id>`.
- `Clean(ctx, args, out)` parses `clean <session_id>` and keeps
`clean --all`.
- Extend `Session(ctx, args, out)` dispatch to support:
- `init <session_id>`
- `validate <session_id>`
- `status <session_id>`
- `plan <session_id>`
- `restore <session_id>`
- `artifacts <session_id>`
- `locks <session_id>`
- `locks add <session_id> <source>`
- `locks remove <session_id> <source>`
- Keep storage access through the existing app-level object-store helper.
- Keep AWS SDK details behind storage adapters.
- Keep the runner, stages, manifest behavior, archive behavior, restore
planning, lock semantics, and artifact catalog behavior unchanged.
Acceptance criteria:
- New forms execute the same code paths and produce equivalent results.
- Positional session ID mismatch with concrete local or remote `session.yml`
fails through existing session identity checks.
- Remote session fallback still uses the positional session ID as the lookup
value.
- Current command tests cover the new forms before old forms are removed.
## Implementation Step 2: Remove Old Public Forms
Status: Implemented
Remove compatibility aliases and make the session-oriented interface the only
documented and supported public CLI.
Implementation requirements:
- Remove top-level dispatch for:
- `plan`
- `status`
- `restore`
- `artifacts`
- `locks`
- Remove `--session-id` flags from public session-aware commands.
- Keep `--previous-session-id` as an expected previous-session identity flag.
- Keep explicit `--session <path>` for loading a local concrete session file,
but still require the positional session ID for commands that operate on a
session.
- Remove `status --manifest`.
- Update usage text and invalid-command errors.
- Update `docs/cli.md` and `docs/operations.md` to use only the new forms.
- Update any roadmap docs that mention old helper command names.
- Update tests to expect old top-level helper commands and `--session-id` forms
to fail.
Acceptance criteria:
- Top-level command list is exactly:
- `run`
- `run-stage`
- `resume`
- `analyze`
- `publish`
- `clean`
- `session`
- All session-oriented commands use `narratio session <subcommand>
<session_id> [--flags]`, except nested lock mutation forms, which use
`narratio session locks add|remove <session_id> <source> [--flags]`.
- `clean <session_id>` and `clean --all` remain top-level.
- Current-behavior docs and tests no longer advertise `--session-id`.
## Test Guidance
Focused tests:
- `go test ./internal/app -run TestExecute -v`
- `go test ./internal/app -run 'Session|Status|Restore|Clean|Locks|Artifacts|Plan|RunStage|Analyze|Publish' -v`
- `go test ./internal/config -v`
Full validation:
- `go test ./...`
Test cases to add or update:
- `run <session_id>` loads local and remote sessions through the existing
config path.
- `resume <session_id>`, `analyze <session_id>`, and `publish <session_id>`
preserve current behavior.
- `run-stage <stage> <session_id>` preserves current run-stage output and
force/artifact-selection behavior.
- `session plan <session_id>` replaces top-level `plan`.
- `session status <session_id>` replaces top-level session status.
- `session validate <session_id>` replaces `session validate --session-id`.
- `session init <session_id>` writes the same local or remote concrete
`session.yml`.
- `session restore <session_id>` preserves restore planning/execution.
- `session artifacts <session_id> --remote` preserves promoted-output
availability reporting.
- `session locks <session_id>`, `session locks add <session_id> <source>`, and
`session locks remove <session_id> <source>` preserve static/remote lock
semantics.
- `clean <session_id>` preserves session cleanup behavior, while `clean --all`
remains unchanged.
- Old top-level helper commands fail after Step 2.
- `--session-id` fails after Step 2.
- `status --manifest` fails after Step 2.
## Documentation Guidance
Update only after implementation lands:
- `docs/cli.md`
- `docs/operations.md`
- any internal docs that list command names or examples
Keep planned behavior only in this roadmap until the command refactor is
implemented.
## Architecture Guardrails
- Keep Narratio explicit and stage-driven.
- Do not introduce a generic workflow or command framework abstraction.
- Reuse existing app command helpers where practical.
- Keep config loading strict and centralized.
- Keep storage details behind `storage.ObjectStore`.
- Keep secret-backed object-store construction in `internal/app`.
- Preserve manifest-driven resume and restore behavior.
- Treat command renaming as a public CLI contract change, not a runtime stage
behavior change.

View File

@@ -1,52 +0,0 @@
# Roadmap: Operator Helper Commands
## Status
Implemented.
The operator helper command set is no longer conceptual. Current behavior is documented in:
- `docs/cli.md`
- `docs/operations.md`
- `docs/config.md`
- `docs/internal/artifacts.md`
- `docs/internal/stage-archive.md`
## Implemented Commands
- `narratio session validate`
- `narratio status --manifest <path>`
- `narratio status --session-id <id>`
- `narratio session init --output <path>`
- `narratio session init --remote`
- `narratio artifacts list`
- `narratio artifacts list --remote`
- `narratio locks`
- `narratio locks add <source>`
- `narratio locks remove <source>`
## Implemented Decisions
- Helper output is text-only. No JSON schema exists yet.
- `status` remains a top-level command.
- `session validate`, `session init`, and `artifacts list` are nested helper commands.
- `locks` is the single top-level command for listing, adding, and removing archive promotion locks.
- Remote session initialization requires explicit `--remote`.
- Local session initialization requires `--output`.
- Remote artifact availability is opt-in with `artifacts list --remote`.
- Mutable locks are source-based and stored at `{session_prefix}/locks.yml`.
- The remote lock store uses strict YAML with top-level `locks`.
- Static `pipeline.archive.locks` and remote locks are merged; static locks win on duplicate sources.
- `locks remove` removes only remote locks.
- Ordinary execution `--force` does not override locks.
- Remote lock writes use existence checks and `--force` for updates; there is no compare-and-swap protection.
## Remaining Future Enhancements
These are intentionally not implemented:
- `--json` output for helper commands.
- Optimistic concurrency or ETag compare-and-swap for remote lock mutations.
- Rich remote artifact availability across historical run-local objects.
- Session-lock acquisition for remote mutation helpers.
- Broader campaign helper commands such as `campaign validate` or `campaign publish`.

287
docs/roadmap/publish.md Normal file
View File

@@ -0,0 +1,287 @@
# Roadmap: Publish Contract
Status: Planned
## Problem
Narratio currently uses several terms for one operator-facing concept:
- `archive` is the stage that uploads run state and commits remote current
state.
- `publish` is the convenience command that force-runs the archive stage.
- `promote`, `promoted`, and `promote_artifacts` describe configured top-level
remote output writes.
This mixed vocabulary makes the public contract harder to explain. Operators
should not need to distinguish "archive the run", "publish the run", and
"promote artifacts" when these are all part of the same publish action.
The public model should use:
- `publish` for the stage, command, config section, and action;
- `published` for an expected remote output that exists at its top-level
current destination;
- `publish rules` for the configured source-to-destination output rules;
- `locked` for sources whose top-level published destination must not be
overwritten;
- `run history` for immutable per-run records under `runs/<run_id>/`.
## Target Model
The public stage is `publish`.
The convenience command:
narratio publish <session_id>
is equivalent to:
narratio run-stage publish <session_id> --force
Pipeline configuration uses `publish`:
publish:
enabled: true
upload_run: true
outputs:
- source: narratio.transcript.final_trimmed
- source: narratio.artifact.session_recap
locks:
- source: narratio.artifact.session_recap
reason: Final recap was manually edited.
Publish output rules are source-based. Each rule writes one artifact source to
a top-level remote destination. If `dest` is omitted, Narratio derives the
destination from the artifact registry or configured artifact output path.
The mutable remote lock store remains:
{session_prefix}/locks.yml
Remote availability output uses `published`:
Published:
- narratio.transcript.final_trimmed remote=published
- narratio.artifact.session_recap locked remote=published
The remote key layout is otherwise unchanged:
- immutable run history stays under `{session_prefix}/runs/{run_id}/`;
- current state stays under `{session_prefix}/current/manifest.json`;
- the final commit marker stays `{session_prefix}/current/run_id.txt`;
- `current/run_id.txt` is still written last.
## Compatibility Policy
This is a hard cutover.
After implementation:
- `pipeline.archive` is rejected by strict YAML decoding.
- `pipeline.archive.promote_artifacts` is rejected.
- `pipeline.workspace.cleanup_after_archive` is rejected.
- `pipeline.spool.delete_audio_after_archive` is rejected.
- `narratio run-stage archive <session_id>` is an unknown stage.
- manifests that record an `archive` stage are not migrated.
- old archive/promotion metadata keys are not read as compatibility fallbacks.
Existing remote objects are not moved or renamed. Remote layout remains stable;
the rename changes configuration, stage names, status output, metadata, helper
names, tests, examples, and documentation.
## Implementation Stages
### Stage 1: Public Schema and Stage Cutover
Status: Planned
Switch the public config and stage contract to publish terminology.
Implementation requirements:
- Replace `pipeline.archive` with `pipeline.publish`.
- Replace `archive.promote_artifacts` with `publish.outputs`.
- Keep output rule fields:
- `source`
- `dest`
- `required`
- Replace `pipeline.archive.locks` with `pipeline.publish.locks`.
- Rename post-publish cleanup fields:
- `pipeline.workspace.cleanup_after_publish`
- `pipeline.spool.delete_audio_after_publish`
- Rename the registered stage from `archive` to `publish`.
- Update stage order so `publish` runs after `analyze` and before `notify`.
- Update top-level `narratio publish` to target stage `publish`.
- Keep `run-stage --artifacts <names> publish` support.
- Reject `run-stage --artifacts <names>` for stages other than `analyze` and
`publish`.
- Preserve the remote commit ordering and storage adapter boundaries.
Acceptance criteria:
- `narratio run-stage publish <session_id>` executes the publish stage.
- `narratio publish <session_id>` force-runs the publish stage.
- `narratio run-stage archive <session_id>` fails clearly as an unknown stage.
- Old archive config fields fail strict decoding.
- New publish config fields load, default, and validate.
### Stage 2: Runtime Terminology and Metadata Cutover
Status: Planned
Rename implementation concepts and runtime output to publish terminology.
Implementation requirements:
- Rename archive/promotion config and runtime types conceptually to
publish/output terms.
- Rename the remote key helper intent from promoted artifact to published
output while keeping generated keys unchanged.
- Change helper output:
- `Promoted:` becomes `Published:`
- `remote=promoted` becomes `remote=published`
- lock output uses `published` / `not-published`
- Rename publish-stage metadata, including:
- `promoted_paths` to `published_paths`
- `promoted_files_uploaded` to `published_files_uploaded`
- `skipped_optional_promotions` to `skipped_optional_outputs`
- `skipped_unselected_promotions` to `skipped_unselected_outputs`
- `locked_promotion_count` to `locked_output_count`
- `locked_promotions` to `locked_outputs`
- Update previous-cache and restore logic to use the `publish` stage and
`published_paths` metadata only.
- Keep run-local stage output materialization separate from remote publish
terminology. If local helper names are confusing, rename them to
materialization-oriented names rather than publish names.
Acceptance criteria:
- Status and artifact helper output use `Published:` and `remote=published`.
- Publish metadata contains only publish/output terminology.
- Previous-cache and restore behavior works with publish metadata and does not
depend on old archive metadata.
- Storage adapters still receive explicit keys and no AWS SDK details leak into
app or stage logic.
### Stage 3: Documentation, Examples, and Final Cleanup
Status: Planned
Update implemented-behavior docs and remove stale public terminology after the
runtime cutover lands.
Implementation requirements:
- Update current-behavior docs:
- `docs/config.md`
- `docs/cli.md`
- `docs/operations.md`
- `docs/troubleshooting.md`
- `docs/architecture.md`
- relevant files under `docs/internal/`
- Rename `docs/internal/stage-archive.md` to
`docs/internal/stage-publish.md`.
- Update internal documentation links and references.
- Update examples to use:
- `publish.outputs`
- `publish.locks`
- `cleanup_after_publish`
- `delete_audio_after_publish`
- Update tests and final searches so old terminology remains only in this
roadmap as historical context.
Acceptance criteria:
- Maintained examples load and validate.
- Current-behavior docs describe only implemented publish terminology.
- Internal docs describe run history, published outputs, locks, and current
commit ordering clearly.
- Old user-facing archive/promote wording is removed except where discussing
historical behavior in this roadmap.
## Test Guidance
Focused tests:
- `go test ./internal/config -v`
- `go test ./internal/app -v`
- `go test ./internal/stage -v`
- `go test ./internal/artifacts -v`
Full validation:
- `go test ./...`
Config tests to add or update:
- `publish.outputs` defaults and validates.
- `publish.outputs[].dest` derives from the artifact registry when omitted.
- `publish.locks` validates with the same source rules as publish outputs.
- old `archive` fails strict decode.
- old `promote_artifacts` fails strict decode.
- old cleanup fields fail strict decode.
App and stage tests to add or update:
- stage order uses `publish` before `notify`.
- `run-stage publish` succeeds.
- `run-stage archive` fails clearly.
- `narratio publish` force-runs the `publish` stage.
- `--artifacts` is accepted for `run-stage publish`.
- `--artifacts` error text names `analyze` and `publish`.
- status and artifact list output show `Published:` and `remote=published`.
- lock output says `published` or `not-published`.
- previous-cache and restore use `publish` stage metadata.
Final searches:
- Config/stage names:
- `pipeline.archive`
- `archive:`
- `promote_artifacts`
- `cleanup_after_archive`
- `delete_audio_after_archive`
- User-facing output:
- `Promoted:`
- `remote=promoted`
- `not-promoted`
- Runtime symbols and metadata:
- `ArchiveConfig`
- `ArchivePromotionRule`
- `S3PromotedArtifactKey`
- `promoted_paths`
- `promoted_files_uploaded`
- `locked_promotions`
Expected remaining matches should be limited to this roadmap and narrowly
justified historical references until the roadmap is fully retired.
## Architecture Guardrails
- Keep Narratio explicit and stage-driven.
- Do not introduce a generic workflow or DAG abstraction.
- Keep strict YAML decoding.
- Keep remote path construction centralized.
- Keep storage details behind `storage.ObjectStore`.
- Keep AWS SDK types inside storage adapters.
- Preserve manifest-driven resume and restore behavior.
- Preserve current-state commit ordering with `current/run_id.txt` written
last.
- Keep raw secrets out of configs, manifests, logs, generated configs, and
publish metadata.
- Keep planned behavior only in this roadmap until implementation lands.
## Assumptions
- This is a breaking public/config/stage contract change.
- No compatibility aliases are retained.
- No migration logic is needed for in-progress local manifests.
- No migration logic is needed for old remote manifests.
- Existing remote objects are not moved or renamed.
- `publish` means uploading run history, writing configured published outputs,
and committing current state.
- `run history` is the preferred term for immutable per-run records under
`runs/<run_id>/`.
- `archive` remains acceptable only as a generic English concept in historical
roadmap context, not as a public Narratio command, config field, stage name,
or metadata term after implementation.

210
docs/roadmap/transcripts.md Normal file
View File

@@ -0,0 +1,210 @@
# Roadmap: Transcript Artifact Naming
Status: Implemented
## Problem
Narratio's built-in transcript artifact names and canonical paths currently mix
operator-facing artifact meaning with historical stage and tool terminology:
- `narratio.transcript.merged` maps to `transcripts/merged.json`.
- `narratio.transcript.polished` maps to `transcripts/processed.json`.
- `narratio.transcript.full` maps to `transcripts/normalized.json`.
- `narratio.transcript.trimmed` maps to `transcripts/trimmed.json`.
This makes the public artifact surface harder to reason about. Operators see
`full`, `normalized`, `processed`, `polished`, `merged`, and `trimmed` used in
different places for the same transcript lineage.
The transcript source IDs, canonical paths, and manifest output kinds should
use one vocabulary based on each transcript's role in the session artifact
model.
## Target Model
Built-in transcript artifacts should use these public source IDs, canonical
paths, and manifest output kinds:
| Source ID | Canonical path | Output kind | Meaning |
| --- | --- | --- | --- |
| `narratio.transcript.base` | `transcripts/base.json` | `transcript_base` | First unified transcript produced by merging per-speaker raw transcripts. |
| `narratio.transcript.polished` | `transcripts/polished.json` | `transcript_polished` | Audita-polished transcript. |
| `narratio.transcript.final` | `transcripts/final.json` | `transcript_final` | Full final transcript after normalization. |
| `narratio.transcript.final_trimmed` | `transcripts/final.trimmed.json` | `transcript_final_trimmed` | Trimmed version of the final transcript. |
Stage names remain process-oriented and unchanged:
- `merge`
- `polish`
- `normalize`
- `trim`
Downstream adapter contracts also remain process-oriented. The rename changes
Narratio's artifact model, canonical paths, config examples, archive promotion
sources, lock sources, status output, and documentation. It should not rename
the stages themselves or move external integration details into stage logic.
## Compatibility Policy
This is a hard cutover.
After implementation, these old source IDs should be rejected:
- `narratio.transcript.merged`
- `narratio.transcript.full`
- `narratio.transcript.trimmed`
These old canonical paths should not be compatibility fallbacks:
- `transcripts/merged.json`
- `transcripts/processed.json`
- `transcripts/normalized.json`
- `transcripts/trimmed.json`
Existing remote archives are not migrated automatically. Operators who want
new promoted keys for old sessions should republish those sessions after
updating configuration.
## Implementation Stages
### Stage 1: Centralize Transcript Artifact Naming
Status: Implemented
Consolidate transcript artifact source IDs, canonical paths, and output kinds
in the artifact/path layer before changing runtime behavior.
Implementation requirements:
- Add or consolidate constants/helpers for built-in transcript source IDs.
- Add or consolidate constants/helpers for canonical transcript paths.
- Add or consolidate constants/helpers for transcript manifest output kinds.
- Keep source ID, path, and output-kind mappings in one registry or one
obviously shared artifact model.
- Update artifact registry tests to prove the target mapping.
- Avoid changing stage output behavior in this stage unless the implementation
is simpler and still reviewable.
Acceptance criteria:
- There is one clear source of truth for built-in transcript artifact names,
paths, and output kinds.
- Tests prove the new target mapping in the artifact layer.
- No generic workflow abstraction is introduced.
### Stage 2: Rename Runtime Outputs and Defaults
Status: Implemented
Switch runtime behavior to the new transcript artifact model.
Implementation requirements:
- Update `merge` to write and record `transcripts/base.json` with
`transcript_base`.
- Update `polish` to write and record `transcripts/polished.json` with
`transcript_polished`.
- Update `normalize` to write and record `transcripts/final.json` with
`transcript_final`.
- Update `trim` to write and record `transcripts/final.trimmed.json` with
`transcript_final_trimmed`.
- Update normalize and trim defaults to:
- `pipeline.normalize.output_path: transcripts/final.json`
- `pipeline.trim.output_path: transcripts/final.trimmed.json`
- Update built-in artifact resolution, archive promotion destination
derivation, archive locks, status output, artifact catalog output,
previous-cache resolution, restore planning, and restore execution to use
the new registry values.
- Ensure old source IDs fail config validation.
Acceptance criteria:
- New runs produce the target canonical transcript files.
- Manifest outputs use the target output kinds.
- Archive promotion and lock validation accept new source IDs and reject old
source IDs.
- Status and artifact listing display new source IDs.
- Restore uses the new canonical paths and does not restore old transcript
paths as canonical outputs.
### Stage 3: Update Tests, Examples, and Current Documentation
Status: Implemented
Update all implemented-behavior references after the runtime cutover lands.
Implementation requirements:
- Update examples to use `narratio.transcript.final_trimmed` and
`transcripts/final.trimmed.json` where trimmed final transcript is intended.
- Update examples that refer to full final transcripts to use
`narratio.transcript.final` and `transcripts/final.json`.
- Update `docs/config.md`, `docs/internal/artifacts.md`, stage docs,
CLI examples, operations examples, archive examples, lock examples, and
status/artifact-list examples.
- Add strict validation tests proving old source IDs are rejected.
- Mark roadmap stages implemented only after code, tests, examples, and
current-behavior docs agree.
Acceptance criteria:
- Maintained examples load and validate.
- Current-behavior docs describe only implemented new names.
- Old names remain only in this roadmap as historical/planning context until
this roadmap is retired or archived.
## Test Guidance
Run focused tests while implementing:
- `go test ./internal/artifacts -v`
- `go test ./internal/config -v`
- `go test ./internal/stage -v`
- `go test ./internal/app -v`
Run full validation before finishing:
- `go test ./...`
Run final searches:
- Old source IDs:
- `narratio.transcript.merged`
- `narratio.transcript.full`
- `narratio.transcript.trimmed`
- Old paths:
- `transcripts/merged.json`
- `transcripts/processed.json`
- `transcripts/normalized.json`
- `transcripts/trimmed.json`
- Old output kinds:
- `transcript_merged`
- `transcript_processed`
- `transcript_normalized`
- `transcript_trimmed`
Expected remaining matches should be limited to this roadmap's
historical/planning references until the roadmap is fully completed.
## Architecture Guardrails
- Keep Narratio explicit and stage-driven; do not introduce a generic workflow
or DAG abstraction.
- Keep path and artifact naming in centralized helpers rather than scattered
string concatenation.
- Preserve manifest-driven resume behavior.
- Keep storage details behind storage adapters.
- Do not move Seriatim, Audita, or Scriptorium command details out of their
adapter boundaries.
- Keep current-behavior documentation in sync only after implementation lands;
planned behavior belongs in this roadmap until then.
## Assumptions
- The cutover is intentionally not backward-compatible.
- Existing remote archive objects are not renamed or migrated automatically.
- Stage names and downstream adapter request field names remain unchanged.
- The term `base` is preferred over `merged` for the first unified transcript.
- The term `final` is preferred over `full` or `normalized` for the full final
transcript.
- The trimmed final path is `transcripts/final.trimmed.json`.

View File

@@ -6,46 +6,46 @@ Canonical operator troubleshooting guide for recurring implemented Narratio fail
## Config file discovery failure ## Config file discovery failure
Symptom: Symptom:
- `run`, `plan`, `resume`, `run-stage`, or `restore` fails with config/session not found. - `run`, `resume`, `run-stage`, `session plan`, or `session restore` fails with config/session not found.
Likely Cause: Likely Cause:
- `pipeline.yml`, `campaign.yml`, or `session.yml` is missing from system discovery paths. - `pipeline.yml` or `session.yml` is missing from system discovery paths.
- the selected campaign ID does not exist under `pipeline.campaigns.root`.
- a local working-directory config file was not passed explicitly. - a local working-directory config file was not passed explicitly.
Diagnostics: Diagnostics:
```bash ```bash
ls -l /usr/local/etc/narratio/pipeline.yml /etc/narratio/pipeline.yml ls -l /usr/local/etc/narratio/pipeline.yml /etc/narratio/pipeline.yml
ls -l /usr/local/etc/narratio/campaign.yml /etc/narratio/campaign.yml
ls -l /usr/local/etc/narratio/session.yml /etc/narratio/session.yml ls -l /usr/local/etc/narratio/session.yml /etc/narratio/session.yml
``` ```
Safe Fix: Safe Fix:
- pass explicit `--config`, `--campaign`, and `--session`. - pass explicit `--config`, `--campaign <id>`, `--campaign-file <path>`, and `--session` as appropriate.
- or place files in documented discovery paths. - or place files in documented discovery paths and set `pipeline.campaigns.default_campaign_id`.
Links: Links:
- [docs/config.md](./config.md) - [docs/config.md](./config.md)
- [docs/cli.md](./cli.md) - [docs/cli.md](./cli.md)
## Session template rendering failure ## Templated session file rejected
Symptom: Symptom:
- load fails with unresolved placeholder or `session_id` mismatch. - load fails with a message that `session.yml must be concrete`.
Likely Cause: Likely Cause:
- templated `session.yml` used without `--session-id`. - a template authoring file such as `session.template.yml` was passed to `--session` or uploaded as remote `session.yml`.
- rendered `session_id` differs from passed `--session-id`. - `session.yml` still contains `{{ ... }}` placeholders.
Diagnostics: Diagnostics:
```bash ```bash
narratio plan --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session ./session.yml --session-id 2026-04-04 narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session ./session.yml
``` ```
Safe Fix: Safe Fix:
- pass `--session-id` when template placeholders are present. - generate concrete YAML with `narratio session init`.
- ensure rendered `session_id` matches intended run session id. - pass the generated concrete `session.yml` to downstream commands or upload it through `session init --remote`.
Links: Links:
- [docs/config.md](./config.md) - [docs/config.md](./config.md)
@@ -62,7 +62,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```bash
narratio plan --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml
``` ```
Safe Fix: Safe Fix:
@@ -84,7 +84,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```bash
narratio run --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 --artifacts player_handout narratio run 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout
``` ```
Safe Fix: Safe Fix:
@@ -95,22 +95,22 @@ Links:
- [docs/cli.md](./cli.md) - [docs/cli.md](./cli.md)
- [docs/config.md](./config.md) - [docs/config.md](./config.md)
## `run-stage --artifacts` on non-analyze stage ## `run-stage --artifacts` on unsupported stage
Symptom: Symptom:
- `run-stage` fails with `--artifacts is only supported for stage "analyze"`. - `run-stage` fails because `--artifacts` is only supported for `analyze` and `archive`.
Likely Cause: Likely Cause:
- `--artifacts` was used with a non-`analyze` stage. - `--artifacts` was used with a stage other than `analyze` or `archive`.
Diagnostics: Diagnostics:
```bash ```bash
narratio run-stage --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 --artifacts session_recap polish narratio run-stage polish 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --artifacts session_recap
``` ```
Safe Fix: Safe Fix:
- use `--artifacts` only with `run-stage ... analyze`. - use `--artifacts` only with `run-stage analyze ...` or `run-stage archive ...`.
Links: Links:
- [docs/cli.md](./cli.md) - [docs/cli.md](./cli.md)
@@ -129,7 +129,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```bash
narratio plan --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 narratio session plan 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml
``` ```
Safe Fix: Safe Fix:
@@ -153,8 +153,8 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```bash
narratio status --manifest /path/to/manifest.json narratio session status 2026-04-04
narratio run-stage --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 --artifacts player_handout analyze narratio run-stage analyze 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout
``` ```
Safe Fix: Safe Fix:
@@ -168,22 +168,21 @@ Links:
## Manifest/status path failure ## Manifest/status path failure
Symptom: Symptom:
- `status` fails because manifest path is missing, unreadable, or invalid. - `session status` fails because config/session state is missing, unreadable, or invalid.
Likely Cause: Likely Cause:
- wrong manifest path. - wrong session ID.
- wrong config/campaign/session file selected.
- manifest removed after cleanup. - manifest removed after cleanup.
- `--manifest` omitted.
Diagnostics: Diagnostics:
```bash ```bash
narratio status --manifest /path/to/manifest.json narratio session status 2026-04-04
ls -l /path/to/manifest.json
``` ```
Safe Fix: Safe Fix:
- use manifest path printed by `run`, `resume`, or `run-stage`. - use the same session ID and config files that will be used for `run`, `resume`, or `run-stage`.
Links: Links:
- [docs/cli.md](./cli.md) - [docs/cli.md](./cli.md)
@@ -192,7 +191,7 @@ Links:
## Session lock conflict (`.lock`) ## Session lock conflict (`.lock`)
Symptom: Symptom:
- `run`, `resume`, `run-stage`, or `restore` fails with lock conflict for session workdir. - `run`, `resume`, `run-stage`, or `session restore` fails with lock conflict for session workdir.
Likely Cause: Likely Cause:
- another Narratio process is running same session. - another Narratio process is running same session.
@@ -217,7 +216,7 @@ Links:
## Restore remote current pointer or manifest missing ## Restore remote current pointer or manifest missing
Symptom: Symptom:
- `restore` fails with remote current pointer or current manifest errors. - `session restore` fails with remote current pointer or current manifest errors.
Likely Cause: Likely Cause:
- `current/run_id.txt` was never published. - `current/run_id.txt` was never published.
@@ -227,7 +226,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```bash
narratio restore --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 --dry-run narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --dry-run
``` ```
Safe Fix: Safe Fix:
@@ -241,20 +240,20 @@ Links:
## Restore manifest identity mismatch ## Restore manifest identity mismatch
Symptom: Symptom:
- `restore` fails because remote manifest session or campaign does not match requested values. - `session restore` fails because remote manifest session or campaign does not match requested values.
Likely Cause: Likely Cause:
- wrong `--session-id` or wrong session config selected. - wrong positional session ID or wrong session config selected.
- archive prefix points to a different campaign/session. - archive prefix points to a different campaign/session.
Diagnostics: Diagnostics:
```bash ```bash
narratio restore --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 --dry-run narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --dry-run
``` ```
Safe Fix: Safe Fix:
- use the correct session config and `--session-id`. - use the correct session config and positional session ID.
- verify campaign/session identity in local config before restore. - verify campaign/session identity in local config before restore.
Links: Links:
@@ -264,7 +263,7 @@ Links:
## Restore conflict without `--force` ## Restore conflict without `--force`
Symptom: Symptom:
- `restore` fails with `restore conflict` and conflict counts. - `session restore` fails with `restore conflict` and conflict counts.
Likely Cause: Likely Cause:
- local durable file differs from remote file for one or more planned restore paths. - local durable file differs from remote file for one or more planned restore paths.
@@ -272,7 +271,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```bash
narratio restore --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 --dry-run narratio session restore 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml --dry-run
``` ```
Safe Fix: Safe Fix:
@@ -342,7 +341,7 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```bash
narratio run-stage --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 prepare narratio run-stage prepare 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml
``` ```
Safe Fix: Safe Fix:
@@ -365,8 +364,8 @@ Likely Cause:
Diagnostics: Diagnostics:
```bash ```bash
narratio status --manifest /path/to/manifest.json narratio session status 2026-04-04
narratio run-stage --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 archive narratio run-stage archive 2026-04-04 --config /path/to/pipeline.yml --campaign-file /path/to/campaign.yml --session /path/to/session.yml
``` ```
Safe Fix: Safe Fix:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,9 +14,6 @@ workspace:
storage: storage:
# Optional storage backend selector; use "s3" for archive + S3 audio workflows. # Optional storage backend selector; use "s3" for archive + S3 audio workflows.
backend: s3 backend: s3
# Compatibility fields retained in schema.
bucket: ""
prefix: ""
s3: s3:
# Required when using S3 audio or S3 archive uploads. # Required when using S3 audio or S3 archive uploads.
bucket: my-dnd-archive bucket: my-dnd-archive
@@ -30,6 +27,12 @@ storage:
access_key_id_env: OBJECT_STORAGE_KEY_ID access_key_id_env: OBJECT_STORAGE_KEY_ID
secret_access_key_env: OBJECT_STORAGE_KEY secret_access_key_env: OBJECT_STORAGE_KEY
campaigns:
# Optional; defaults to /usr/local/share/narratio/campaigns.
root: /usr/local/share/narratio/campaigns
# Optional command default when --campaign is omitted.
default_campaign_id: sample-campaign
spool: spool:
# Optional; defaults to /var/spool/narratio. # Optional; defaults to /var/spool/narratio.
root: /var/spool/narratio root: /var/spool/narratio
@@ -42,8 +45,8 @@ archive:
upload_run: true upload_run: true
# Optional promotion rules; sources use Narratio artifact source IDs. # Optional promotion rules; sources use Narratio artifact source IDs.
promote_artifacts: promote_artifacts:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/trimmed.json dest: transcripts/final.trimmed.json
required: true required: true
- source: narratio.artifact.session_recap - source: narratio.artifact.session_recap
dest: artifacts/session_recap.md dest: artifacts/session_recap.md
@@ -96,14 +99,14 @@ audita:
normalize: normalize:
# Optional; defaults shown explicitly. # Optional; defaults shown explicitly.
output_path: transcripts/normalized.json output_path: transcripts/final.json
output_schema: seriatim-intermediate output_schema: seriatim-intermediate
report: true report: true
trim: trim:
# Keep disabled unless bounds prompt integration is configured. # Keep disabled unless bounds prompt integration is configured.
enabled: false enabled: false
output_path: transcripts/trimmed.json output_path: transcripts/final.trimmed.json
bounds: bounds:
prompt_id: dnd.session_bounds prompt_id: dnd.session_bounds
profile_id: local-fast profile_id: local-fast
@@ -130,7 +133,7 @@ scriptorium:
timeout: 10m timeout: 10m
inputs: inputs:
transcript: transcript:
source: narratio.transcript.trimmed source: narratio.transcript.final_trimmed
required: true required: true
previous_recap: previous_recap:
source: narratio.previous_session.artifact.session_recap source: narratio.previous_session.artifact.session_recap
@@ -158,21 +161,13 @@ scriptorium:
source: narratio.artifact.session_recap source: narratio.artifact.session_recap
required: true required: true
transcript: transcript:
source: narratio.transcript.trimmed source: narratio.transcript.final_trimmed
required: true required: true
vars: vars:
session_id: true session_id: true
campaign_name: true campaign_name: true
output_kind: player_handout output_kind: player_handout
analyzer:
# Optional adapter settings.
binary_path: ""
timeout: 2m
artifacts:
output_dir: ""
types: []
notification: notification:
# Optional notification settings. # Optional notification settings.
backend: "" backend: ""

View File

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

View File

@@ -11,6 +11,10 @@ storage:
access_key_id_env: OBJECT_STORAGE_KEY_ID access_key_id_env: OBJECT_STORAGE_KEY_ID
secret_access_key_env: OBJECT_STORAGE_KEY secret_access_key_env: OBJECT_STORAGE_KEY
campaigns:
root: /usr/local/share/narratio/campaigns
default_campaign_id: sample-campaign
spool: spool:
root: /var/spool/narratio root: /var/spool/narratio
delete_audio_after_archive: true delete_audio_after_archive: true
@@ -19,8 +23,8 @@ archive:
enabled: true enabled: true
upload_run: true upload_run: true
promote_artifacts: promote_artifacts:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/trimmed.json dest: transcripts/final.trimmed.json
required: true required: true
- source: narratio.artifact.session_recap - source: narratio.artifact.session_recap
dest: artifacts/session_recap.md dest: artifacts/session_recap.md
@@ -57,7 +61,7 @@ audita:
report: true report: true
normalize: normalize:
output_path: transcripts/normalized.json output_path: transcripts/final.json
output_schema: seriatim-intermediate output_schema: seriatim-intermediate
report: true report: true
@@ -78,7 +82,7 @@ scriptorium:
timeout: 10m timeout: 10m
inputs: inputs:
transcript: transcript:
source: narratio.transcript.trimmed source: narratio.transcript.final_trimmed
required: true required: true
previous_recap: previous_recap:
source: narratio.previous_session.artifact.session_recap source: narratio.previous_session.artifact.session_recap
@@ -102,14 +106,11 @@ scriptorium:
source: narratio.artifact.session_recap source: narratio.artifact.session_recap
required: true required: true
transcript: transcript:
source: narratio.transcript.trimmed source: narratio.transcript.final_trimmed
required: true required: true
vars: vars:
session_id: true session_id: true
output_kind: player_handout output_kind: player_handout
analyzer:
timeout: 2m
notification: notification:
timeout: 30s timeout: 30s

View File

@@ -1,40 +0,0 @@
package analyzer
import "context"
// NoopRunner is a deterministic no-op analyzer adapter.
type NoopRunner struct{}
// Run returns the requested output path with placeholder metadata.
func (n *NoopRunner) Run(ctx context.Context, req AnalyzeRequest) (AnalyzeResult, error) {
if err := ctx.Err(); err != nil {
return AnalyzeResult{}, err
}
return AnalyzeResult{ArtifactPath: req.OutputPath, Metadata: map[string]any{"placeholder": true}}, nil
}
// FakeRunner captures analyze requests and returns deterministic responses.
type FakeRunner struct {
Requests []AnalyzeRequest
Err error
Result AnalyzeResult
}
// Run records request and returns configured response.
func (f *FakeRunner) Run(ctx context.Context, req AnalyzeRequest) (AnalyzeResult, error) {
if err := ctx.Err(); err != nil {
return AnalyzeResult{}, err
}
f.Requests = append(f.Requests, req)
if f.Err != nil {
return AnalyzeResult{}, f.Err
}
res := f.Result
if res.ArtifactPath == "" {
res.ArtifactPath = req.OutputPath
}
if res.Metadata == nil {
res.Metadata = map[string]any{"fake": true}
}
return res, nil
}

View File

@@ -1,31 +0,0 @@
package analyzer
import (
"context"
"errors"
"testing"
)
func TestFakeRunnerCapturesRequestAndReturnsPath(t *testing.T) {
fake := &FakeRunner{}
req := AnalyzeRequest{ArtifactType: "session-log", OutputPath: "artifacts/session-log.md"}
res, err := fake.Run(context.Background(), req)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(fake.Requests) != 1 || fake.Requests[0].ArtifactType != "session-log" {
t.Fatalf("requests = %#v, want captured request", fake.Requests)
}
if res.ArtifactPath != req.OutputPath {
t.Fatalf("artifact path = %q, want %q", res.ArtifactPath, req.OutputPath)
}
}
func TestFakeRunnerError(t *testing.T) {
fake := &FakeRunner{Err: errors.New("boom")}
_, err := fake.Run(context.Background(), AnalyzeRequest{})
if err == nil {
t.Fatal("expected error, got nil")
}
}

View File

@@ -1,28 +0,0 @@
// Package analyzer declares the adapter contract for artifact analysis generation.
package analyzer
import "context"
// TODO: implement analyzer integration once the analyzer contract is finalized.
// Runner is the adapter boundary for analyzer invocations.
type Runner interface {
Run(ctx context.Context, req AnalyzeRequest) (AnalyzeResult, error)
}
// AnalyzeRequest describes one analyzer artifact generation request.
type AnalyzeRequest struct {
ArtifactType string
ProcessedTranscriptPath string
ContextReferences []string
OutputPath string
GeneratedConfigPath string
StdoutLogPath string
StderrLogPath string
}
// AnalyzeResult describes analyzer output.
type AnalyzeResult struct {
ArtifactPath string
Metadata map[string]any
}

View File

@@ -14,7 +14,7 @@ func TestFakeRunnerCapturesRequestAndReturnsPath(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
req := PolishRequest{ req := PolishRequest{
GeneratedConfigPath: filepath.Join(dir, "config", "audita.yml"), GeneratedConfigPath: filepath.Join(dir, "config", "audita.yml"),
OutputProcessedPath: filepath.Join(dir, "transcripts", "processed.json"), OutputProcessedPath: filepath.Join(dir, "transcripts", "polished.json"),
StdoutLogPath: filepath.Join(dir, "logs", "audita.stdout.log"), StdoutLogPath: filepath.Join(dir, "logs", "audita.stdout.log"),
StderrLogPath: filepath.Join(dir, "logs", "audita.stderr.log"), StderrLogPath: filepath.Join(dir, "logs", "audita.stderr.log"),
} }

View File

@@ -52,9 +52,9 @@ func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
req := PolishRequest{ req := PolishRequest{
GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"), GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"),
MergedTranscriptPath: filepath.Join(dir, "merged.json"), MergedTranscriptPath: filepath.Join(dir, "base.json"),
GlossaryPath: filepath.Join(dir, "glossary.yml"), GlossaryPath: filepath.Join(dir, "glossary.yml"),
OutputProcessedPath: filepath.Join(dir, "processed.json"), OutputProcessedPath: filepath.Join(dir, "polished.json"),
ReportPath: filepath.Join(dir, "audita.report.json"), ReportPath: filepath.Join(dir, "audita.report.json"),
WorkDir: filepath.Join(dir, "artifacts", "audita-work"), WorkDir: filepath.Join(dir, "artifacts", "audita-work"),
StdoutLogPath: filepath.Join(dir, "audita.stdout.log"), StdoutLogPath: filepath.Join(dir, "audita.stdout.log"),
@@ -571,7 +571,7 @@ func mustAuditaRunner(t *testing.T, cfg SubprocessRunnerConfig) *SubprocessRunne
func auditaReqForTest(t *testing.T, withReport bool) PolishRequest { func auditaReqForTest(t *testing.T, withReport bool) PolishRequest {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
merged := filepath.Join(dir, "merged.json") merged := filepath.Join(dir, "base.json")
glossary := filepath.Join(dir, "glossary.yml") glossary := filepath.Join(dir, "glossary.yml")
writeAuditaTestFile(t, merged, `{"segments":[]}`) writeAuditaTestFile(t, merged, `{"segments":[]}`)
writeAuditaTestFile(t, glossary, "terms: []\n") writeAuditaTestFile(t, glossary, "terms: []\n")
@@ -579,7 +579,7 @@ func auditaReqForTest(t *testing.T, withReport bool) PolishRequest {
GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"), GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"),
MergedTranscriptPath: merged, MergedTranscriptPath: merged,
GlossaryPath: glossary, GlossaryPath: glossary,
OutputProcessedPath: filepath.Join(dir, "processed.json"), OutputProcessedPath: filepath.Join(dir, "polished.json"),
WorkDir: filepath.Join(dir, "artifacts", "audita-work"), WorkDir: filepath.Join(dir, "artifacts", "audita-work"),
StdoutLogPath: filepath.Join(dir, "audita.stdout.log"), StdoutLogPath: filepath.Join(dir, "audita.stdout.log"),
StderrLogPath: filepath.Join(dir, "audita.stderr.log"), StderrLogPath: filepath.Join(dir, "audita.stderr.log"),

View File

@@ -31,7 +31,7 @@ func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *tes
ConfigPath: "/etc/scriptorium/config.yml", ConfigPath: "/etc/scriptorium/config.yml",
PromptID: "dnd.session_recap", PromptID: "dnd.session_recap",
ProfileID: "local-quality", ProfileID: "local-quality",
InputPaths: map[string]string{"transcript": filepath.Join(dir, "processed.json"), "other": filepath.Join(dir, "other.md")}, InputPaths: map[string]string{"transcript": filepath.Join(dir, "polished.json"), "other": filepath.Join(dir, "other.md")},
Vars: map[string]string{"session_id": "2026-05-03", "campaign_name": "Icewind Dale"}, Vars: map[string]string{"session_id": "2026-05-03", "campaign_name": "Icewind Dale"},
OutputPath: filepath.Join(dir, "artifacts", "session_recap.md"), OutputPath: filepath.Join(dir, "artifacts", "session_recap.md"),
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.run.stdout.log"), StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.run.stdout.log"),
@@ -180,7 +180,7 @@ func TestSubprocessRunnerRenderSuccess(t *testing.T) {
req := RenderArtifactRequest{ req := RenderArtifactRequest{
Binary: wrapper, Binary: wrapper,
PromptID: "dnd.session_recap", PromptID: "dnd.session_recap",
InputPaths: map[string]string{"transcript": filepath.Join(dir, "processed.json")}, InputPaths: map[string]string{"transcript": filepath.Join(dir, "polished.json")},
OutputPath: filepath.Join(dir, "artifacts", "session_recap.render.json"), OutputPath: filepath.Join(dir, "artifacts", "session_recap.render.json"),
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.render.stdout.log"), StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.render.stdout.log"),
StderrLogPath: filepath.Join(dir, "logs", "scriptorium.render.stderr.log"), StderrLogPath: filepath.Join(dir, "logs", "scriptorium.render.stderr.log"),
@@ -285,7 +285,7 @@ type scriptoriumHelperRecord struct {
func runReqForTest(t *testing.T, binary string) RunArtifactRequest { func runReqForTest(t *testing.T, binary string) RunArtifactRequest {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
transcriptPath := filepath.Join(dir, "processed.json") transcriptPath := filepath.Join(dir, "polished.json")
writeScriptoriumFile(t, transcriptPath, `{"segments":[]}`) writeScriptoriumFile(t, transcriptPath, `{"segments":[]}`)
return RunArtifactRequest{ return RunArtifactRequest{
Binary: binary, Binary: binary,

View File

@@ -14,7 +14,7 @@ func TestFakeRunnerCapturesRequestAndReturnsPath(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
req := MergeRequest{ req := MergeRequest{
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.yml"), GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.yml"),
OutputMergedTranscriptPath: filepath.Join(dir, "transcripts", "merged.json"), OutputMergedTranscriptPath: filepath.Join(dir, "transcripts", "base.json"),
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.stdout.log"), StdoutLogPath: filepath.Join(dir, "logs", "seriatim.stdout.log"),
StderrLogPath: filepath.Join(dir, "logs", "seriatim.stderr.log"), StderrLogPath: filepath.Join(dir, "logs", "seriatim.stderr.log"),
} }
@@ -57,8 +57,8 @@ func TestFakeRunnerTrimCapturesRequestAndReturnsPath(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
req := TrimRequest{ req := TrimRequest{
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.trim.yml"), GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.trim.yml"),
InputTranscriptPath: filepath.Join(dir, "transcripts", "processed.json"), InputTranscriptPath: filepath.Join(dir, "transcripts", "polished.json"),
OutputTrimmedPath: filepath.Join(dir, "transcripts", "trimmed.json"), OutputTrimmedPath: filepath.Join(dir, "transcripts", "final.trimmed.json"),
KeepSelector: "1-10", KeepSelector: "1-10",
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.trim.stdout.log"), StdoutLogPath: filepath.Join(dir, "logs", "seriatim.trim.stdout.log"),
StderrLogPath: filepath.Join(dir, "logs", "seriatim.trim.stderr.log"), StderrLogPath: filepath.Join(dir, "logs", "seriatim.trim.stderr.log"),
@@ -105,8 +105,8 @@ func TestFakeRunnerNormalizeCapturesRequestAndReturnsPath(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
req := NormalizeRequest{ req := NormalizeRequest{
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.normalize.yml"), GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.normalize.yml"),
InputTranscriptPath: filepath.Join(dir, "transcripts", "processed.json"), InputTranscriptPath: filepath.Join(dir, "transcripts", "polished.json"),
OutputNormalizedPath: filepath.Join(dir, "transcripts", "normalized.json"), OutputNormalizedPath: filepath.Join(dir, "transcripts", "final.json"),
OutputSchema: "seriatim-intermediate", OutputSchema: "seriatim-intermediate",
ReportPath: filepath.Join(dir, "artifacts", "seriatim.normalize.report.json"), ReportPath: filepath.Join(dir, "artifacts", "seriatim.normalize.report.json"),
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.normalize.stdout.log"), StdoutLogPath: filepath.Join(dir, "logs", "seriatim.normalize.stdout.log"),

View File

@@ -50,7 +50,7 @@ func TestSubprocessRunnerSuccessWithReportArgsAndEnv(t *testing.T) {
req := MergeRequest{ req := MergeRequest{
GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"), GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"),
InputTranscriptPaths: []string{filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json")}, InputTranscriptPaths: []string{filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json")},
OutputMergedTranscriptPath: filepath.Join(dir, "merged.json"), OutputMergedTranscriptPath: filepath.Join(dir, "base.json"),
ReportPath: filepath.Join(dir, "seriatim.report.json"), ReportPath: filepath.Join(dir, "seriatim.report.json"),
SpeakersPath: filepath.Join(dir, "speakers.yml"), SpeakersPath: filepath.Join(dir, "speakers.yml"),
AutocorrectPath: filepath.Join(dir, "autocorrect.yml"), AutocorrectPath: filepath.Join(dir, "autocorrect.yml"),
@@ -732,7 +732,7 @@ func mergeReqForTest(t *testing.T, withReport bool) MergeRequest {
req := MergeRequest{ req := MergeRequest{
GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"), GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"),
InputTranscriptPaths: []string{in1, in2}, InputTranscriptPaths: []string{in1, in2},
OutputMergedTranscriptPath: filepath.Join(dir, "merged.json"), OutputMergedTranscriptPath: filepath.Join(dir, "base.json"),
StdoutLogPath: filepath.Join(dir, "seriatim.stdout.log"), StdoutLogPath: filepath.Join(dir, "seriatim.stdout.log"),
StderrLogPath: filepath.Join(dir, "seriatim.stderr.log"), StderrLogPath: filepath.Join(dir, "seriatim.stderr.log"),
} }
@@ -745,11 +745,11 @@ func mergeReqForTest(t *testing.T, withReport bool) MergeRequest {
func trimReqForTest(t *testing.T) TrimRequest { func trimReqForTest(t *testing.T) TrimRequest {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
input := filepath.Join(dir, "processed.json") input := filepath.Join(dir, "polished.json")
writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`) writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
return TrimRequest{ return TrimRequest{
InputTranscriptPath: input, InputTranscriptPath: input,
OutputTrimmedPath: filepath.Join(dir, "trimmed.json"), OutputTrimmedPath: filepath.Join(dir, "final.trimmed.json"),
KeepSelector: "5-12", KeepSelector: "5-12",
GeneratedConfigPath: filepath.Join(dir, "seriatim.trim.generated.yml"), GeneratedConfigPath: filepath.Join(dir, "seriatim.trim.generated.yml"),
StdoutLogPath: filepath.Join(dir, "seriatim.trim.stdout.log"), StdoutLogPath: filepath.Join(dir, "seriatim.trim.stdout.log"),
@@ -760,12 +760,12 @@ func trimReqForTest(t *testing.T) TrimRequest {
func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest { func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
input := filepath.Join(dir, "processed.json") input := filepath.Join(dir, "polished.json")
writeSeriatimFile(t, input, `{"schema":"audita.processed.v1","segments":[]}`) writeSeriatimFile(t, input, `{"schema":"audita.processed.v1","segments":[]}`)
req := NormalizeRequest{ req := NormalizeRequest{
InputTranscriptPath: input, InputTranscriptPath: input,
OutputNormalizedPath: filepath.Join(dir, "normalized.json"), OutputNormalizedPath: filepath.Join(dir, "final.json"),
OutputSchema: "seriatim-intermediate", OutputSchema: "seriatim-intermediate",
GeneratedConfigPath: filepath.Join(dir, "seriatim.normalize.generated.yml"), GeneratedConfigPath: filepath.Join(dir, "seriatim.normalize.generated.yml"),
StdoutLogPath: filepath.Join(dir, "seriatim.normalize.stdout.log"), StdoutLogPath: filepath.Join(dir, "seriatim.normalize.stdout.log"),

View File

@@ -46,7 +46,7 @@ func (f *artifactSelectionFlag) Normalize() ([]string, error) {
return out, nil return out, nil
} }
func validateSelectedAnalyzeArtifacts(cfg *config.Config, selected []string) error { func validateSelectedArtifacts(cfg *config.Config, selected []string) error {
if len(selected) == 0 { if len(selected) == 0 {
return nil return nil
} }

View File

@@ -14,25 +14,67 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/stage" "gitea.maximumdirect.net/eric/narratio/internal/stage"
) )
func TestExecuteRunStageArtifactsNonAnalyzeFails(t *testing.T) { func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, 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, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap", "polish"}, []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&stdout, &stdout,
&stderr, &stderr,
) )
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
if !strings.Contains(stderr.String(), `run-stage: --artifacts is only supported for stage "analyze"`) { if !strings.Contains(stderr.String(), `run-stage: --artifacts is only supported for stages "analyze" and "archive"`) {
t.Fatalf("stderr = %q, want stage-gating error", stderr.String()) t.Fatalf("stderr = %q, want stage-gating error", stderr.String())
} }
} }
func TestExecuteRunStageArchivePropagatesSelectedArtifacts(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
var capturedStages []string
var capturedArtifacts []string
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
for _, s := range stages {
capturedStages = append(capturedStages, s.Name())
}
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"archive"}}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{
"run-stage", "archive", "2026-05-03",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
"--artifacts", "session_recap",
},
&stdout,
&stderr,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if len(capturedStages) != 1 || capturedStages[0] != "archive" {
t.Fatalf("captured stages = %#v, want [archive]", capturedStages)
}
if strings.Join(capturedArtifacts, ",") != "session_recap" {
t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts)
}
}
func TestExecuteUnknownArtifactsFailValidation(t *testing.T) { func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
@@ -40,7 +82,7 @@ func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( code := Execute(
[]string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
&stdout, &stdout,
&stderr, &stderr,
) )
@@ -67,7 +109,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
err := RunStage( err := RunStage(
context.Background(), context.Background(),
[]string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap", "analyze"}, []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap"},
&out, &out,
) )
if err != nil { if err != nil {
@@ -95,7 +137,7 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
err := Resume( err := Resume(
context.Background(), context.Background(),
[]string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&out, &out,
) )
if err != nil { if err != nil {
@@ -130,7 +172,7 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( code := Execute(
[]string{"analyze", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
&stdout, &stdout,
&stderr, &stderr,
) )
@@ -167,8 +209,9 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
code := Execute( code := Execute(
[]string{ []string{
"analyze", "analyze",
"2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--artifacts", "player_handout,session_recap", "--artifacts", "player_handout,session_recap",
}, },
@@ -190,7 +233,7 @@ func TestExecuteAnalyzeUnknownArtifactFailsValidation(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( code := Execute(
[]string{"analyze", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"}, []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
&stdout, &stdout,
&stderr, &stderr,
) )
@@ -208,7 +251,7 @@ func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
args []string args []string
want string want string
}{ }{
{name: "positional", args: []string{"analyze", "extra"}, want: "analyze: unexpected positional arguments"}, {name: "extra positional", args: []string{"analyze", "2026-05-03", "extra"}, want: "analyze: unexpected positional arguments"},
{name: "force flag", args: []string{"analyze", "--force"}, want: "analyze: invalid flags: flag provided but not defined: -force"}, {name: "force flag", args: []string{"analyze", "--force"}, want: "analyze: invalid flags: flag provided but not defined: -force"},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -229,7 +272,7 @@ func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) { func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"analyze"}, &stdout, &stderr) code := Execute([]string{"analyze", "2026-05-03"}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -238,7 +281,109 @@ func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
} }
} }
func TestExecuteUsageIncludesAnalyze(t *testing.T) { func TestExecutePublishForceRunsArchive(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
var capturedStages []string
var capturedForce bool
var capturedArtifacts []string
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
for _, s := range stages {
capturedStages = append(capturedStages, s.Name())
}
capturedForce = opts.Force
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
return &RunSummary{
ManifestPath: filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json"),
Executed: []string{"archive"},
}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&stdout,
&stderr,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if len(capturedStages) != 1 || capturedStages[0] != "archive" {
t.Fatalf("captured stages = %#v, want [archive]", capturedStages)
}
if !capturedForce {
t.Fatal("captured force = false, want true")
}
if strings.Join(capturedArtifacts, ",") != "session_recap" {
t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts)
}
if !strings.Contains(stdout.String(), "narratio publish: executed=1 skipped=0 force=true; manifest=") {
t.Fatalf("stdout = %q, want publish summary", stdout.String())
}
}
func TestExecutePublishRejectsUnsupportedArgsAndFlags(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{name: "extra positional", args: []string{"publish", "2026-05-03", "extra"}, want: "publish: unexpected positional arguments"},
{name: "force flag", args: []string{"publish", "--force"}, want: "publish: invalid flags: flag provided but not defined: -force"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(tc.args, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), tc.want) {
t.Fatalf("stderr = %q, want %q", stderr.String(), tc.want)
}
})
}
}
func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
&stdout,
&stderr,
)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), `publish: --artifacts includes unknown artifact "unknown_artifact"`) {
t.Fatalf("stderr = %q, want unknown-artifact validation error", stderr.String())
}
}
func TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"publish", "2026-05-03"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "publish: no pipeline config path provided and no default pipeline config found; searched:") {
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
}
}
func TestExecuteUsageIncludesAnalyzeAndPublish(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute(nil, &stdout, &stderr) code := Execute(nil, &stdout, &stderr)
@@ -248,6 +393,9 @@ func TestExecuteUsageIncludesAnalyze(t *testing.T) {
if !strings.Contains(stderr.String(), "analyze") { if !strings.Contains(stderr.String(), "analyze") {
t.Fatalf("stderr = %q, want usage to include analyze", stderr.String()) t.Fatalf("stderr = %q, want usage to include analyze", stderr.String())
} }
if !strings.Contains(stderr.String(), "publish") {
t.Fatalf("stderr = %q, want usage to include publish", stderr.String())
}
} }
func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string, string) { func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string, string) {

View File

@@ -64,7 +64,7 @@ func TestArtifactSelectionFlagNormalize(t *testing.T) {
} }
} }
func TestValidateSelectedAnalyzeArtifacts(t *testing.T) { func TestValidateSelectedArtifacts(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
cfg *config.Config cfg *config.Config
@@ -114,7 +114,7 @@ func TestValidateSelectedAnalyzeArtifacts(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) {
err := validateSelectedAnalyzeArtifacts(tt.cfg, tt.selected) err := validateSelectedArtifacts(tt.cfg, tt.selected)
if tt.wantErr != "" { if tt.wantErr != "" {
if err == nil { if err == nil {
t.Fatalf("error = nil, want %q", tt.wantErr) t.Fatalf("error = nil, want %q", tt.wantErr)

View File

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

View File

@@ -1,49 +1,84 @@
package app package app
import ( import (
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
) )
func TestResolveCampaignConfigPathExplicitWins(t *testing.T) { func TestResolveCampaignConfigPathCampaignFileWins(t *testing.T) {
explicit := filepath.Join(t.TempDir(), "custom-campaign.yml") explicit := filepath.Join(t.TempDir(), "custom-campaign.yml")
got, err := resolveCampaignConfigPathWithCandidates(explicit, []string{filepath.Join(t.TempDir(), "campaign.yml")}) got, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "", explicit)
if err != nil { if err != nil {
t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err) t.Fatalf("resolveCampaignConfigPath() error = %v", err)
} }
if got != explicit { if got != explicit {
t.Fatalf("path = %q, want explicit path %q", got, explicit) t.Fatalf("path = %q, want explicit path %q", got, explicit)
} }
} }
func TestResolveCampaignConfigPathUsesFirstExistingDefault(t *testing.T) { func TestResolveCampaignConfigPathUsesSelectedCampaignID(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
missing := filepath.Join(dir, "missing.yml") pipelineCfg := &config.PipelineConfig{}
found := filepath.Join(dir, "campaign.yml") pipelineCfg.Campaigns.Root = dir
if err := os.WriteFile(found, []byte("campaign: sample-campaign\n"), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err)
}
got, err := resolveCampaignConfigPathWithCandidates("", []string{missing, found}) got, err := resolveCampaignConfigPath(pipelineCfg, "icewind", "")
if err != nil { if err != nil {
t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err) t.Fatalf("resolveCampaignConfigPath() error = %v", err)
} }
if got != filepath.Clean(found) { want := filepath.Join(dir, "icewind", "campaign.yml")
t.Fatalf("path = %q, want %q", got, filepath.Clean(found)) if got != filepath.Clean(want) {
t.Fatalf("path = %q, want %q", got, filepath.Clean(want))
} }
} }
func TestResolveCampaignConfigPathErrorIncludesSearchedPaths(t *testing.T) { func TestResolveCampaignConfigPathUsesDefaultCampaignID(t *testing.T) {
_, err := resolveCampaignConfigPathWithCandidates("", []string{"/usr/local/etc/narratio/campaign.yml", "/etc/narratio/campaign.yml"}) dir := t.TempDir()
pipelineCfg := &config.PipelineConfig{}
pipelineCfg.Campaigns.Root = dir
pipelineCfg.Campaigns.DefaultCampaignID = "dilfs"
got, err := resolveCampaignConfigPath(pipelineCfg, "", "")
if err != nil {
t.Fatalf("resolveCampaignConfigPath() error = %v", err)
}
want := filepath.Join(dir, "dilfs", "campaign.yml")
if got != filepath.Clean(want) {
t.Fatalf("path = %q, want %q", got, filepath.Clean(want))
}
}
func TestResolveCampaignConfigPathRejectsCampaignIDAndFile(t *testing.T) {
_, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "dilfs", filepath.Join(t.TempDir(), "campaign.yml"))
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if !strings.Contains(err.Error(), "searched") { if !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %q, want searched paths", err.Error()) t.Fatalf("error = %q, want mutual exclusion", err.Error())
} }
if !strings.Contains(err.Error(), "pass --campaign") { }
t.Fatalf("error = %q, want explicit-campaign guidance", err.Error())
func TestResolveCampaignConfigPathRequiresCampaignSelection(t *testing.T) {
_, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "", "")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "no campaign selected") {
t.Fatalf("error = %q, want missing selection guidance", err.Error())
}
}
func TestResolveCampaignConfigPathRejectsPathLikeCampaignID(t *testing.T) {
pipelineCfg := &config.PipelineConfig{}
pipelineCfg.Campaigns.Root = t.TempDir()
_, err := resolveCampaignConfigPath(pipelineCfg, "../icewind", "")
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "single path segment") {
t.Fatalf("error = %q, want path segment guidance", err.Error())
} }
} }

View File

@@ -16,6 +16,7 @@ import (
// Clean removes local workspace/spool state while preserving durable cache // Clean removes local workspace/spool state while preserving durable cache
// state unless cache cleanup is explicitly requested. // state unless cache cleanup is explicitly requested.
func Clean(ctx context.Context, args []string, out io.Writer) error { func Clean(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("clean", flag.ContinueOnError) fs := flag.NewFlagSet("clean", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
@@ -29,8 +30,17 @@ func Clean(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("clean: invalid flags: %w", err) return fmt.Errorf("clean: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("clean: unexpected positional arguments") if err := applyParsedSessionIDArg("clean", fs, &flags.sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("clean: unexpected positional arguments")
}
if err := applyPositionalSessionID("clean", positionalSessionID, &flags.sessionID); err != nil {
return err
}
} }
if all { if all {
return cleanAllLocal(flags, dryRun, clearCache, out) return cleanAllLocal(flags, dryRun, clearCache, out)
@@ -40,9 +50,9 @@ func Clean(ctx context.Context, args []string, out io.Writer) error {
func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error { func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error {
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("clean: --session-id is required unless --all is set") return fmt.Errorf("clean: session_id is required unless --all is set")
} }
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions()) cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
return fmt.Errorf("clean: %w", err) return fmt.Errorf("clean: %w", err)
} }
@@ -82,10 +92,11 @@ func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCac
func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error { func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error {
if strings.TrimSpace(flags.campaignPath) != "" || if strings.TrimSpace(flags.campaignPath) != "" ||
strings.TrimSpace(flags.campaignFilePath) != "" ||
strings.TrimSpace(flags.sessionPath) != "" || strings.TrimSpace(flags.sessionPath) != "" ||
strings.TrimSpace(flags.sessionID) != "" || strings.TrimSpace(flags.sessionID) != "" ||
strings.TrimSpace(flags.previousSessionID) != "" { strings.TrimSpace(flags.previousSessionID) != "" {
return fmt.Errorf("clean: --all cannot be combined with --campaign, --session, --session-id, or --previous-session-id") return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
} }
resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath) resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath)
if err != nil { if err != nil {

View File

@@ -26,7 +26,7 @@ func TestExecuteCleanSessionDeletesWorkAndSpoolButPreservesCache(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03"}, &stdout, &stderr) code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -48,7 +48,7 @@ func TestExecuteCleanSessionDryRunDeletesNothing(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--dry-run"}, &stdout, &stderr) code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -65,7 +65,7 @@ func TestExecuteCleanMissingSessionPathsSucceeds(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03"}, &stdout, &stderr) code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -105,7 +105,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--clear-cache"}, &stdout, &stderr) code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -122,7 +122,7 @@ func TestExecuteCleanLocalAudioClearCacheIsNoop(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--clear-cache"}, &stdout, &stderr) code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -184,7 +184,7 @@ func TestExecuteCleanAllRejectsSessionScopedFlags(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--all"}, &stdout, &stderr) code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--all"}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -200,7 +200,7 @@ func TestCleanRequiresSessionID(t *testing.T) {
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
if !strings.Contains(stderr.String(), "--session-id is required unless --all is set") { if !strings.Contains(stderr.String(), "session_id is required unless --all is set") {
t.Fatalf("stderr = %q, want missing session-id", stderr.String()) t.Fatalf("stderr = %q, want missing session-id", stderr.String())
} }
} }

View File

@@ -7,7 +7,7 @@ import (
"strings" "strings"
) )
var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage", "analyze", "restore", "session", "artifacts", "locks", "clean"} var supportedCommands = []string{"run", "run-stage", "resume", "analyze", "publish", "clean", "session"}
// Execute dispatches CLI commands and returns a process exit code. // Execute dispatches CLI commands and returns a process exit code.
func Execute(args []string, stdout, stderr io.Writer) int { func Execute(args []string, stdout, stderr io.Writer) int {
@@ -24,24 +24,16 @@ func Execute(args []string, stdout, stderr io.Writer) int {
switch cmd { switch cmd {
case "run": case "run":
err = Run(ctx, cmdArgs, stdout) err = Run(ctx, cmdArgs, stdout)
case "plan":
err = Plan(ctx, cmdArgs, stdout)
case "status":
err = Status(ctx, cmdArgs, stdout)
case "resume": case "resume":
err = Resume(ctx, cmdArgs, stdout) err = Resume(ctx, cmdArgs, stdout)
case "run-stage": case "run-stage":
err = RunStage(ctx, cmdArgs, stdout) err = RunStage(ctx, cmdArgs, stdout)
case "analyze": case "analyze":
err = Analyze(ctx, cmdArgs, stdout) err = Analyze(ctx, cmdArgs, stdout)
case "restore": case "publish":
err = Restore(ctx, cmdArgs, stdout) err = Publish(ctx, cmdArgs, stdout)
case "session": case "session":
err = Session(ctx, cmdArgs, stdout) err = Session(ctx, cmdArgs, stdout)
case "artifacts":
err = Artifacts(ctx, cmdArgs, stdout)
case "locks":
err = Locks(ctx, cmdArgs, stdout)
case "clean": case "clean":
err = Clean(ctx, cmdArgs, stdout) err = Clean(ctx, cmdArgs, stdout)
default: default:

View File

@@ -25,18 +25,17 @@ func TestExecuteValidCommands(t *testing.T) {
defer srv.Close() defer srv.Close()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
manifestPath := writeManifestPathForExecute(t)
cases := []struct { cases := []struct {
name string name string
args []string args []string
wantOut string wantOut string
}{ }{
{name: "run", args: []string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="}, {name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
{name: "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: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"},
{name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"}, {name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
{name: "resume", args: []string{"resume", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"}, {name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
{name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="}, {name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -64,13 +63,13 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
args []string args []string
want string want string
}{ }{
{name: "run missing flags", args: []string{"run"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"}, {name: "run missing session", args: []string{"run"}, want: "run: session_id is required"},
{name: "plan missing flags", args: []string{"plan"}, want: "plan: no pipeline config path provided and no default pipeline config found; searched:"}, {name: "plan old top-level removed", args: []string{"plan"}, want: `unknown command: "plan"`},
{name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"}, {name: "status old top-level removed", args: []string{"status"}, want: `unknown command: "status"`},
{name: "resume missing flags", args: []string{"resume"}, want: "resume: no pipeline config path provided and no default pipeline config found; searched:"}, {name: "resume missing session", args: []string{"resume"}, want: "resume: session_id is required"},
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected exactly one stage name"}, {name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected stage name and session_id"},
{name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: no pipeline config path provided and no default pipeline config found; searched:"}, {name: "run-stage missing session", args: []string{"run-stage", "polish"}, want: "run-stage: expected stage name and session_id"},
{name: "run missing config uses defaults", args: []string{"run", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"}, {name: "run missing config uses defaults", args: []string{"run", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -99,7 +98,7 @@ func TestExecuteRunStageUnknownFails(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "unknown"}, &stdout, &stderr) code := Execute([]string{"run-stage", "unknown", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -112,12 +111,12 @@ func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, 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", "polished.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, "--campaign", campaignPath, "--session", sessionPath, "normalize"}, &stdout, &stderr) code := Execute([]string{"run-stage", "normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -141,14 +140,14 @@ func TestExecuteRunStageTranscribeUsesConfiguredWhisperXServer(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "prepare"}, &stdout, &stderr) code := Execute([]string{"run-stage", "prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
stdout.Reset() stdout.Reset()
stderr.Reset() stderr.Reset()
code = Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "transcribe"}, &stdout, &stderr) code = Execute([]string{"run-stage", "transcribe", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -203,8 +202,6 @@ seriatim:
audita: audita:
binary: ` + auditaBinary + ` binary: ` + auditaBinary + `
llm_api_key_env: OPENROUTER_API_KEY llm_api_key_env: OPENROUTER_API_KEY
analyzer:
timeout: 20m
notification: notification:
timeout: 10s timeout: 10s
` `
@@ -235,12 +232,12 @@ inputs:
}) })
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", sessionID) workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", sessionID)
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"schema":"seriatim-intermediate","segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"schema":"seriatim-intermediate","segments":[]}`)
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "[]\n")
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &stdout, &stderr) code := Execute([]string{"run-stage", "polish", sessionID, "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -268,8 +265,6 @@ seriatim:
binary: seriatim binary: seriatim
audita: audita:
binary: audita binary: audita
analyzer:
timeout: 20m
notification: notification:
timeout: 10s timeout: 10s
` `
@@ -290,7 +285,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -309,17 +304,15 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...) originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath} config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
config.DefaultCampaignConfigSearchPaths = []string{campaignPath}
defer func() { defer func() {
config.DefaultPipelineConfigSearchPaths = originalDefaults config.DefaultPipelineConfigSearchPaths = originalDefaults
config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults
}() }()
_ = campaignPath
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run", "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"run", "2026-05-03", "--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())
} }
@@ -328,30 +321,83 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
} }
} }
func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) { func TestExecuteMissingCampaignConfigReportsRegistryPath(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
missingCampaignPath := filepath.Join(t.TempDir(), "campaign.yml") if err := os.Remove(campaignPath); err != nil {
originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...) t.Fatalf("remove campaign config: %v", err)
config.DefaultCampaignConfigSearchPaths = []string{missingCampaignPath} }
defer func() {
config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults
}()
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"run", "2026-05-03", "--config", pipelinePath, "--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")
} }
if stdout.Len() != 0 { if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty", stdout.String()) t.Fatalf("stdout = %q, want empty", stdout.String())
} }
if !strings.Contains(stderr.String(), "no campaign config path provided and no default campaign config found; searched:") { if !strings.Contains(stderr.String(), "load campaign config") {
t.Fatalf("stderr = %q, want campaign discovery failure", stderr.String()) t.Fatalf("stderr = %q, want campaign discovery failure", stderr.String())
} }
if !strings.Contains(stderr.String(), "pass --campaign") { if !strings.Contains(stderr.String(), filepath.ToSlash(filepath.Join("campaigns", "sample-campaign", "campaign.yml"))) {
t.Fatalf("stderr = %q, want explicit campaign guidance", stderr.String()) t.Fatalf("stderr = %q, want campaign registry path", stderr.String())
}
}
func TestExecuteUsesPipelineDefaultCampaignID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Campaign: sample-campaign") {
t.Fatalf("stdout = %q, want default campaign", stdout.String())
}
}
func TestExecuteCampaignIDSelectsRegistryCampaign(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
campaignRoot := filepath.Dir(filepath.Dir(campaignPath))
otherDir := filepath.Join(campaignRoot, "icewind")
mustWriteTestFile(t, filepath.Join(otherDir, "campaign.yml"), `campaign_id: icewind
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`)
mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", "icewind", "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Campaign: icewind") {
t.Fatalf("stdout = %q, want selected campaign", stdout.String())
}
}
func TestExecuteRejectsCampaignIDAndCampaignFile(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "mutually exclusive") {
t.Fatalf("stderr = %q, want mutually exclusive error", stderr.String())
} }
} }
@@ -396,7 +442,9 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...
dir := t.TempDir() dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml") pipelinePath := filepath.Join(dir, "pipeline.yml")
campaignPath := filepath.Join(dir, "campaign.yml") campaignRoot := filepath.Join(dir, "campaigns")
campaignDir := filepath.Join(campaignRoot, "sample-campaign")
campaignPath := filepath.Join(campaignDir, "campaign.yml")
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
url := "https://example.com/transcribe" url := "https://example.com/transcribe"
if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" { if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" {
@@ -410,6 +458,9 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...
pipelineYAML := `workspace: pipelineYAML := `workspace:
root: ` + workspaceRoot + ` root: ` + workspaceRoot + `
campaigns:
root: ` + campaignRoot + `
default_campaign_id: sample-campaign
cache: cache:
root: ` + filepath.Join(workspaceRoot, "cache") + ` root: ` + filepath.Join(workspaceRoot, "cache") + `
spool: spool:
@@ -435,10 +486,6 @@ seriatim:
report: true report: true
audita: audita:
binary: ` + auditaBinary + ` binary: ` + auditaBinary + `
analyzer:
timeout: 20m
artifacts:
output_dir: artifacts
notification: notification:
timeout: 10s timeout: 10s
` `
@@ -447,7 +494,7 @@ notification:
inputs: inputs:
audio_dir: ./audio audio_dir: ./audio
` `
campaignYAML := `campaign: sample-campaign campaignYAML := `campaign_id: sample-campaign
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml
@@ -457,6 +504,9 @@ inputs:
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline config: %v", err) t.Fatalf("write pipeline config: %v", err)
} }
if err := os.MkdirAll(campaignDir, 0o755); err != nil {
t.Fatalf("create campaign dir: %v", err)
}
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil { if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign config: %v", err) t.Fatalf("write campaign config: %v", err)
} }
@@ -464,9 +514,9 @@ inputs:
t.Fatalf("write session config: %v", err) t.Fatalf("write session config: %v", err)
} }
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n") mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes") mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, campaignPath, sessionPath return pipelinePath, campaignPath, sessionPath
@@ -475,7 +525,7 @@ inputs:
func writeAppTestCampaignConfig(t *testing.T, dir string) string { func writeAppTestCampaignConfig(t *testing.T, dir string) string {
t.Helper() t.Helper()
campaignPath := filepath.Join(dir, "campaign.yml") campaignPath := filepath.Join(dir, "campaign.yml")
campaignYAML := `campaign: sample-campaign campaignYAML := `campaign_id: sample-campaign
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml

View File

@@ -12,18 +12,21 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) { type pipelineCampaignConfig struct {
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag) PipelinePath string
if err != nil { CampaignPath string
return nil, err Pipeline *config.PipelineConfig
} Campaign *config.CampaignConfig
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignFlag) }
func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) {
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" { if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" {
return config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, explicitSession, sessionOpts) return config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, explicitSession, sessionOpts)
} }
discoveredSession, err := discoverSessionConfigPathWithCandidates(config.DefaultSessionConfigSearchPaths) discoveredSession, err := discoverSessionConfigPathWithCandidates(config.DefaultSessionConfigSearchPaths)
@@ -31,30 +34,21 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
return nil, err return nil, err
} }
if discoveredSession.Path != "" { if discoveredSession.Path != "" {
return config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, discoveredSession.Path, sessionOpts) return config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, discoveredSession.Path, sessionOpts)
}
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
if err != nil {
return nil, err
}
campaignCfg, err := config.LoadCampaign(resolvedCampaignPath)
if err != nil {
return nil, err
} }
sessionID := strings.TrimSpace(sessionOpts.SessionID) sessionID := strings.TrimSpace(sessionOpts.SessionID)
if sessionID == "" { if sessionID == "" {
return nil, missingSessionConfigError(discoveredSession.Searched, "remote session loading requires --session-id") return nil, missingSessionConfigError(discoveredSession.Searched, "remote session loading requires a session_id")
} }
sessionPrefix := artifacts.S3SessionPrefix(pipelineCfg.Storage.S3.RootPrefix, campaignCfg.Campaign, sessionID) sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID)
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix) remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
partialCfg := &config.Config{ partialCfg := &config.Config{
Pipeline: pipelineCfg, Pipeline: base.Pipeline,
Campaign: campaignCfg, Campaign: base.Campaign,
PipelinePath: resolvedPipelinePath, PipelinePath: base.PipelinePath,
CampaignPath: resolvedCampaignPath, CampaignPath: base.CampaignPath,
} }
store, err := newCommandObjectStore(ctx, partialCfg, nil) store, err := newCommandObjectStore(ctx, partialCfg, nil)
if err != nil { if err != nil {
@@ -73,22 +67,22 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
if err != nil { if err != nil {
return nil, fmt.Errorf("read downloaded remote session %q: %w", sessionTempPath, err) return nil, fmt.Errorf("read downloaded remote session %q: %w", sessionTempPath, err)
} }
sessionCfg, err := config.LoadSessionBytesWithOptions("s3://"+s3BucketName(pipelineCfg)+"/"+remoteKey, sessionBytes, sessionOpts) sessionCfg, err := config.LoadSessionBytesWithOptions("s3://"+s3BucketName(base.Pipeline)+"/"+remoteKey, sessionBytes, sessionOpts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return config.Resolve( return config.Resolve(
resolvedPipelinePath, base.PipelinePath,
pipelineCfg, base.Pipeline,
resolvedCampaignPath, base.CampaignPath,
campaignCfg, base.Campaign,
sessionTempPath, sessionTempPath,
sessionCfg, sessionCfg,
config.SessionSource{ config.SessionSource{
Source: "session_config.s3", Source: "session_config.s3",
LocalPath: sessionTempPath, LocalPath: sessionTempPath,
S3Bucket: s3BucketName(pipelineCfg), S3Bucket: s3BucketName(base.Pipeline),
S3Key: remoteKey, S3Key: remoteKey,
S3Size: sessionInfo.Size, S3Size: sessionInfo.Size,
S3ETag: sessionInfo.ETag, S3ETag: sessionInfo.ETag,
@@ -97,6 +91,36 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
) )
} }
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) {
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
if err != nil {
return nil, err
}
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
if err != nil {
return nil, err
}
resolvedCampaignPath, err := resolveCampaignConfigPath(pipelineCfg, campaignFlag, campaignFileFlag)
if err != nil {
return nil, err
}
campaignCfg, err := config.LoadCampaign(resolvedCampaignPath)
if err != nil {
return nil, err
}
if selectedID := strings.TrimSpace(campaignFlag); selectedID != "" && strings.TrimSpace(campaignFileFlag) == "" {
if got := config.CampaignID(campaignCfg); got != selectedID {
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID)
}
}
return &pipelineCampaignConfig{
PipelinePath: resolvedPipelinePath,
CampaignPath: resolvedCampaignPath,
Pipeline: pipelineCfg,
Campaign: campaignCfg,
}, nil
}
func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) { func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) {
objects, err := store.List(ctx, sessionPrefix) objects, err := store.List(ctx, sessionPrefix)
if err != nil { if err != nil {

View File

@@ -21,6 +21,7 @@ import (
type commonConfigFlags struct { type commonConfigFlags struct {
pipelinePath string pipelinePath string
campaignPath string campaignPath string
campaignFilePath string
sessionPath string sessionPath string
sessionID string sessionID string
previousSessionID string previousSessionID string
@@ -42,10 +43,10 @@ func (e findingError) Error() string {
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) { func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&flags.campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml") fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
} }
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions { func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
@@ -58,18 +59,42 @@ func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
// Session dispatches session helper subcommands. // Session dispatches session helper subcommands.
func Session(ctx context.Context, args []string, out io.Writer) error { func Session(ctx context.Context, args []string, out io.Writer) error {
if len(args) == 0 { if len(args) == 0 {
return fmt.Errorf("session: expected subcommand: validate|init") return fmt.Errorf("session: expected subcommand: init|validate|status|plan|restore|artifacts|locks")
} }
switch args[0] { switch args[0] {
case "validate":
return SessionValidate(ctx, args[1:], out)
case "init": case "init":
return SessionInit(ctx, args[1:], out) return SessionInit(ctx, args[1:], out)
case "validate":
return SessionValidate(ctx, args[1:], out)
case "status":
return Status(ctx, args[1:], out)
case "plan":
return Plan(ctx, args[1:], out)
case "restore":
return Restore(ctx, args[1:], out)
case "artifacts":
return ArtifactsList(ctx, args[1:], out)
case "locks":
return SessionLocks(ctx, args[1:], out)
default: default:
return fmt.Errorf("session: unknown subcommand %q", args[0]) return fmt.Errorf("session: unknown subcommand %q", args[0])
} }
} }
// SessionLocks dispatches session-oriented archive lock list and mutation
// helpers while preserving the existing lock implementations.
func SessionLocks(ctx context.Context, args []string, out io.Writer) error {
if len(args) > 0 && !isCLIFlagToken(args[0]) {
switch args[0] {
case "add":
return LocksAdd(ctx, args[1:], out)
case "remove":
return LocksRemove(ctx, args[1:], out)
}
}
return LocksList(ctx, args, out)
}
// Artifacts dispatches artifact helper subcommands. // Artifacts dispatches artifact helper subcommands.
func Artifacts(ctx context.Context, args []string, out io.Writer) error { func Artifacts(ctx context.Context, args []string, out io.Writer) error {
if len(args) == 0 { if len(args) == 0 {
@@ -85,6 +110,7 @@ func Artifacts(ctx context.Context, args []string, out io.Writer) error {
// SessionValidate performs a read-only session preflight. // SessionValidate performs a read-only session preflight.
func SessionValidate(ctx context.Context, args []string, out io.Writer) error { func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("session validate", flag.ContinueOnError) fs := flag.NewFlagSet("session validate", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
@@ -92,12 +118,24 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("session validate: invalid flags: %w", err) return fmt.Errorf("session validate: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("session validate: unexpected positional arguments") if err := applyParsedSessionIDArg("session validate", fs, &flags.sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("session validate: unexpected positional arguments")
}
if err := applyPositionalSessionID("session validate", positionalSessionID, &flags.sessionID); err != nil {
return err
}
}
if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("session validate: session_id is required")
} }
findings := []finding{} findings := []finding{}
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions()) cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
findings = append(findings, errorFinding("config", err.Error())) findings = append(findings, errorFinding("config", err.Error()))
return renderFindings(out, "", "", findings) return renderFindings(out, "", "", findings)
@@ -152,27 +190,32 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
return renderFindings(out, cfg.Session.Campaign, cfg.Session.SessionID, findings) return renderFindings(out, cfg.Session.Campaign, cfg.Session.SessionID, findings)
} }
// Status reports either a requested manifest or effective local/remote session state. // Status reports effective local/remote session state.
func Status(ctx context.Context, args []string, out io.Writer) error { func Status(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("status", flag.ContinueOnError) fs := flag.NewFlagSet("status", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var manifestPath string
var flags commonConfigFlags var flags commonConfigFlags
fs.StringVar(&manifestPath, "manifest", "", "path to manifest.json")
addCommonConfigFlags(fs, &flags) addCommonConfigFlags(fs, &flags)
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("status: invalid flags: %w", err) return fmt.Errorf("status: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("status: unexpected positional arguments") if err := applyParsedSessionIDArg("status", fs, &flags.sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("status: unexpected positional arguments")
}
if err := applyPositionalSessionID("status", positionalSessionID, &flags.sessionID); err != nil {
return err
}
} }
if strings.TrimSpace(manifestPath) != "" { if strings.TrimSpace(flags.sessionID) == "" {
return statusManifest(ctx, manifestPath, out) return fmt.Errorf("status: session_id is required")
} }
if flags.pipelinePath == "" && flags.campaignPath == "" && flags.sessionPath == "" && flags.sessionID == "" && flags.previousSessionID == "" { cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
return fmt.Errorf("status: --manifest is required")
}
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
return fmt.Errorf("status: %w", err) return fmt.Errorf("status: %w", err)
} }
@@ -231,36 +274,21 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
writeLocks(out, cfg, locks) writeLocks(out, cfg, locks)
} }
fmt.Fprintln(out, "Next actions:") fmt.Fprintln(out, "Next actions:")
fmt.Fprintf(out, "- narratio session validate --session-id %s\n", cfg.Session.SessionID) fmt.Fprintf(out, "- narratio session validate %s\n", cfg.Session.SessionID)
fmt.Fprintf(out, "- narratio restore --session-id %s --dry-run\n", cfg.Session.SessionID) fmt.Fprintf(out, "- narratio session restore %s --dry-run\n", cfg.Session.SessionID)
return nil
}
func statusManifest(ctx context.Context, manifestPath string, out io.Writer) error {
store := &manifest.LocalStore{}
m, err := store.Load(ctx, manifestPath)
if err != nil {
return fmt.Errorf("status: %w", err)
}
if _, err := fmt.Fprintf(out, "session_id: %s\n", m.SessionID); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "updated_at: %s\n", m.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")); err != nil {
return err
}
writeStageStatuses(out, m)
return nil return nil
} }
// SessionInit creates a local or remote session.yml skeleton. // SessionInit creates a local or remote session.yml skeleton.
func SessionInit(ctx context.Context, args []string, out io.Writer) error { func SessionInit(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("session init", flag.ContinueOnError) fs := flag.NewFlagSet("session init", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath, campaignPath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
var remote, force bool var remote, force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml") fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&sessionID, "session-id", "", "session identifier") fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier") fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier")
fs.StringVar(&date, "date", "", "session date") fs.StringVar(&date, "date", "", "session date")
fs.StringVar(&title, "title", "", "session title") fs.StringVar(&title, "title", "", "session title")
@@ -272,11 +300,20 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("session init: invalid flags: %w", err) return fmt.Errorf("session init: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("session init: unexpected positional arguments") if err := applyParsedSessionIDArg("session init", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("session init: unexpected positional arguments")
}
if err := applyPositionalSessionID("session init", positionalSessionID, &sessionID); err != nil {
return err
}
} }
if strings.TrimSpace(pipelinePath) == "" || strings.TrimSpace(campaignPath) == "" || strings.TrimSpace(sessionID) == "" { if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("session init: --config, --campaign, and --session-id are required") return fmt.Errorf("session init: session_id is required")
} }
if (strings.TrimSpace(output) == "") == !remote { if (strings.TrimSpace(output) == "") == !remote {
return fmt.Errorf("session init: specify exactly one target: --output <path> or --remote") return fmt.Errorf("session init: specify exactly one target: --output <path> or --remote")
@@ -285,24 +322,23 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive") return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
} }
resolvedPipeline, err := resolvePipelineConfigPath(pipelinePath) base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath)
if err != nil {
return fmt.Errorf("session init: %w", err)
}
resolvedCampaign, err := resolveCampaignConfigPath(campaignPath)
if err != nil {
return fmt.Errorf("session init: %w", err)
}
pipelineCfg, err := config.LoadPipeline(resolvedPipeline)
if err != nil {
return fmt.Errorf("session init: %w", err)
}
campaignCfg, err := config.LoadCampaign(resolvedCampaign)
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
data, err := buildSessionYAML(campaignCfg.Campaign, sessionID, previousSessionID, date, title, audioS3Prefix, audioDir) input := sessionInitInput{
Campaign: config.CampaignID(base.Campaign),
CampaignPath: base.CampaignPath,
TemplateFile: base.Campaign.SessionTemplateFile,
SessionID: sessionID,
PreviousSessionID: previousSessionID,
Date: date,
Title: title,
AudioS3Prefix: audioS3Prefix,
AudioDir: audioDir,
}
data, err := buildSessionInitYAML(input)
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
@@ -317,7 +353,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
cfg, err := config.Resolve(resolvedPipeline, pipelineCfg, resolvedCampaign, campaignCfg, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label}) cfg, err := config.Resolve(base.PipelinePath, base.Pipeline, base.CampaignPath, base.Campaign, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
@@ -337,7 +373,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }
sessionPrefix := artifacts.S3SessionPrefix(pipelineCfg.Storage.S3.RootPrefix, campaignCfg.Campaign, sessionID) sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID)
key := artifacts.S3SessionConfigKey(sessionPrefix) key := artifacts.S3SessionConfigKey(sessionPrefix)
exists, err := store.Exists(ctx, key) exists, err := store.Exists(ctx, key)
if err != nil { if err != nil {
@@ -362,12 +398,13 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
if _, err := store.Upload(ctx, tmpPath, key, storage.UploadOptions{ContentType: "application/x-yaml; charset=utf-8"}); err != nil { if _, err := store.Upload(ctx, tmpPath, key, storage.UploadOptions{ContentType: "application/x-yaml; charset=utf-8"}); err != nil {
return fmt.Errorf("session init: upload remote session %q: %w", key, err) return fmt.Errorf("session init: upload remote session %q: %w", key, err)
} }
_, err = fmt.Fprintf(out, "narratio session init: wrote s3://%s/%s\n", s3BucketName(pipelineCfg), key) _, err = fmt.Fprintf(out, "narratio session init: wrote s3://%s/%s\n", s3BucketName(base.Pipeline), key)
return err return err
} }
// ArtifactsList lists effective artifact sources. // ArtifactsList lists effective artifact sources.
func ArtifactsList(ctx context.Context, args []string, out io.Writer) error { func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("artifacts list", flag.ContinueOnError) fs := flag.NewFlagSet("artifacts list", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
@@ -377,8 +414,20 @@ func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("artifacts list: invalid flags: %w", err) return fmt.Errorf("artifacts list: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("artifacts list: unexpected positional arguments") if err := applyParsedSessionIDArg("artifacts list", fs, &flags.sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("artifacts list: unexpected positional arguments")
}
if err := applyPositionalSessionID("artifacts list", positionalSessionID, &flags.sessionID); err != nil {
return err
}
}
if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("artifacts list: session_id is required")
} }
cfg, store, locks, _, err := loadHelperContext(ctx, flags, remote) cfg, store, locks, _, err := loadHelperContext(ctx, flags, remote)
if err != nil { if err != nil {
@@ -413,6 +462,7 @@ func Locks(ctx context.Context, args []string, out io.Writer) error {
// LocksList lists effective archive locks. // LocksList lists effective archive locks.
func LocksList(ctx context.Context, args []string, out io.Writer) error { func LocksList(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("locks", flag.ContinueOnError) fs := flag.NewFlagSet("locks", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
@@ -420,11 +470,20 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("locks: invalid flags: %w", err) return fmt.Errorf("locks: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("locks: unexpected positional arguments") if err := applyParsedSessionIDArg("locks", fs, &flags.sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("locks: unexpected positional arguments")
}
if err := applyPositionalSessionID("locks", positionalSessionID, &flags.sessionID); err != nil {
return err
}
} }
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks: --session-id is required") return fmt.Errorf("locks: session_id is required")
} }
cfg, _, locks, _, err := loadHelperContext(ctx, flags, true) cfg, _, locks, _, err := loadHelperContext(ctx, flags, true)
if err != nil { if err != nil {
@@ -436,6 +495,13 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error {
// LocksAdd adds or updates one remote lock. // LocksAdd adds or updates one remote lock.
func LocksAdd(ctx context.Context, args []string, out io.Writer) error { func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
var positionalSessionID string
var source string
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
positionalSessionID = strings.TrimSpace(args[0])
source = strings.TrimSpace(args[1])
args = append([]string(nil), args[2:]...)
}
fs := flag.NewFlagSet("locks add", flag.ContinueOnError) fs := flag.NewFlagSet("locks add", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
@@ -447,13 +513,21 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("locks add: invalid flags: %w", err) return fmt.Errorf("locks add: invalid flags: %w", err)
} }
if fs.NArg() != 1 { if source == "" {
return fmt.Errorf("locks add: expected exactly one source id") if fs.NArg() != 2 {
return fmt.Errorf("locks add: expected session_id and source id")
}
positionalSessionID = strings.TrimSpace(fs.Arg(0))
source = strings.TrimSpace(fs.Arg(1))
} else if fs.NArg() != 0 {
return fmt.Errorf("locks add: unexpected positional arguments")
}
if err := applyPositionalSessionID("locks add", positionalSessionID, &flags.sessionID); err != nil {
return err
} }
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks add: --session-id is required") return fmt.Errorf("locks add: session_id is required")
} }
source := strings.TrimSpace(fs.Arg(0))
cfg, store, locks, _, err := loadHelperContext(ctx, flags, true) cfg, store, locks, _, err := loadHelperContext(ctx, flags, true)
if err != nil { if err != nil {
return fmt.Errorf("locks add: %w", err) return fmt.Errorf("locks add: %w", err)
@@ -476,12 +550,19 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.ArchiveLockStore{Locks: remoteLocks}); err != nil { if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.ArchiveLockStore{Locks: remoteLocks}); err != nil {
return fmt.Errorf("locks add: %w", err) return fmt.Errorf("locks add: %w", err)
} }
_, err = fmt.Fprintf(out, "narratio locks add: locked %s\n", source) _, err = fmt.Fprintf(out, "narratio session locks add: locked %s\n", source)
return err return err
} }
// LocksRemove removes one remote lock. // LocksRemove removes one remote lock.
func LocksRemove(ctx context.Context, args []string, out io.Writer) error { func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
var positionalSessionID string
var source string
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
positionalSessionID = strings.TrimSpace(args[0])
source = strings.TrimSpace(args[1])
args = append([]string(nil), args[2:]...)
}
fs := flag.NewFlagSet("locks remove", flag.ContinueOnError) fs := flag.NewFlagSet("locks remove", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
@@ -489,13 +570,21 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("locks remove: invalid flags: %w", err) return fmt.Errorf("locks remove: invalid flags: %w", err)
} }
if fs.NArg() != 1 { if source == "" {
return fmt.Errorf("locks remove: expected exactly one source id") if fs.NArg() != 2 {
return fmt.Errorf("locks remove: expected session_id and source id")
}
positionalSessionID = strings.TrimSpace(fs.Arg(0))
source = strings.TrimSpace(fs.Arg(1))
} else if fs.NArg() != 0 {
return fmt.Errorf("locks remove: unexpected positional arguments")
}
if err := applyPositionalSessionID("locks remove", positionalSessionID, &flags.sessionID); err != nil {
return err
} }
if strings.TrimSpace(flags.sessionID) == "" { if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks remove: --session-id is required") return fmt.Errorf("locks remove: session_id is required")
} }
source := strings.TrimSpace(fs.Arg(0))
cfg, store, locks, _, err := loadHelperContext(ctx, flags, true) cfg, store, locks, _, err := loadHelperContext(ctx, flags, true)
if err != nil { if err != nil {
return fmt.Errorf("locks remove: %w", err) return fmt.Errorf("locks remove: %w", err)
@@ -515,12 +604,12 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.ArchiveLockStore{Locks: remoteLocks}); err != nil { if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.ArchiveLockStore{Locks: remoteLocks}); err != nil {
return fmt.Errorf("locks remove: %w", err) return fmt.Errorf("locks remove: %w", err)
} }
_, err = fmt.Fprintf(out, "narratio locks remove: unlocked %s\n", source) _, err = fmt.Fprintf(out, "narratio session locks remove: unlocked %s\n", source)
return err return err
} }
func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore bool) (*config.Config, storage.ObjectStore, *effectiveLocks, *manifest.Manifest, error) { func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore bool) (*config.Config, storage.ObjectStore, *effectiveLocks, *manifest.Manifest, error) {
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions()) cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil { if err != nil {
return nil, nil, nil, nil, err return nil, nil, nil, nil, err
} }
@@ -600,6 +689,104 @@ func buildSessionYAML(campaign, sessionID, previousSessionID, date, title, audio
return data, nil return data, nil
} }
type sessionInitInput struct {
Campaign string
CampaignPath string
TemplateFile string
SessionID string
PreviousSessionID string
Date string
Title string
AudioS3Prefix string
AudioDir string
}
func buildSessionInitYAML(in sessionInitInput) ([]byte, error) {
if strings.TrimSpace(in.TemplateFile) == "" {
return buildSessionYAML(in.Campaign, in.SessionID, in.PreviousSessionID, in.Date, in.Title, in.AudioS3Prefix, in.AudioDir)
}
templatePath := resolveSessionInitTemplatePath(in.CampaignPath, in.TemplateFile)
templateBytes, err := os.ReadFile(templatePath)
if err != nil {
return nil, fmt.Errorf("read session template %q: %w", templatePath, err)
}
rendered, err := renderSessionInitTemplate(string(templateBytes), in)
if err != nil {
return nil, fmt.Errorf("render session template %q: %w", templatePath, err)
}
return []byte(rendered), nil
}
func resolveSessionInitTemplatePath(campaignPath, templateFile string) string {
templateFile = strings.TrimSpace(templateFile)
if filepath.IsAbs(templateFile) {
return filepath.Clean(templateFile)
}
return filepath.Clean(filepath.Join(filepath.Dir(campaignPath), templateFile))
}
var sessionInitTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
func renderSessionInitTemplate(content string, in sessionInitInput) (string, error) {
values := map[string]string{
"session_id": strings.TrimSpace(in.SessionID),
"previous_session_id": strings.TrimSpace(in.PreviousSessionID),
"date": strings.TrimSpace(in.Date),
"title": strings.TrimSpace(in.Title),
"audio_s3_prefix": strings.TrimSpace(in.AudioS3Prefix),
"audio_dir": strings.TrimSpace(in.AudioDir),
}
used := map[string]struct{}{}
unknown := map[string]struct{}{}
missing := map[string]struct{}{}
rendered := sessionInitTemplatePattern.ReplaceAllStringFunc(content, func(match string) string {
parts := sessionInitTemplatePattern.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
name := parts[1]
value, ok := values[name]
if !ok {
unknown[name] = struct{}{}
return match
}
used[name] = struct{}{}
if value == "" {
missing[name] = struct{}{}
return match
}
return value
})
if len(unknown) > 0 {
return "", fmt.Errorf("unsupported template variable(s): %s", sortedStringSet(unknown))
}
if len(missing) > 0 {
return "", fmt.Errorf("missing required template variable value(s): %s", sortedStringSet(missing))
}
unused := map[string]struct{}{}
for _, name := range []string{"previous_session_id", "date", "title", "audio_s3_prefix", "audio_dir"} {
if values[name] == "" {
continue
}
if _, ok := used[name]; !ok {
unused[name] = struct{}{}
}
}
if len(unused) > 0 {
return "", fmt.Errorf("unused template variable value(s): %s", sortedStringSet(unused))
}
return rendered, nil
}
func sortedStringSet(set map[string]struct{}) string {
items := make([]string, 0, len(set))
for item := range set {
items = append(items, item)
}
sort.Strings(items)
return strings.Join(items, ", ")
}
func renderFindings(out io.Writer, campaign, sessionID string, findings []finding) error { func renderFindings(out io.Writer, campaign, sessionID string, findings []finding) error {
if campaign != "" || sessionID != "" { if campaign != "" || sessionID != "" {
fmt.Fprintf(out, "Campaign: %s\n", campaign) fmt.Fprintf(out, "Campaign: %s\n", campaign)
@@ -797,10 +984,10 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
lockSet := lockSourceSet(locks.All) lockSet := lockSourceSet(locks.All)
fmt.Fprintln(out, "Built-in:") fmt.Fprintln(out, "Built-in:")
for _, id := range []string{ for _, id := range []string{
artifacts.ArtifactTranscriptMerged, artifacts.ArtifactTranscriptBase,
artifacts.ArtifactTranscriptPolished, artifacts.ArtifactTranscriptPolished,
artifacts.ArtifactTranscriptFull, artifacts.ArtifactTranscriptFinal,
artifacts.ArtifactTranscriptTrimmed, artifacts.ArtifactTranscriptFinalTrimmed,
artifacts.ArtifactBoundsSession, artifacts.ArtifactBoundsSession,
} { } {
writeArtifactLine(out, id, lockSet) writeArtifactLine(out, id, lockSet)

View File

@@ -25,10 +25,9 @@ func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{ code := Execute([]string{
"session", "init", "session", "init", "2026-06-07",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session-id", "2026-06-07",
"--title", "The Black Cabin", "--title", "The Black Cabin",
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -48,6 +47,363 @@ func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) {
} }
} }
func TestExecuteSessionInitRemoteUsesDefaultConfigDiscovery(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
key := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07"))
if _, ok := fake.Objects[key]; !ok {
t.Fatalf("remote session key %q not uploaded; objects=%v", key, fake.Objects)
}
if storeInitCalls != 1 {
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
}
}
func TestExecuteSessionInitLocalUsesDefaultConfigDiscovery(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
outputPath := filepath.Join(t.TempDir(), "session.yml")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init", "2026-06-07",
"--output", outputPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read generated session: %v", err)
}
if !strings.Contains(string(data), `session_id: "2026-06-07"`) || !strings.Contains(string(data), "prefix: audio/") {
t.Fatalf("generated session = %q", string(data))
}
}
func TestExecuteSessionInitExplicitConfigWinsOverDefaults(t *testing.T) {
workspaceRoot := t.TempDir()
defaultPipeline, defaultCampaign, _ := writeValidConfigFiles(t, workspaceRoot)
withDefaultPipelineCampaignConfigs(t, defaultPipeline, defaultCampaign)
explicitDir := t.TempDir()
explicitCampaign := filepath.Join(explicitDir, "campaign.yml")
if err := os.WriteFile(explicitCampaign, []byte(`campaign_id: explicit-campaign
inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`), 0o644); err != nil {
t.Fatalf("write explicit campaign: %v", err)
}
mustWriteTestFile(t, filepath.Join(explicitDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n")
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init", "2026-06-07",
"--config", defaultPipeline,
"--campaign-file", explicitCampaign,
"--remote",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
explicitKey := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "explicit-campaign", "2026-06-07"))
if _, ok := fake.Objects[explicitKey]; !ok {
t.Fatalf("explicit campaign remote key %q not uploaded; objects=%v", explicitKey, fake.Objects)
}
defaultKey := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07"))
if _, ok := fake.Objects[defaultKey]; ok {
t.Fatalf("default campaign key %q uploaded despite explicit campaign override", defaultKey)
}
}
func TestExecuteSessionInitRequiresSessionID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "init", "--remote"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "session init: session_id is required") {
t.Fatalf("stderr = %q, want session-id required error", stderr.String())
}
}
func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T) {
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")}
t.Cleanup(func() {
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "session init: no pipeline config path provided and no default pipeline config found; searched:") {
t.Fatalf("stderr = %q, want default pipeline searched-path error", stderr.String())
}
}
func TestExecuteSessionInitRemoteLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
accessKeyEnv := "NARRATIO_TEST_SESSION_INIT_OBJECT_KEY_ID"
secretKeyEnv := "NARRATIO_TEST_SESSION_INIT_OBJECT_SECRET"
restoreEnvAfterTest(t, accessKeyEnv, secretKeyEnv)
secretsDir := t.TempDir()
mustWriteTestFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-key-id\n")
mustWriteTestFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret\n")
addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv)
fake := &storage.FakeBackend{}
origStoreFn := newObjectStoreFromConfigFn
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
if os.Getenv(accessKeyEnv) != "test-key-id" || os.Getenv(secretKeyEnv) != "test-secret" {
return nil, fmt.Errorf("secrets were not loaded before object store init")
}
return fake, nil
}
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
}
func TestExecuteSessionInitLocalRendersCampaignTemplate(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}"
date: "{{ date }}"
title: "{{ title }}"
inputs:
audio_s3:
prefix: "{{ audio_s3_prefix }}"
`)
outputPath := filepath.Join(t.TempDir(), "session.yml")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--previous-session-id", "2026-05-31",
"--date", "2026-06-07",
"--title", "The Black Cabin",
"--audio-s3-prefix", "audio/",
"--output", outputPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read generated session: %v", err)
}
got := string(data)
for _, want := range []string{
`session_id: "2026-06-07"`,
`previous_session_id: "2026-05-31"`,
`date: "2026-06-07"`,
`title: "The Black Cabin"`,
`prefix: "audio/"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("generated session = %q, want %q", got, want)
}
}
if strings.Contains(got, "{{") {
t.Fatalf("generated session still contains template placeholder: %q", got)
}
}
func TestExecuteSessionInitRemoteRendersCampaignTemplate(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
inputs:
audio_s3:
prefix: audio/
`)
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--remote",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
key := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07"))
obj, ok := fake.Objects[key]
if !ok {
t.Fatalf("remote session key %q not uploaded; objects=%v", key, fake.Objects)
}
if strings.Contains(string(obj.Data), "{{") || !strings.Contains(string(obj.Data), `session_id: "2026-06-07"`) {
t.Fatalf("remote session data = %q, want rendered concrete session", string(obj.Data))
}
}
func TestExecuteSessionInitTemplatePathIsCampaignRelative(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
templateDir := filepath.Join(filepath.Dir(campaignPath), "templates")
if err := os.MkdirAll(templateDir, 0o755); err != nil {
t.Fatalf("mkdir template dir: %v", err)
}
templatePath := filepath.Join(templateDir, "session.template.yml")
if err := os.WriteFile(templatePath, []byte(`session_id: "{{ session_id }}"
inputs:
audio_dir: ./audio
`), 0o644); err != nil {
t.Fatalf("write session template: %v", err)
}
addSessionTemplateToCampaign(t, campaignPath, "./templates/session.template.yml")
outputPath := filepath.Join(t.TempDir(), "session.yml")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--output", outputPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read generated session: %v", err)
}
if !strings.Contains(string(data), `session_id: "2026-06-07"`) {
t.Fatalf("generated session = %q, want campaign-relative template output", string(data))
}
}
func TestExecuteSessionInitTemplateMissingVariableFails(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
date: "{{ date }}"
inputs:
audio_s3:
prefix: audio/
`)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--remote",
}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "missing required template variable value(s): date") {
t.Fatalf("stderr = %q, want missing date variable", stderr.String())
}
}
func TestExecuteSessionInitTemplateUnusedFlagFails(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
inputs:
audio_s3:
prefix: audio/
`)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--title", "Unused Title",
"--remote",
}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "unused template variable value(s): title") {
t.Fatalf("stderr = %q, want unused title variable", stderr.String())
}
}
func TestExecuteSessionInitTemplateStrictDecodeFailure(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
unknown: true
inputs:
audio_s3:
prefix: audio/
`)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--remote",
}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "strict decode failed") {
t.Fatalf("stderr = %q, want strict decode error", stderr.String())
}
}
func TestExecuteSessionValidateLoadsSecretsBeforeObjectStoreInit(t *testing.T) { func TestExecuteSessionValidateLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -82,7 +438,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"session", "validate", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
} }
@@ -101,13 +457,11 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{ code := Execute([]string{
"locks", "add", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
"--reason", "manual edit", "--reason", "manual edit",
"narratio.transcript.trimmed",
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -117,35 +471,32 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
if !ok { if !ok {
t.Fatalf("remote locks key %q not uploaded", key) t.Fatalf("remote locks key %q not uploaded", key)
} }
if !strings.Contains(string(obj.Data), "source: narratio.transcript.trimmed") || !strings.Contains(string(obj.Data), "reason: manual edit") { if !strings.Contains(string(obj.Data), "source: narratio.transcript.final_trimmed") || !strings.Contains(string(obj.Data), "reason: manual edit") {
t.Fatalf("lock store data = %q", string(obj.Data)) t.Fatalf("lock store data = %q", string(obj.Data))
} }
stdout.Reset() stdout.Reset()
stderr.Reset() stderr.Reset()
code = Execute([]string{ code = Execute([]string{
"locks", "session", "locks", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("locks list exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("locks list exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
if !strings.Contains(stdout.String(), "- narratio.transcript.trimmed origin=remote") { if !strings.Contains(stdout.String(), "- narratio.transcript.final_trimmed origin=remote") {
t.Fatalf("stdout = %q, want remote lock", stdout.String()) t.Fatalf("stdout = %q, want remote lock", stdout.String())
} }
stdout.Reset() stdout.Reset()
stderr.Reset() stderr.Reset()
code = Execute([]string{ code = Execute([]string{
"locks", "remove", "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
"narratio.transcript.trimmed",
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -172,13 +523,11 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{ code := Execute([]string{
"locks", "add", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
"--reason", "first", "--reason", "first",
"narratio.transcript.trimmed",
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("initial locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("initial locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -187,13 +536,11 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
stdout.Reset() stdout.Reset()
stderr.Reset() stderr.Reset()
code = Execute([]string{ code = Execute([]string{
"locks", "add", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
"--reason", "second", "--reason", "second",
"narratio.transcript.trimmed",
}, &stdout, &stderr) }, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("duplicate locks add exit code = 0, want non-zero") t.Fatal("duplicate locks add exit code = 0, want non-zero")
@@ -205,14 +552,12 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
stdout.Reset() stdout.Reset()
stderr.Reset() stderr.Reset()
code = Execute([]string{ code = Execute([]string{
"locks", "add", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
"--reason", "second", "--reason", "second",
"--force", "--force",
"narratio.transcript.trimmed",
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("forced locks add exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("forced locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -229,9 +574,9 @@ func TestExecuteLocksRequireSessionID(t *testing.T) {
args []string args []string
want string want string
}{ }{
{"list", []string{"locks"}, "locks: --session-id is required"}, {"list", []string{"session", "locks"}, "locks: session_id is required"},
{"add", []string{"locks", "add", "narratio.transcript.trimmed"}, "locks add: --session-id is required"}, {"add", []string{"session", "locks", "add", "narratio.transcript.final_trimmed"}, "locks add: expected session_id and source id"},
{"remove", []string{"locks", "remove", "narratio.transcript.trimmed"}, "locks remove: --session-id is required"}, {"remove", []string{"session", "locks", "remove", "narratio.transcript.final_trimmed"}, "locks remove: expected session_id and source id"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -251,7 +596,7 @@ func TestExecuteLocksRequireSessionID(t *testing.T) {
func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) { func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addStaticArchiveLockToPipelineConfig(t, pipelinePath, "narratio.transcript.trimmed") addStaticArchiveLockToPipelineConfig(t, pipelinePath, "narratio.transcript.final_trimmed")
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
var storeInitCalls int var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
@@ -259,12 +604,10 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{ code := Execute([]string{
"locks", "add", "session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
"narratio.transcript.trimmed",
}, &stdout, &stderr) }, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("locks add static lock exit code = 0, want non-zero") t.Fatal("locks add static lock exit code = 0, want non-zero")
@@ -276,12 +619,10 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
stdout.Reset() stdout.Reset()
stderr.Reset() stderr.Reset()
code = Execute([]string{ code = Execute([]string{
"locks", "remove", "session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
"narratio.transcript.trimmed",
}, &stdout, &stderr) }, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("locks remove static lock exit code = 0, want non-zero") t.Fatal("locks remove static lock exit code = 0, want non-zero")
@@ -297,7 +638,7 @@ func TestExecuteTopLevelLockAndUnlockAreRemoved(t *testing.T) {
t.Run(cmd, func(t *testing.T) { t.Run(cmd, func(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{cmd, "narratio.transcript.trimmed"}, &stdout, &stderr) code := Execute([]string{cmd, "narratio.transcript.final_trimmed"}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -308,19 +649,53 @@ func TestExecuteTopLevelLockAndUnlockAreRemoved(t *testing.T) {
} }
} }
func withDefaultPipelineCampaignConfigs(t *testing.T, pipelinePath, campaignPath string) {
t.Helper()
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
t.Cleanup(func() {
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
})
_ = campaignPath
}
func writeSessionInitTemplate(t *testing.T, campaignPath, templateYAML string) {
t.Helper()
templatePath := filepath.Join(filepath.Dir(campaignPath), "session.template.yml")
if err := os.WriteFile(templatePath, []byte(templateYAML), 0o644); err != nil {
t.Fatalf("write session template: %v", err)
}
addSessionTemplateToCampaign(t, campaignPath, "./session.template.yml")
}
func addSessionTemplateToCampaign(t *testing.T, campaignPath, templateFile string) {
t.Helper()
data, err := os.ReadFile(campaignPath)
if err != nil {
t.Fatalf("read campaign config: %v", err)
}
if strings.Contains(string(data), "session_template_file:") {
t.Fatalf("campaign config already has session_template_file: %q", string(data))
}
updated := "session_template_file: " + templateFile + "\n" + string(data)
if err := os.WriteFile(campaignPath, []byte(updated), 0o644); err != nil {
t.Fatalf("write campaign config: %v", err)
}
}
func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) { func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addArchivePromotionsToPipeline(t, pipelinePath, ` addArchivePromotionsToPipeline(t, pipelinePath, `
promote_artifacts: promote_artifacts:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/trimmed.json dest: transcripts/final.trimmed.json
required: true required: true
`) `)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
trimmedKey := artifacts.S3PromotedArtifactKey( trimmedKey := artifacts.S3PromotedArtifactKey(
artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"),
"transcripts/trimmed.json", "transcripts/final.trimmed.json",
) )
fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte(`{"segments":[]}`)}) fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte(`{"segments":[]}`)})
var storeInitCalls int var storeInitCalls int
@@ -329,16 +704,16 @@ func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{ code := Execute([]string{
"artifacts", "list", "session", "artifacts", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
if !strings.Contains(stdout.String(), "narratio.transcript.trimmed remote=promoted") { if !strings.Contains(stdout.String(), "narratio.transcript.final_trimmed remote=promoted") {
t.Fatalf("stdout = %q, want promoted remote availability", stdout.String()) t.Fatalf("stdout = %q, want promoted remote availability", stdout.String())
} }
} }
@@ -348,7 +723,7 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addArchivePromotionsToPipeline(t, pipelinePath, ` addArchivePromotionsToPipeline(t, pipelinePath, `
promote_artifacts: promote_artifacts:
- source: narratio.transcript.full - source: narratio.transcript.final
dest: transcripts/full.json dest: transcripts/full.json
required: true required: true
- source: narratio.bounds.session - source: narratio.bounds.session
@@ -365,9 +740,9 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{ code := Execute([]string{
"artifacts", "list", "session", "artifacts", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--remote", "--remote",
}, &stdout, &stderr) }, &stdout, &stderr)
@@ -376,7 +751,7 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
} }
out := stdout.String() out := stdout.String()
for _, unwanted := range []string{ for _, unwanted := range []string{
"narratio.transcript.full remote=missing", "narratio.transcript.final remote=missing",
"narratio.bounds.session remote=missing", "narratio.bounds.session remote=missing",
} { } {
if strings.Contains(out, unwanted) { if strings.Contains(out, unwanted) {
@@ -384,7 +759,7 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
} }
} }
for _, want := range []string{ for _, want := range []string{
"narratio.transcript.full dest=transcripts/full.json remote=promoted", "narratio.transcript.final dest=transcripts/full.json remote=promoted",
"narratio.bounds.session dest=transcripts/bounds.json remote=promoted", "narratio.bounds.session dest=transcripts/bounds.json remote=promoted",
} { } {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
@@ -398,35 +773,34 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addArchivePromotionsToPipeline(t, pipelinePath, ` addArchivePromotionsToPipeline(t, pipelinePath, `
promote_artifacts: promote_artifacts:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/trimmed.json dest: transcripts/final.trimmed.json
required: true required: true
- source: narratio.transcript.full - source: narratio.transcript.final
dest: transcripts/full.json dest: transcripts/full.json
required: true required: true
`) `)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03") sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix) manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
trimmedKey := artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/trimmed.json") trimmedKey := artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/final.trimmed.json")
fullKey := artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/full.json") fullKey := artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/full.json")
lockKey := artifacts.S3SessionLocksKey(sessionPrefix) lockKey := artifacts.S3SessionLocksKey(sessionPrefix)
fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")}) fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")}) fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")})
fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte(`{"segments":[]}`)}) fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte(`{"segments":[]}`)})
fake.SeedObject(storage.FakeObject{Key: fullKey, Data: []byte(`{"segments":[]}`)}) fake.SeedObject(storage.FakeObject{Key: fullKey, Data: []byte(`{"segments":[]}`)})
fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.trimmed\n reason: remote review\n")}) fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.final_trimmed\n reason: remote review\n")})
var storeInitCalls int var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{ code := Execute([]string{
"status", "session", "status", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -438,15 +812,15 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
"Configured:", "Configured:",
"Previous-session:", "Previous-session:",
"Promoted:", "Promoted:",
"narratio.transcript.trimmed locked", "narratio.transcript.final_trimmed locked",
"narratio.transcript.trimmed locked remote=promoted", "narratio.transcript.final_trimmed locked remote=promoted",
"narratio.transcript.full dest=transcripts/full.json remote=promoted", "narratio.transcript.final dest=transcripts/full.json remote=promoted",
} { } {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Fatalf("stdout = %q, want %q", out, want) t.Fatalf("stdout = %q, want %q", out, want)
} }
} }
if strings.Contains(out, "narratio.transcript.merged remote=missing") { if strings.Contains(out, "narratio.transcript.base remote=missing") {
t.Fatalf("stdout = %q, did not want catalog remote marker", out) t.Fatalf("stdout = %q, did not want catalog remote marker", out)
} }
} }
@@ -456,8 +830,8 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
addArchivePromotionsToPipeline(t, pipelinePath, ` addArchivePromotionsToPipeline(t, pipelinePath, `
promote_artifacts: promote_artifacts:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/trimmed.json dest: transcripts/final.trimmed.json
required: true required: true
`) `)
fake := &storage.FakeBackend{ExistsErr: fmt.Errorf("exists failed")} fake := &storage.FakeBackend{ExistsErr: fmt.Errorf("exists failed")}
@@ -467,11 +841,10 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{ code := Execute([]string{
"status", "session", "status", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
}, &stdout, &stderr) }, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -480,7 +853,7 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
if !strings.Contains(out, "Remote archive: missing or unavailable:") { if !strings.Contains(out, "Remote archive: missing or unavailable:") {
t.Fatalf("stdout = %q, want remote archive unavailable state", out) t.Fatalf("stdout = %q, want remote archive unavailable state", out)
} }
if !strings.Contains(out, "Remote outputs:") || !strings.Contains(out, "narratio.transcript.trimmed remote=error") { if !strings.Contains(out, "Remote outputs:") || !strings.Contains(out, "narratio.transcript.final_trimmed remote=error") {
t.Fatalf("stdout = %q, want remote output error state", out) t.Fatalf("stdout = %q, want remote output error state", out)
} }
if !strings.Contains(out, "Archive locks: error:") { if !strings.Contains(out, "Archive locks: error:") {
@@ -493,7 +866,7 @@ func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidArchiveConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidArchiveConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
lockKey := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")) lockKey := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"))
fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.trimmed\n reason: remote review\n")}) fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.final_trimmed\n reason: remote review\n")})
var storeInitCalls int var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
@@ -502,15 +875,15 @@ func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) {
// The archive stage only checks the manifest statuses and source files. // The archive stage only checks the manifest statuses and source files.
_ = stageName _ = stageName
} }
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "trimmed.json"), `{"segments":[]}`) mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "archive"}, &stdout, &stderr) code := Execute([]string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
promotedKey := artifacts.S3PromotedArtifactKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "transcripts/trimmed.json") promotedKey := artifacts.S3PromotedArtifactKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "transcripts/final.trimmed.json")
if _, ok := fake.Objects[promotedKey]; ok { if _, ok := fake.Objects[promotedKey]; ok {
t.Fatalf("locked promoted key %q was uploaded", promotedKey) t.Fatalf("locked promoted key %q was uploaded", promotedKey)
} }

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -16,29 +17,43 @@ import (
// Plan validates configuration, prepares the local workdir, and prints stage order. // Plan validates configuration, prepares the local workdir, and prints stage order.
func Plan(ctx context.Context, args []string, out io.Writer) error { func Plan(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("plan", flag.ContinueOnError) fs := flag.NewFlagSet("plan", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("plan: invalid flags: %w", err) return fmt.Errorf("plan: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("plan: unexpected positional arguments") if err := applyParsedSessionIDArg("plan", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("plan: unexpected positional arguments")
}
if err := applyPositionalSessionID("plan", positionalSessionID, &sessionID); err != nil {
return err
}
} }
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{ if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("plan: session_id is required")
}
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })
@@ -68,7 +83,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
runCount := 0 runCount := 0
skipCount := 0 skipCount := 0
if _, err := fmt.Fprintf(out, "narratio plan: workdir prepared at %s\n", paths.Root); err != nil { if _, err := fmt.Fprintf(out, "narratio session plan: workdir prepared at %s\n", paths.Root); err != nil {
return err return err
} }
for _, d := range decisions { for _, d := range decisions {

View File

@@ -18,13 +18,13 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer var out bytes.Buffer
args := []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath} args := []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}
if err := Plan(context.Background(), args, &out); err != nil { if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("first Plan() error = %v", err) t.Fatalf("first Plan() error = %v", err)
} }
got := out.String() got := out.String()
if !strings.Contains(got, "narratio plan: workdir prepared at") { if !strings.Contains(got, "narratio session plan: workdir prepared at") {
t.Fatalf("first output = %q, want workdir prepared", got) t.Fatalf("first output = %q, want workdir prepared", got)
} }
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} { for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} {
@@ -55,7 +55,7 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
if err := Plan(context.Background(), args, &out); err != nil { if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("second Plan() error = %v", err) t.Fatalf("second Plan() error = %v", err)
} }
if !strings.Contains(out.String(), "narratio plan: workdir prepared at") { if !strings.Contains(out.String(), "narratio session plan: workdir prepared at") {
t.Fatalf("second output = %q, want workdir prepared", out.String()) t.Fatalf("second output = %q, want workdir prepared", out.String())
} }
} }
@@ -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, "--campaign", campaignPath, "--session", sessionPath}, &out); err != nil { if err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out); err != nil {
t.Fatalf("Plan() error = %v", err) t.Fatalf("Plan() error = %v", err)
} }
got := out.String() got := out.String()
@@ -108,8 +108,6 @@ seriatim:
binary: seriatim binary: seriatim
audita: audita:
binary: audita binary: audita
analyzer:
timeout: 20m
notification: notification:
timeout: 10s timeout: 10s
` `
@@ -129,7 +127,7 @@ inputs:
} }
var out bytes.Buffer var out bytes.Buffer
err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }

View File

@@ -197,7 +197,7 @@ func TestPostArchiveCleanupNotRunWhenPromotionIsMissing(t *testing.T) {
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
cfg.Pipeline.Workspace.CleanupAfterArchive = true cfg.Pipeline.Workspace.CleanupAfterArchive = true
cfg.Pipeline.Archive.PromoteArtifacts = []config.ArchivePromotionRule{ cfg.Pipeline.Archive.PromoteArtifacts = []config.ArchivePromotionRule{
{Source: "narratio.transcript.merged", Dest: "transcripts/merged.json", Required: boolPtr(true)}, {Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(true)},
} }
archiveStageImpl, err := stage.Select("archive") archiveStageImpl, err := stage.Select("archive")
@@ -333,7 +333,7 @@ func archiveStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
Enabled: boolPtr(true), Enabled: boolPtr(true),
UploadRun: boolPtr(true), UploadRun: boolPtr(true),
PromoteArtifacts: []config.ArchivePromotionRule{ PromoteArtifacts: []config.ArchivePromotionRule{
{Source: "narratio.transcript.trimmed", Dest: "transcripts/trimmed.json", Required: boolPtr(true)}, {Source: "narratio.transcript.final_trimmed", Dest: "transcripts/final.trimmed.json", Required: boolPtr(true)},
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(true)}, {Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
}, },
} }
@@ -371,14 +371,14 @@ func writeArchiveFixtureRunFiles(t *testing.T, runWorkDir, sessionRoot string) {
t.Helper() t.Helper()
mustWriteFile(t, filepath.Join(runWorkDir, "prepare", "inputs", "session.yml"), "session_id: 2026-05-03\n") mustWriteFile(t, filepath.Join(runWorkDir, "prepare", "inputs", "session.yml"), "session_id: 2026-05-03\n")
mustWriteFile(t, filepath.Join(runWorkDir, "transcribe", "outputs", "transcripts", "raw", "speaker.json"), "{}\n") mustWriteFile(t, filepath.Join(runWorkDir, "transcribe", "outputs", "transcripts", "raw", "speaker.json"), "{}\n")
mustWriteFile(t, filepath.Join(runWorkDir, "trim", "outputs", "transcripts", "trimmed.json"), "{\"segments\":[]}\n") mustWriteFile(t, filepath.Join(runWorkDir, "trim", "outputs", "transcripts", "final.trimmed.json"), "{\"segments\":[]}\n")
mustWriteFile(t, filepath.Join(runWorkDir, "analyze", "outputs", "artifacts", "session_recap.md"), "# recap\n") mustWriteFile(t, filepath.Join(runWorkDir, "analyze", "outputs", "artifacts", "session_recap.md"), "# recap\n")
mustWriteFile(t, filepath.Join(runWorkDir, "polish", "reports", "audita.report.json"), "{}\n") mustWriteFile(t, filepath.Join(runWorkDir, "polish", "reports", "audita.report.json"), "{}\n")
mustWriteFile(t, filepath.Join(runWorkDir, "merge", "config", "seriatim.generated.yml"), "key: value\n") mustWriteFile(t, filepath.Join(runWorkDir, "merge", "config", "seriatim.generated.yml"), "key: value\n")
mustWriteFile(t, filepath.Join(runWorkDir, "logs", "audita.stderr.log"), "stderr\n") mustWriteFile(t, filepath.Join(runWorkDir, "logs", "audita.stderr.log"), "stderr\n")
mustWriteFile(t, filepath.Join(runWorkDir, "manifest.json"), "{}\n") mustWriteFile(t, filepath.Join(runWorkDir, "manifest.json"), "{}\n")
mustWriteFile(t, filepath.Join(sessionRoot, "transcripts", "trimmed.json"), "{\"segments\":[]}\n") mustWriteFile(t, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json"), "{\"segments\":[]}\n")
mustWriteFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n") mustWriteFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
} }

View File

@@ -19,7 +19,7 @@ func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
remoteKey := seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}" remoteKey := seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
inputs: inputs:
audio_s3: audio_s3:
prefix: audio/ prefix: audio/
@@ -29,14 +29,14 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr) code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
if storeInitCalls != 1 { if storeInitCalls != 1 {
t.Fatalf("object store init calls = %d, want 1", storeInitCalls) t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
} }
if !strings.Contains(stdout.String(), "narratio plan: workdir prepared") { if !strings.Contains(stdout.String(), "narratio session plan: workdir prepared") {
t.Fatalf("stdout = %q, want plan output", stdout.String()) t.Fatalf("stdout = %q, want plan output", stdout.String())
} }
if _, ok := fake.Objects[remoteKey]; !ok { if _, ok := fake.Objects[remoteKey]; !ok {
@@ -56,7 +56,7 @@ func TestExecuteRemoteSessionFallbackLoadsSecretsBeforeObjectStoreInit(t *testin
addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv) addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}" seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
inputs: inputs:
audio_s3: audio_s3:
prefix: audio/ prefix: audio/
@@ -77,7 +77,7 @@ inputs:
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr) code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String()) t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
} }
@@ -92,7 +92,7 @@ func TestExecuteExplicitLocalSessionPrecedenceSkipsRemote(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03"}, &stdout, &stderr) code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -110,7 +110,7 @@ func TestExecuteLocalSessionDiscoveryPrecedenceSkipsRemote(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr) code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -129,7 +129,7 @@ func TestExecuteRemoteSessionMissingObjectFailsClearly(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr) code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -149,12 +149,12 @@ func TestExecuteRemoteSessionRequiresSessionID(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr) code := Execute([]string{"session", "plan", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
if !strings.Contains(stderr.String(), "remote session loading requires --session-id") { if !strings.Contains(stderr.String(), "plan: session_id is required") {
t.Fatalf("stderr = %q, want session-id guidance", stderr.String()) t.Fatalf("stderr = %q, want session_id guidance", stderr.String())
} }
if storeInitCalls != 0 { if storeInitCalls != 0 {
t.Fatalf("object store init calls = %d, want 0", storeInitCalls) t.Fatalf("object store init calls = %d, want 0", storeInitCalls)
@@ -177,7 +177,7 @@ func TestExecuteRemoteSessionStorageInitErrorFailsClearly(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr) code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -196,7 +196,7 @@ func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr) code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -205,6 +205,48 @@ func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) {
} }
} }
func TestExecuteRemoteSessionTemplateFailsConcreteSessionCheck(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}"
inputs:
audio_s3:
prefix: audio/
`)
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "session.yml must be concrete") || !strings.Contains(stderr.String(), "run narratio session init") {
t.Fatalf("stderr = %q, want concrete session guidance", stderr.String())
}
}
func TestExecuteRemoteSessionMismatchFails(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
seedRemoteSessionConfig(t, fake, "2026-05-03", "session_id: 2026-05-04\ninputs:\n audio_s3:\n prefix: audio/\n")
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "session_id mismatch") {
t.Fatalf("stderr = %q, want session_id mismatch", stderr.String())
}
}
func restoreAppConfigTestGlobals(t *testing.T, fake *storage.FakeBackend, storeInitCalls *int, sessionDefaults []string) { func restoreAppConfigTestGlobals(t *testing.T, fake *storage.FakeBackend, storeInitCalls *int, sessionDefaults []string) {
t.Helper() t.Helper()
origStoreFn := newObjectStoreFromConfigFn origStoreFn := newObjectStoreFromConfigFn

View File

@@ -8,6 +8,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
@@ -22,11 +23,13 @@ var executeRestorePlanFn = executeRestorePlan
// Restore validates restore CLI/config inputs and storage preflight for future restore phases. // Restore validates restore CLI/config inputs and storage preflight for future restore phases.
func Restore(ctx context.Context, args []string, out io.Writer) error { func Restore(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("restore", flag.ContinueOnError) fs := flag.NewFlagSet("restore", flag.ContinueOnError)
fs.SetOutput(out) fs.SetOutput(out)
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
@@ -34,15 +37,15 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
var force bool var force bool
var includeAudio bool var includeAudio bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files") fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files")
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state") fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects") fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
fs.Usage = func() { fs.Usage = func() {
_, _ = fmt.Fprintln(out, "Usage: narratio restore [--config <path>] [--campaign <path>] [--session <path>] [--session-id <value>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]") _, _ = fmt.Fprintln(out, "Usage: narratio session restore <session_id> [--config <path>] [--campaign <id>] [--campaign-file <path>] [--session <path>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]")
_, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintln(out, "Flags:") _, _ = fmt.Fprintln(out, "Flags:")
fs.PrintDefaults() fs.PrintDefaults()
@@ -54,10 +57,22 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
} }
return fmt.Errorf("restore: invalid flags: %w", err) return fmt.Errorf("restore: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("restore: unexpected positional arguments") if err := applyParsedSessionIDArg("restore", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("restore: unexpected positional arguments")
}
if err := applyPositionalSessionID("restore", positionalSessionID, &sessionID); err != nil {
return err
}
} }
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{ if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("restore: session_id is required")
}
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })

View File

@@ -32,7 +32,7 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -74,7 +74,7 @@ func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -100,7 +100,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -115,7 +115,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
} }
stdout.Reset() stdout.Reset()
stderr.Reset() stderr.Reset()
code = Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr) code = Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -128,23 +128,29 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) { func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`)) seedRestorePreviousCurrent(t, fake, cfg, "# previous recap\n")
seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
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, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "manifest.json"), `{"session_id":"2026-04-26"}`) previousManifestBytes, err := os.ReadFile(filepath.Join(sessionRoot, "previous", "manifest.json"))
if err != nil {
t.Fatalf("read restored previous manifest: %v", err)
}
if !strings.Contains(string(previousManifestBytes), `"session_id":"2026-04-26"`) {
t.Fatalf("restored previous manifest = %q, want previous session id", string(previousManifestBytes))
}
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n") mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json")) report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
if report.Execution.Downloaded != 3 { if report.Execution.Downloaded != 3 {
@@ -152,6 +158,33 @@ func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
} }
} }
func TestExecuteRestoreDryRunReportsPreviousCacheWithoutWriting(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
fake := &storage.FakeBackend{}
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestorePreviousCurrent(t, fake, cfg, "# previous recap\n")
restoreWithStoreAndRealPhases(t, fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "previous/artifacts/session_recap.md") {
t.Fatalf("stdout = %q, want planned previous-cache artifact", stdout.String())
}
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
if _, err := os.Stat(filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md")); !os.IsNotExist(err) {
t.Fatalf("previous artifact should not be written during dry-run; stat err=%v", err)
}
}
func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) { func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -167,7 +200,7 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -199,7 +232,7 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -213,10 +246,11 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) { func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
fake := &storage.FakeBackend{} fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath) cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# remote previous recap\n")) seedRestorePreviousCurrent(t, fake, cfg, "# remote previous recap\n")
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n") mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
@@ -225,7 +259,7 @@ func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
@@ -251,7 +285,7 @@ func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -298,7 +332,7 @@ func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -401,6 +435,50 @@ func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipeline
return cfg, sessionPrefix, manifestKey, runIDKey return cfg, sessionPrefix, manifestKey, runIDKey
} }
func appendRestoreWorkflowPreviousInputConfig(t *testing.T, pipelinePath, sessionPath string) {
t.Helper()
appendRestoreWorkflowScriptoriumConfig(t, pipelinePath, `
scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
previous_recap:
source: narratio.previous_session.artifact.session_recap
required: true
`)
appendRestoreWorkflowScriptoriumConfig(t, sessionPath, `
previous_session_id: 2026-04-26
`)
}
func seedRestorePreviousCurrent(t *testing.T, fake *storage.FakeBackend, cfg *config.Config, artifactBody string) {
t.Helper()
seedRestorePreviousCurrentManifestOnly(t, fake, cfg)
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
seedRestoreObject(fake, previousPrefix+"artifacts/session_recap.md", []byte(artifactBody))
}
func seedRestorePreviousCurrentManifestOnly(t *testing.T, fake *storage.FakeBackend, cfg *config.Config) {
t.Helper()
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(previousPrefix)
previousRunID := "20260426T010203Z-a1b2c3d4"
seedRestoreObject(fake, runIDKey, []byte(previousRunID+"\n"))
m := manifest.New(cfg.Session.PreviousSessionID, nowUTC())
m.Campaign = cfg.Session.Campaign
m.RunID = previousRunID
data, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal previous restore manifest: %v", err)
}
seedRestoreObject(fake, manifestKey, append(data, '\n'))
}
func mustReadEquals(t *testing.T, path, want string) { func mustReadEquals(t *testing.T, path, want string) {
t.Helper() t.Helper()
data, err := os.ReadFile(path) data, err := os.ReadFile(path)

View File

@@ -13,6 +13,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/previouscache"
) )
// RestoreActionKind identifies one restore planner action. // RestoreActionKind identifies one restore planner action.
@@ -114,6 +115,12 @@ func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCu
actions = append(actions, action) actions = append(actions, action)
} }
previousActions, err := buildPreviousCacheRestoreActions(ctx, cfg, sessionPaths, store, opts.Force)
if err != nil {
return nil, err
}
actions = append(actions, previousActions...)
sort.Slice(actions, func(i, j int) bool { sort.Slice(actions, func(i, j int) bool {
if actions[i].LocalRelativePath == actions[j].LocalRelativePath { if actions[i].LocalRelativePath == actions[j].LocalRelativePath {
return actions[i].RemoteKey < actions[j].RemoteKey return actions[i].RemoteKey < actions[j].RemoteKey
@@ -195,7 +202,7 @@ func restoreLocalRelativePathForKey(sessionPrefix, currentManifestKey, key strin
return cleanRel, true, nil return cleanRel, true, nil
} }
if cleanRel == config.PathPreviousDirSegment || strings.HasPrefix(cleanRel, config.PathPreviousDirSegment+"/") { if cleanRel == config.PathPreviousDirSegment || strings.HasPrefix(cleanRel, config.PathPreviousDirSegment+"/") {
return cleanRel, true, nil return "", false, nil
} }
if includeAudio && (cleanRel == config.PathAudioDirSegment || strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/")) { if includeAudio && (cleanRel == config.PathAudioDirSegment || strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/")) {
return cleanRel, true, nil return cleanRel, true, nil
@@ -223,6 +230,35 @@ func joinWithinSessionRoot(sessionRoot, relative string) (string, error) {
return abs, nil return abs, nil
} }
func buildPreviousCacheRestoreActions(
ctx context.Context,
cfg *config.Config,
sessionPaths artifacts.SessionPaths,
store storage.ObjectStore,
force bool,
) ([]RestoreAction, error) {
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Scriptorium == nil {
return nil, nil
}
requirements := artifacts.CollectPreviousArtifactRequirements(cfg.Pipeline.Scriptorium.Artifacts)
if len(requirements) == 0 {
return nil, nil
}
plan, err := previouscache.BuildPlan(ctx, cfg, sessionPaths, requirements, store)
if err != nil {
return nil, fmt.Errorf("plan previous-session cache restore: %w", err)
}
actions := make([]RestoreAction, 0, len(plan.Records))
for _, record := range plan.Records {
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{Key: record.RemoteKey}, record.LocalRelativePath, record.LocalPath, force)
if err != nil {
return nil, fmt.Errorf("classify previous-session cache object %q: %w", record.RemoteKey, err)
}
actions = append(actions, action)
}
return actions, nil
}
func classifyRestoreAction( func classifyRestoreAction(
ctx context.Context, ctx context.Context,
store storage.ObjectStore, store storage.ObjectStore,

View File

@@ -86,6 +86,27 @@ func TestRestorePlanExistingAudioUsesSizeWithoutRemoteChecksumDownload(t *testin
} }
func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) { func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) {
cfg := restorePlanConfig(t)
configureRestorePlanPreviousRequirement(cfg, true)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestorePreviousCurrent(t, store, cfg, "# previous recap\n")
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
if err != nil {
t.Fatalf("buildRestorePlan() error = %v", err)
}
got := actionRelPaths(plan.Actions)
want := []string{"manifest.json", "previous/artifacts/session_recap.md", "previous/manifest.json"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("action local paths = %#v, want %#v", got, want)
}
}
func TestRestorePlanIgnoresCurrentSessionArchivedPreviousCache(t *testing.T) {
cfg := restorePlanConfig(t) cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg) current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{} store := &storage.FakeBackend{}
@@ -100,12 +121,76 @@ func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) {
} }
got := actionRelPaths(plan.Actions) got := actionRelPaths(plan.Actions)
want := []string{"manifest.json", "previous/artifacts/session_recap.md", "previous/manifest.json"} want := []string{"manifest.json"}
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Fatalf("action local paths = %#v, want %#v", got, want) t.Fatalf("action local paths = %#v, want %#v", got, want)
} }
} }
func TestRestorePlanMissingOptionalPreviousCacheSkipsArtifact(t *testing.T) {
cfg := restorePlanConfig(t)
configureRestorePlanPreviousRequirement(cfg, false)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
if err != nil {
t.Fatalf("buildRestorePlan() error = %v", err)
}
got := actionRelPaths(plan.Actions)
want := []string{"manifest.json", "previous/manifest.json"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("action local paths = %#v, want %#v", got, want)
}
}
func TestRestorePlanMissingRequiredPreviousCacheFails(t *testing.T) {
cfg := restorePlanConfig(t)
configureRestorePlanPreviousRequirement(cfg, true)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
if err == nil || !strings.Contains(err.Error(), "required previous-session artifact") {
t.Fatalf("buildRestorePlan() error = %v, want required previous artifact failure", err)
}
}
func TestRestorePlanPreviousCacheConflictRequiresForce(t *testing.T) {
cfg := restorePlanConfig(t)
configureRestorePlanPreviousRequirement(cfg, true)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestorePreviousCurrent(t, store, cfg, "# remote previous recap\n")
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
if err != nil {
t.Fatalf("buildRestorePlan() error = %v", err)
}
if plan.ConflictCount != 1 {
t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount)
}
plan, err = buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
if err != nil {
t.Fatalf("buildRestorePlan(force) error = %v", err)
}
if plan.ConflictCount != 0 {
t.Fatalf("force ConflictCount = %d, want 0", plan.ConflictCount)
}
}
func TestRestorePlanClassifiesSameAndConflict(t *testing.T) { func TestRestorePlanClassifiesSameAndConflict(t *testing.T) {
cfg := restorePlanConfig(t) cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg) current := restorePlanCurrentState(t, cfg)
@@ -207,6 +292,10 @@ func restorePlanConfig(t *testing.T) *config.Config {
return &config.Config{ return &config.Config{
Pipeline: &config.PipelineConfig{ Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: workspaceRoot}, Workspace: config.WorkspaceConfig{Root: workspaceRoot},
Storage: config.StorageConfig{S3: &config.StorageS3Config{
Bucket: "test-bucket",
RootPrefix: "dnd",
}},
}, },
Session: &config.SessionConfig{ Session: &config.SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",
@@ -215,6 +304,24 @@ func restorePlanConfig(t *testing.T) *config.Config {
} }
} }
func configureRestorePlanPreviousRequirement(cfg *config.Config, required bool) {
cfg.Session.PreviousSessionID = "2026-04-26"
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
OutputPath: "artifacts/session_recap.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"previous_recap": {
Source: "narratio.previous_session.artifact.session_recap",
Required: required,
},
},
},
},
}
}
func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState { func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState {
t.Helper() t.Helper()
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)

View File

@@ -18,7 +18,7 @@ func TestExecuteRestoreHelp(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--help"}, &stdout, &stderr) code := Execute([]string{"session", "restore", "--help"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0", code) t.Fatalf("exit code = %d, want 0", code)
} }
@@ -26,7 +26,7 @@ func TestExecuteRestoreHelp(t *testing.T) {
t.Fatalf("stderr = %q, want empty", stderr.String()) t.Fatalf("stderr = %q, want empty", stderr.String())
} }
out := stdout.String() out := stdout.String()
if !strings.Contains(out, "Usage: narratio restore") { if !strings.Contains(out, "Usage: narratio session restore <session_id>") {
t.Fatalf("stdout = %q, want restore usage", out) t.Fatalf("stdout = %q, want restore usage", out)
} }
if !strings.Contains(out, "--include-audio") { if !strings.Contains(out, "--include-audio") {
@@ -79,11 +79,10 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( code := Execute(
[]string{ []string{
"restore", "session", "restore", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
"--dry-run", "--dry-run",
"--force", "--force",
"--include-audio", "--include-audio",
@@ -124,7 +123,7 @@ func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -146,7 +145,7 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -174,7 +173,7 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -263,11 +262,10 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute( code := Execute(
[]string{ []string{
"restore", "session", "restore", "2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
"--dry-run", "--dry-run",
}, },
&stdout, &stdout,
@@ -315,7 +313,7 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 { if code == 0 {
t.Fatal("exit code = 0, want non-zero") t.Fatal("exit code = 0, want non-zero")
} }
@@ -367,7 +365,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr) code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }

View File

@@ -46,11 +46,10 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
var stderr bytes.Buffer var stderr bytes.Buffer
restoreCode := Execute( restoreCode := Execute(
[]string{ []string{
"restore", "session", "restore", cfg.Session.SessionID,
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", cfg.Session.SessionID,
}, },
&stdout, &stdout,
&stderr, &stderr,
@@ -85,14 +84,12 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
stderr.Reset() stderr.Reset()
runStageCode := Execute( runStageCode := Execute(
[]string{ []string{
"run-stage", "run-stage", "analyze", cfg.Session.SessionID,
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", cfg.Session.SessionID,
"--force", "--force",
"--artifacts", "player_handout", "--artifacts", "player_handout",
"analyze",
}, },
&stdout, &stdout,
&stderr, &stderr,
@@ -170,20 +167,22 @@ scriptorium:
output_path: artifacts/session_recap.md output_path: artifacts/session_recap.md
inputs: inputs:
transcript: transcript:
source: narratio.transcript.trimmed source: narratio.transcript.final_trimmed
required: true required: true
previous_recap: previous_recap:
source: narratio.previous_session.artifact.session_recap source: narratio.previous_session.artifact.session_recap
required: true required: true
`)
appendRestoreWorkflowScriptoriumConfig(t, sessionPath, `
previous_session_id: 2026-04-26
`) `)
fakeStore := &storage.FakeBackend{} fakeStore := &storage.FakeBackend{}
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fakeStore, pipelinePath, campaignPath, 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/final.trimmed.json", []byte(`{"segments":[]}`+"\n"))
seedRestoreObject(fakeStore, sessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`)) seedRestorePreviousCurrent(t, fakeStore, cfg, "# previous recap\n")
seedRestoreObject(fakeStore, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
restoreWithStoreAndRealPhases(t, fakeStore) restoreWithStoreAndRealPhases(t, fakeStore)
@@ -191,11 +190,10 @@ scriptorium:
var stderr bytes.Buffer var stderr bytes.Buffer
restoreCode := Execute( restoreCode := Execute(
[]string{ []string{
"restore", "session", "restore", cfg.Session.SessionID,
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", cfg.Session.SessionID,
}, },
&stdout, &stdout,
&stderr, &stderr,
@@ -208,8 +206,14 @@ scriptorium:
} }
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID) sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "trimmed.json"), `{"segments":[]}`+"\n") mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`+"\n")
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "manifest.json"), `{"session_id":"2026-04-26"}`) previousManifestBytes, err := os.ReadFile(filepath.Join(sessionRoot, "previous", "manifest.json"))
if err != nil {
t.Fatalf("read restored previous manifest: %v", err)
}
if !strings.Contains(string(previousManifestBytes), `"session_id":"2026-04-26"`) {
t.Fatalf("restored previous manifest = %q, want previous session id", string(previousManifestBytes))
}
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n") mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
scriptoriumFake := &scriptorium.FakeRunner{} scriptoriumFake := &scriptorium.FakeRunner{}
@@ -236,14 +240,12 @@ scriptorium:
stderr.Reset() stderr.Reset()
runStageCode := Execute( runStageCode := Execute(
[]string{ []string{
"run-stage", "run-stage", "analyze", cfg.Session.SessionID,
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", cfg.Session.SessionID,
"--force", "--force",
"--artifacts", "session_recap", "--artifacts", "session_recap",
"analyze",
}, },
&stdout, &stdout,
&stderr, &stderr,
@@ -261,7 +263,7 @@ scriptorium:
t.Fatalf("scriptorium run requests = %d, want 1", len(scriptoriumFake.RunRequests)) t.Fatalf("scriptorium run requests = %d, want 1", len(scriptoriumFake.RunRequests))
} }
req := scriptoriumFake.RunRequests[0] req := scriptoriumFake.RunRequests[0]
if got := req.InputPaths["transcript"]; got != filepath.Join(sessionRoot, "transcripts", "trimmed.json") { if got := req.InputPaths["transcript"]; got != filepath.Join(sessionRoot, "transcripts", "final.trimmed.json") {
t.Fatalf("transcript input = %q, want trimmed transcript path", got) t.Fatalf("transcript input = %q, want trimmed transcript path", got)
} }
if got := req.InputPaths["previous_recap"]; got != filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md") { if got := req.InputPaths["previous_recap"]; got != filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md") {

View File

@@ -5,6 +5,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -13,31 +14,45 @@ import (
// Resume continues execution from the first non-succeeded stage in the manifest. // Resume continues execution from the first non-succeeded stage in the manifest.
func Resume(ctx context.Context, args []string, out io.Writer) error { func Resume(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("resume", flag.ContinueOnError) fs := flag.NewFlagSet("resume", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution") fs.BoolVar(&force, "force", false, "force stage execution")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("resume: invalid flags: %w", err) return fmt.Errorf("resume: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("resume: unexpected positional arguments") if err := applyParsedSessionIDArg("resume", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("resume: unexpected positional arguments")
}
if err := applyPositionalSessionID("resume", positionalSessionID, &sessionID); err != nil {
return err
}
} }
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{ if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("resume: session_id is required")
}
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })
@@ -51,7 +66,7 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("resume: invalid --artifacts: %w", err) return fmt.Errorf("resume: invalid --artifacts: %w", err)
} }
if err := validateSelectedAnalyzeArtifacts(cfg, normalizedArtifacts); err != nil { if err := validateSelectedArtifacts(cfg, normalizedArtifacts); err != nil {
return fmt.Errorf("resume: %w", err) return fmt.Errorf("resume: %w", err)
} }

View File

@@ -32,7 +32,7 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer var out bytes.Buffer
err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Resume() error = %v", err)
} }
@@ -64,7 +64,7 @@ func TestResumeNoRemainingStages(t *testing.T) {
} }
var out bytes.Buffer var out bytes.Buffer
err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Resume() error = %v", err)
} }
@@ -93,7 +93,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
} }
var out bytes.Buffer var out bytes.Buffer
err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out) err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
if err != nil { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Resume() error = %v", err)
} }
@@ -107,11 +107,11 @@ func TestRunStageExecutesOnlySelectedStage(t *testing.T) {
pipelinePath, campaignPath, 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", "base.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, "--campaign", campaignPath, "--session", sessionPath, "polish"}, &out) err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage() error = %v", err) t.Fatalf("RunStage() error = %v", err)
} }
@@ -137,7 +137,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
pipelinePath, campaignPath, 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", "base.json"), `{"segments":[]}`)
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
@@ -148,7 +148,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
} }
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, &out) err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage() error = %v", err) t.Fatalf("RunStage() error = %v", err)
} }
@@ -157,7 +157,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
} }
out.Reset() out.Reset()
err = RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &out) err = RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage(force) error = %v", err) t.Fatalf("RunStage(force) error = %v", err)
} }
@@ -171,7 +171,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
pipelinePath, campaignPath, 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", "base.json"), `{"segments":[]}`)
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
@@ -184,7 +184,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
} }
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &out) err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage(force) error = %v", err) t.Fatalf("RunStage(force) error = %v", err)
} }
@@ -203,7 +203,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
} }
out.Reset() out.Reset()
err = Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out) err = Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Resume() error = %v", err)
} }
@@ -217,10 +217,10 @@ func TestRunStageTrimExecutes(t *testing.T) {
pipelinePath, campaignPath, 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", "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "trim"}, &out) err := RunStage(context.Background(), []string{"trim", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage(trim) error = %v", err) t.Fatalf("RunStage(trim) error = %v", err)
} }
@@ -246,10 +246,10 @@ func TestRunStageNormalizeExecutes(t *testing.T) {
pipelinePath, campaignPath, 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", "polished.json"), `{"segments":[{"id":1},{"id":2}]}`)
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "normalize"}, &out) err := RunStage(context.Background(), []string{"normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage(normalize) error = %v", err) t.Fatalf("RunStage(normalize) error = %v", err)
} }

View File

@@ -5,37 +5,52 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
// Run executes the pipeline plan and persists manifest state. // Run executes the pipeline plan and persists manifest state.
func Run(ctx context.Context, args []string, out io.Writer) error { func Run(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("run", flag.ContinueOnError) fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("run: invalid flags: %w", err) return fmt.Errorf("run: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("run: unexpected positional arguments") if err := applyParsedSessionIDArg("run", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("run: unexpected positional arguments")
}
if err := applyPositionalSessionID("run", positionalSessionID, &sessionID); err != nil {
return err
}
} }
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{ if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("run: session_id is required")
}
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
}) })
@@ -49,7 +64,7 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
if err != nil { if err != nil {
return fmt.Errorf("run: invalid --artifacts: %w", err) return fmt.Errorf("run: invalid --artifacts: %w", err)
} }
if err := validateSelectedAnalyzeArtifacts(cfg, normalizedArtifacts); err != nil { if err := validateSelectedArtifacts(cfg, normalizedArtifacts); err != nil {
return fmt.Errorf("run: %w", err) return fmt.Errorf("run: %w", err)
} }

View File

@@ -5,43 +5,66 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
// RunStage executes exactly one selected stage. // RunStage executes exactly one selected stage.
func RunStage(ctx context.Context, args []string, out io.Writer) error { func RunStage(ctx context.Context, args []string, out io.Writer) error {
var stageName string
var positionalSessionID string
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
stageName = strings.TrimSpace(args[0])
positionalSessionID = strings.TrimSpace(args[1])
args = append([]string(nil), args[2:]...)
}
fs := flag.NewFlagSet("run-stage", flag.ContinueOnError) fs := flag.NewFlagSet("run-stage", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var force bool var force bool
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)") fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute or publish (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("run-stage: invalid flags: %w", err) return fmt.Errorf("run-stage: invalid flags: %w", err)
} }
if fs.NArg() != 1 { if stageName == "" {
return fmt.Errorf("run-stage: expected exactly one stage name") switch fs.NArg() {
case 2:
stageName = strings.TrimSpace(fs.Arg(0))
positionalSessionID = strings.TrimSpace(fs.Arg(1))
default:
return fmt.Errorf("run-stage: expected stage name and session_id")
}
} else if fs.NArg() != 0 {
return fmt.Errorf("run-stage: unexpected positional arguments")
}
if err := applyPositionalSessionID("run-stage", positionalSessionID, &sessionID); err != nil {
return err
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("run-stage: session_id is required")
} }
normalizedArtifacts, err := selectedArtifacts.Normalize() normalizedArtifacts, err := selectedArtifacts.Normalize()
if err != nil { if err != nil {
return fmt.Errorf("run-stage: invalid --artifacts: %w", err) return fmt.Errorf("run-stage: invalid --artifacts: %w", err)
} }
stageName := fs.Arg(0) if len(normalizedArtifacts) > 0 && stageName != "analyze" && stageName != "archive" {
if len(normalizedArtifacts) > 0 && stageName != "analyze" { return fmt.Errorf("run-stage: --artifacts is only supported for stages \"analyze\" and \"archive\"")
return fmt.Errorf("run-stage: --artifacts is only supported for stage \"analyze\"")
} }
summary, err := runSingleStageCommand(ctx, singleStageCommand{ summary, err := runSingleStageCommand(ctx, singleStageCommand{
@@ -49,6 +72,7 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
StageName: stageName, StageName: stageName,
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
CampaignFilePath: campaignFilePath,
SessionPath: sessionPath, SessionPath: sessionPath,
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
@@ -73,27 +97,41 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
// Analyze force-runs the analyze stage. // Analyze force-runs the analyze stage.
func Analyze(ctx context.Context, args []string, out io.Writer) error { func Analyze(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("analyze", flag.ContinueOnError) fs := flag.NewFlagSet("analyze", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath string var pipelinePath string
var campaignPath string var campaignPath string
var campaignFilePath string
var sessionPath string var sessionPath string
var sessionID string var sessionID string
var previousSessionID string var previousSessionID string
var selectedArtifacts artifactSelectionFlag var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)") fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml") fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates") fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates") fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute during analyze (comma-separated or repeatable)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return fmt.Errorf("analyze: invalid flags: %w", err) return fmt.Errorf("analyze: invalid flags: %w", err)
} }
if fs.NArg() != 0 { if positionalSessionID == "" {
return fmt.Errorf("analyze: unexpected positional arguments") if err := applyParsedSessionIDArg("analyze", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("analyze: unexpected positional arguments")
}
if err := applyPositionalSessionID("analyze", positionalSessionID, &sessionID); err != nil {
return err
}
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("analyze: session_id is required")
} }
normalizedArtifacts, err := selectedArtifacts.Normalize() normalizedArtifacts, err := selectedArtifacts.Normalize()
if err != nil { if err != nil {
@@ -105,6 +143,7 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
StageName: "analyze", StageName: "analyze",
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
CampaignFilePath: campaignFilePath,
SessionPath: sessionPath, SessionPath: sessionPath,
SessionID: sessionID, SessionID: sessionID,
PreviousSessionID: previousSessionID, PreviousSessionID: previousSessionID,
@@ -125,11 +164,81 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
return err return err
} }
// Publish force-runs the archive stage.
func Publish(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("publish", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var pipelinePath string
var campaignPath string
var campaignFilePath string
var sessionPath string
var sessionID string
var previousSessionID string
var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to publish (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("publish: invalid flags: %w", err)
}
if positionalSessionID == "" {
if err := applyParsedSessionIDArg("publish", fs, &sessionID); err != nil {
return err
}
} else {
if fs.NArg() != 0 {
return fmt.Errorf("publish: unexpected positional arguments")
}
if err := applyPositionalSessionID("publish", positionalSessionID, &sessionID); err != nil {
return err
}
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("publish: session_id is required")
}
normalizedArtifacts, err := selectedArtifacts.Normalize()
if err != nil {
return fmt.Errorf("publish: invalid --artifacts: %w", err)
}
summary, err := runSingleStageCommand(ctx, singleStageCommand{
CommandName: "publish",
StageName: "archive",
PipelinePath: pipelinePath,
CampaignPath: campaignPath,
CampaignFilePath: campaignFilePath,
SessionPath: sessionPath,
SessionID: sessionID,
PreviousSessionID: previousSessionID,
Force: true,
SelectedArtifacts: normalizedArtifacts,
})
if err != nil {
return err
}
_, err = fmt.Fprintf(
out,
"narratio publish: executed=%d skipped=%d force=true; manifest=%s\n",
len(summary.Executed),
len(summary.Skipped),
summary.ManifestPath,
)
return err
}
type singleStageCommand struct { type singleStageCommand struct {
CommandName string CommandName string
StageName string StageName string
PipelinePath string PipelinePath string
CampaignPath string CampaignPath string
CampaignFilePath string
SessionPath string SessionPath string
SessionID string SessionID string
PreviousSessionID string PreviousSessionID string
@@ -143,7 +252,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
return nil, fmt.Errorf("%s: %w", req.CommandName, err) return nil, fmt.Errorf("%s: %w", req.CommandName, err)
} }
cfg, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.SessionPath, config.SessionLoadOptions{ cfg, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{
SessionID: req.SessionID, SessionID: req.SessionID,
PreviousSessionID: req.PreviousSessionID, PreviousSessionID: req.PreviousSessionID,
}) })
@@ -153,7 +262,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
if err := config.Validate(cfg); err != nil { if err := config.Validate(cfg); err != nil {
return nil, fmt.Errorf("%s: %w", req.CommandName, err) return nil, fmt.Errorf("%s: %w", req.CommandName, err)
} }
if err := validateSelectedAnalyzeArtifacts(cfg, req.SelectedArtifacts); err != nil { if err := validateSelectedArtifacts(cfg, req.SelectedArtifacts); err != nil {
return nil, fmt.Errorf("%s: %w", req.CommandName, err) return nil, fmt.Errorf("%s: %w", req.CommandName, err)
} }

View File

@@ -46,7 +46,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
if env.Config == nil { if env.Config == nil {
env.Config = cfg env.Config = cfg
} }
env.SelectedAnalyzeArtifacts = append([]string(nil), opts.SelectedArtifacts...) env.SelectedArtifactKeys = append([]string(nil), opts.SelectedArtifacts...)
if env.ArtifactStore == nil { if env.ArtifactStore == nil {
env.ArtifactStore = artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root) env.ArtifactStore = artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
} }

View File

@@ -54,7 +54,7 @@ func (s captureSelectedArtifactsStage) Name() string { return s.name }
func (s captureSelectedArtifactsStage) Declares() stage.IODecl { return stage.IODecl{} } func (s captureSelectedArtifactsStage) Declares() stage.IODecl { return stage.IODecl{} }
func (s captureSelectedArtifactsStage) Run(_ context.Context, env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) { func (s captureSelectedArtifactsStage) Run(_ context.Context, env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
if s.captured != nil { if s.captured != nil {
*s.captured = append((*s.captured)[:0], env.SelectedAnalyzeArtifacts...) *s.captured = append((*s.captured)[:0], env.SelectedArtifactKeys...)
} }
return &stage.StageResult{Metadata: map[string]any{"captured": true}}, nil return &stage.StageResult{Metadata: map[string]any{"captured": true}}, nil
} }
@@ -78,12 +78,12 @@ type selectedAnalyzeArtifactStage struct {
func (s selectedAnalyzeArtifactStage) Name() string { return "analyze" } func (s selectedAnalyzeArtifactStage) Name() string { return "analyze" }
func (s selectedAnalyzeArtifactStage) Declares() stage.IODecl { return stage.IODecl{} } func (s selectedAnalyzeArtifactStage) Declares() stage.IODecl { return stage.IODecl{} }
func (s selectedAnalyzeArtifactStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) { func (s selectedAnalyzeArtifactStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
if len(env.SelectedAnalyzeArtifacts) != len(s.expected) { if len(env.SelectedArtifactKeys) != len(s.expected) {
return nil, fmt.Errorf("selected artifacts len = %d, want %d", len(env.SelectedAnalyzeArtifacts), len(s.expected)) return nil, fmt.Errorf("selected artifacts len = %d, want %d", len(env.SelectedArtifactKeys), len(s.expected))
} }
for i := range s.expected { for i := range s.expected {
if env.SelectedAnalyzeArtifacts[i] != s.expected[i] { if env.SelectedArtifactKeys[i] != s.expected[i] {
return nil, fmt.Errorf("selected artifacts[%d] = %q, want %q", i, env.SelectedAnalyzeArtifacts[i], s.expected[i]) return nil, fmt.Errorf("selected artifacts[%d] = %q, want %q", i, env.SelectedArtifactKeys[i], s.expected[i])
} }
} }
@@ -248,7 +248,7 @@ func TestNeedsObjectStoreForRunPrepareWithPreviousRequirements(t *testing.T) {
} }
} }
func TestExecuteStagesArchiveFailsWhenRequiredRecapPromotionMissingForSelectedArtifacts(t *testing.T) { func TestExecuteStagesArchiveSkipsRequiredUnselectedConfiguredPromotion(t *testing.T) {
cfg := testConfig(t) cfg := testConfig(t)
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{ cfg.Pipeline.Storage.S3 = &config.StorageS3Config{
Bucket: "my-dnd-archive", Bucket: "my-dnd-archive",
@@ -288,7 +288,7 @@ func TestExecuteStagesArchiveFailsWhenRequiredRecapPromotionMissingForSelectedAr
t.Fatalf("Select(archive) error = %v", err) t.Fatalf("Select(archive) error = %v", err)
} }
_, err = executeStages( summary, err := executeStages(
context.Background(), context.Background(),
cfg, cfg,
[]stage.Stage{ []stage.Stage{
@@ -300,11 +300,28 @@ func TestExecuteStagesArchiveFailsWhenRequiredRecapPromotionMissingForSelectedAr
Env: &Env{ObjectStore: &storage.FakeBackend{}}, Env: &Env{ObjectStore: &storage.FakeBackend{}},
}, },
) )
if err == nil { if err != nil {
t.Fatal("expected archive promotion failure, got nil") t.Fatalf("executeStages() error = %v", err)
} }
if !strings.Contains(err.Error(), "required promotion source unavailable") { if len(summary.Executed) != 2 || summary.Executed[0] != "analyze" || summary.Executed[1] != "archive" {
t.Fatalf("error = %q, want required promotion source unavailable", err.Error()) t.Fatalf("executed = %#v, want analyze and archive", summary.Executed)
}
loadedManifest, err := store.Load(context.Background(), summary.ManifestPath)
if err != nil {
t.Fatalf("Load manifest error = %v", err)
}
meta := loadedManifest.Stages["archive"].Metadata
skipped, ok := meta["skipped_unselected_promotions"].([]any)
if !ok || len(skipped) != 1 {
t.Fatalf("skipped_unselected_promotions = %#v, want one item", meta["skipped_unselected_promotions"])
}
item, ok := skipped[0].(map[string]any)
if !ok {
t.Fatalf("skipped item = %#v, want object", skipped[0])
}
if item["source"] != "narratio.artifact.session_recap" || item["dest"] != "artifacts/session_recap.md" || item["required"] != true {
t.Fatalf("skipped item = %#v, want required session_recap promotion", item)
} }
} }
@@ -744,7 +761,7 @@ func TestExecuteStagesRunLocalArtifactsAndCanonicalPromotion(t *testing.T) {
filepath.Join(runRoot, "merge", "logs", "seriatim.stdout.log"), filepath.Join(runRoot, "merge", "logs", "seriatim.stdout.log"),
filepath.Join(runRoot, "polish", "config", "audita.generated.yml"), filepath.Join(runRoot, "polish", "config", "audita.generated.yml"),
filepath.Join(runRoot, "normalize", "logs", "seriatim.normalize.stdout.log"), filepath.Join(runRoot, "normalize", "logs", "seriatim.normalize.stdout.log"),
filepath.Join(runRoot, "trim", "outputs", "transcripts", "trimmed.json"), filepath.Join(runRoot, "trim", "outputs", "transcripts", "final.trimmed.json"),
} }
for _, p := range runLocalChecks { for _, p := range runLocalChecks {
if _, statErr := os.Stat(p); statErr != nil { if _, statErr := os.Stat(p); statErr != nil {
@@ -754,10 +771,10 @@ func TestExecuteStagesRunLocalArtifactsAndCanonicalPromotion(t *testing.T) {
canonicalChecks := []string{ canonicalChecks := []string{
filepath.Join(paths.TranscriptsRawDir, "alice.json"), filepath.Join(paths.TranscriptsRawDir, "alice.json"),
filepath.Join(paths.TranscriptsDir, "merged.json"), filepath.Join(paths.TranscriptsDir, "base.json"),
filepath.Join(paths.TranscriptsDir, "processed.json"), filepath.Join(paths.TranscriptsDir, "polished.json"),
filepath.Join(paths.TranscriptsDir, "normalized.json"), filepath.Join(paths.TranscriptsDir, "final.json"),
filepath.Join(paths.TranscriptsDir, "trimmed.json"), filepath.Join(paths.TranscriptsDir, "final.trimmed.json"),
} }
for _, p := range canonicalChecks { for _, p := range canonicalChecks {
if _, statErr := os.Stat(p); statErr != nil { if _, statErr := os.Stat(p); statErr != nil {
@@ -902,7 +919,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
if ensureErr != nil { if ensureErr != nil {
t.Fatalf("EnsureLayout() error = %v", ensureErr) t.Fatalf("EnsureLayout() error = %v", ensureErr)
} }
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "merged.json"), []byte(`{"segments":[]}`), 0o644); err != nil { if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "base.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("write merged transcript: %v", err) t.Fatalf("write merged transcript: %v", err)
} }
if err := os.WriteFile(filepath.Join(paths.InputsDir, "glossary.yml"), []byte("terms: []\n"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(paths.InputsDir, "glossary.yml"), []byte("terms: []\n"), 0o644); err != nil {
@@ -914,7 +931,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
if ensureErr != nil { if ensureErr != nil {
t.Fatalf("EnsureLayout() error = %v", ensureErr) t.Fatalf("EnsureLayout() error = %v", ensureErr)
} }
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "processed.json"), []byte(`{"segments":[]}`), 0o644); err != nil { if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "polished.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("write processed transcript: %v", err) t.Fatalf("write processed transcript: %v", err)
} }
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{ cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
@@ -998,7 +1015,7 @@ func testConfig(t *testing.T) *config.Config {
pipelinePath := filepath.Join(cfgDir, "pipeline.yml") pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n") mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
mustWriteFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n") mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n") mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n") mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n") mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
@@ -1007,7 +1024,7 @@ func testConfig(t *testing.T) *config.Config {
return &config.Config{ return &config.Config{
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}}, Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"}, Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
PipelinePath: pipelinePath, PipelinePath: pipelinePath,
CampaignPath: campaignPath, CampaignPath: campaignPath,
SessionPath: sessionPath, SessionPath: sessionPath,
@@ -1050,12 +1067,10 @@ func TestBuildDefaultRunnersWithOmittedToolSections(t *testing.T) {
root: ` + t.TempDir() + ` root: ` + t.TempDir() + `
whisperx: whisperx:
transcribe_url: https://example.com/transcribe transcribe_url: https://example.com/transcribe
analyzer:
timeout: 20m
notification: notification:
timeout: 10s timeout: 10s
` `
campaignYAML := `campaign: sample-campaign campaignYAML := `campaign_id: sample-campaign
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml

View File

@@ -0,0 +1,43 @@
package app
import (
"flag"
"fmt"
"strings"
)
func isCLIFlagToken(arg string) bool {
return strings.HasPrefix(arg, "-") && arg != "-"
}
func pullLeadingSessionID(args []string) (string, []string) {
if len(args) == 0 || isCLIFlagToken(args[0]) {
return "", args
}
rest := append([]string(nil), args[1:]...)
return strings.TrimSpace(args[0]), rest
}
func applyPositionalSessionID(command, positional string, sessionID *string) error {
positional = strings.TrimSpace(positional)
if positional == "" {
return nil
}
existing := strings.TrimSpace(*sessionID)
if existing != "" && existing != positional {
return fmt.Errorf("%s: positional session id %q does not match expected session id %q", command, positional, existing)
}
*sessionID = positional
return nil
}
func applyParsedSessionIDArg(command string, fs *flag.FlagSet, sessionID *string) error {
switch fs.NArg() {
case 0:
return nil
case 1:
return applyPositionalSessionID(command, fs.Arg(0), sessionID)
default:
return fmt.Errorf("%s: unexpected positional arguments", command)
}
}

View File

@@ -10,7 +10,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
func TestPlanUsesDiscoveredSessionTemplateWithSessionIDs(t *testing.T) { func TestPlanRejectsDiscoveredSessionTemplate(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -32,16 +32,20 @@ inputs:
t.Cleanup(func() { config.DefaultSessionConfigSearchPaths = origSessionDefaults }) t.Cleanup(func() { config.DefaultSessionConfigSearchPaths = origSessionDefaults })
var out bytes.Buffer var out bytes.Buffer
if err := Plan(context.Background(), []string{ err := Plan(context.Background(), []string{
"2026-04-04",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session-id", "2026-04-04",
"--previous-session-id", "2026-03-28", "--previous-session-id", "2026-03-28",
}, &out); err != nil { }, &out)
t.Fatalf("Plan() error = %v", err) if err == nil {
t.Fatal("expected error, got nil")
} }
if !strings.Contains(out.String(), "narratio plan: workdir prepared") { if !strings.Contains(err.Error(), "session.yml must be concrete") {
t.Fatalf("output = %q, want plan output", out.String()) t.Fatalf("error = %q, want concrete session guidance", err.Error())
}
if !strings.Contains(err.Error(), "run narratio session init") {
t.Fatalf("error = %q, want session init guidance", err.Error())
} }
} }
@@ -50,7 +54,7 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer var out bytes.Buffer
err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-04-04"}, &out) err := Plan(context.Background(), []string{"2026-04-04", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
@@ -78,10 +82,10 @@ inputs:
var out bytes.Buffer var out bytes.Buffer
err := Plan(context.Background(), []string{ err := Plan(context.Background(), []string{
"2026-05-03",
"--config", pipelinePath, "--config", pipelinePath,
"--campaign", campaignPath, "--campaign-file", campaignPath,
"--session", sessionPath, "--session", sessionPath,
"--session-id", "2026-05-03",
"--previous-session-id", "2026-04-25", "--previous-session-id", "2026-04-25",
}, &out) }, &out)
if err == nil { if err == nil {
@@ -92,12 +96,12 @@ inputs:
} }
} }
func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) { func TestRunStageAcceptsPositionalSessionIDAndParsesStageName(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "prepare"}, &out) err := RunStage(context.Background(), []string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil { if err != nil {
t.Fatalf("RunStage() error = %v", err) t.Fatalf("RunStage() error = %v", err)
} }

View File

@@ -0,0 +1,353 @@
package app
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var capturedSessionID string
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
capturedSessionID = cfg.Session.SessionID
return &RunSummary{
SessionID: cfg.Session.SessionID,
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
Executed: []string{"prepare"},
}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"run",
"2026-05-03",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03") {
t.Fatalf("stdout = %q, want run summary", stdout.String())
}
if capturedSessionID != "2026-05-03" {
t.Fatalf("captured session = %q, want positional session id", capturedSessionID)
}
}
func TestExecutePositionalSessionIDMismatchFails(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"run",
"2026-05-04",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "session_id mismatch") {
t.Fatalf("stderr = %q, want session mismatch", stderr.String())
}
}
func TestExecuteSessionIDFlagFails(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "status", "2026-05-03", "--session-id", "2026-05-04"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "flag provided but not defined: -session-id") {
t.Fatalf("stderr = %q, want invalid --session-id flag", stderr.String())
}
}
func TestExecuteRemoteSessionFallbackUsesPositionalSessionID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
remoteKey := seedRemoteSessionConfig(t, fake, "2026-06-07", `session_id: 2026-06-07
inputs:
audio_s3:
prefix: audio/
`)
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
return &RunSummary{
SessionID: cfg.Session.SessionID,
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
Executed: []string{"prepare"},
}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"run",
"2026-06-07",
"--config", pipelinePath,
"--campaign-file", campaignPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
downloaded := false
for _, call := range fake.Downloads {
if call.Key == remoteKey {
downloaded = true
break
}
}
if !downloaded {
t.Fatalf("remote session %q was not downloaded; downloads=%v", remoteKey, fake.Downloads)
}
if storeInitCalls == 0 {
t.Fatal("object store was not initialized")
}
}
func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
tests := []struct {
name string
args []string
wantStage string
wantForce bool
}{
{
name: "resume",
args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
wantStage: "prepare",
wantForce: false,
},
{
name: "analyze",
args: []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
wantStage: "analyze",
wantForce: true,
},
{
name: "publish",
args: []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
wantStage: "archive",
wantForce: true,
},
{
name: "run-stage",
args: []string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
wantStage: "archive",
wantForce: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedStages []string
var capturedForce bool
var capturedArtifacts []string
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
for _, s := range stages {
capturedStages = append(capturedStages, s.Name())
}
capturedForce = opts.Force
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
return &RunSummary{
SessionID: "2026-05-03",
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
Executed: []string{tt.wantStage},
}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(tt.args, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if len(capturedStages) == 0 || capturedStages[0] != tt.wantStage {
t.Fatalf("captured stages = %#v, want first %q", capturedStages, tt.wantStage)
}
if capturedForce != tt.wantForce {
t.Fatalf("captured force = %t, want %t", capturedForce, tt.wantForce)
}
if tt.name == "analyze" || tt.name == "publish" || tt.name == "run-stage" {
if strings.Join(capturedArtifacts, ",") != "session_recap" {
t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts)
}
}
})
}
}
func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")})
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
tests := []struct {
name string
args []string
want string
}{
{
name: "validate",
args: []string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
want: "OK config",
},
{
name: "status",
args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
want: "Session: 2026-05-03",
},
{
name: "plan",
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
want: "narratio session plan: workdir prepared",
},
{
name: "artifacts",
args: []string{"session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
want: "Built-in:",
},
{
name: "locks",
args: []string{"session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
want: "Archive locks:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(tt.args, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), tt.want) {
t.Fatalf("stdout = %q, want %q", stdout.String(), tt.want)
}
})
}
}
func TestExecuteSessionInitAcceptsPositionalSessionID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
outputPath := filepath.Join(t.TempDir(), "session.yml")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--output", outputPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read generated session: %v", err)
}
if !strings.Contains(string(data), `session_id: "2026-06-07"`) {
t.Fatalf("generated session = %q, want positional session id", string(data))
}
}
func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
"--reason", "review",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
}
key := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"))
if !strings.Contains(string(fake.Objects[key].Data), "reason: review") {
t.Fatalf("lock store data = %q, want reason", string(fake.Objects[key].Data))
}
stdout.Reset()
stderr.Reset()
code = Execute([]string{
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String())
}
store, err := config.LoadArchiveLockStoreBytes("locks.yml", fake.Objects[key].Data, nil)
if err != nil {
t.Fatalf("LoadArchiveLockStoreBytes() error = %v", err)
}
if len(store.Locks) != 0 {
t.Fatalf("locks after remove = %#v, want empty", store.Locks)
}
}
func TestExecuteCleanAcceptsPositionalSessionID(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
workDir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
spoolDir := artifacts.SessionSpoolDir(filepath.Join(workspaceRoot, "spool"), "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workDir, "manifest.json"), "{}")
mustWriteTestFile(t, filepath.Join(spoolDir, "run-1", "audio", "alice.flac"), "audio")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
cleanAssertMissing(t, workDir)
cleanAssertMissing(t, spoolDir)
}

View File

@@ -3,71 +3,28 @@ package app
import ( import (
"bytes" "bytes"
"context" "context"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
func TestStatusCommandReadsManifest(t *testing.T) { func TestStatusCommandRequiresSessionID(t *testing.T) {
manifestPath := writeManifestForStatus(t)
var out bytes.Buffer
err := Status(context.Background(), []string{"--manifest", manifestPath}, &out)
if err != nil {
t.Fatalf("Status() error = %v", err)
}
s := out.String()
if !strings.Contains(s, "session_id: 2026-05-03") {
t.Fatalf("output = %q, want session_id", s)
}
if !strings.Contains(s, "- merge: succeeded") {
t.Fatalf("output = %q, want stage status", s)
}
}
func TestStatusCommandMissingManifestFlag(t *testing.T) {
var out bytes.Buffer var out bytes.Buffer
err := Status(context.Background(), nil, &out) err := Status(context.Background(), nil, &out)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if !strings.Contains(err.Error(), "--manifest is required") { if !strings.Contains(err.Error(), "status: session_id is required") {
t.Fatalf("error = %q, want missing manifest flag", err.Error()) t.Fatalf("error = %q, want missing session_id error", err.Error())
} }
} }
func TestStatusCommandBadManifest(t *testing.T) { func TestStatusCommandRejectsManifestFlag(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "manifest.json")
if err := os.WriteFile(path, []byte("{not-json"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
var out bytes.Buffer var out bytes.Buffer
err := Status(context.Background(), []string{"--manifest", path}, &out) err := Status(context.Background(), []string{"2026-05-03", "--manifest", "manifest.json"}, &out)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if !strings.Contains(err.Error(), "decode manifest") { if !strings.Contains(err.Error(), "status: invalid flags: flag provided but not defined: -manifest") {
t.Fatalf("error = %q, want decode error", err.Error()) t.Fatalf("error = %q, want invalid manifest flag", err.Error())
} }
} }
func writeManifestForStatus(t *testing.T) string {
t.Helper()
store := &manifest.LocalStore{}
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
m.MarkStageSucceeded("merge", time.Date(2026, 5, 3, 10, 5, 0, 0, time.UTC), nil)
path := filepath.Join(t.TempDir(), "manifest.json")
if err := store.Save(context.Background(), path, m); err != nil {
t.Fatalf("Save() error = %v", err)
}
return path
}

View File

@@ -0,0 +1,84 @@
package artifactmodel
import "strings"
const (
SourceTranscriptBase = "narratio.transcript.base"
SourceTranscriptPolished = "narratio.transcript.polished"
SourceTranscriptFinal = "narratio.transcript.final"
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
)
const (
TranscriptPathBase = "transcripts/base.json"
TranscriptPathPolished = "transcripts/polished.json"
TranscriptPathFinal = "transcripts/final.json"
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
)
const (
TranscriptOutputKindBase = "transcript_base"
TranscriptOutputKindPolished = "transcript_polished"
TranscriptOutputKindFinal = "transcript_final"
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
)
// TranscriptArtifactSpec describes one built-in transcript artifact mapping.
type TranscriptArtifactSpec struct {
SourceID string
CanonicalRelPath string
ProducerStage string
OutputKind string
}
var runtimeTranscriptArtifacts = []TranscriptArtifactSpec{
{
SourceID: SourceTranscriptBase,
CanonicalRelPath: TranscriptPathBase,
ProducerStage: "merge",
OutputKind: TranscriptOutputKindBase,
},
{
SourceID: SourceTranscriptPolished,
CanonicalRelPath: TranscriptPathPolished,
ProducerStage: "polish",
OutputKind: TranscriptOutputKindPolished,
},
{
SourceID: SourceTranscriptFinal,
CanonicalRelPath: TranscriptPathFinal,
ProducerStage: "normalize",
OutputKind: TranscriptOutputKindFinal,
},
{
SourceID: SourceTranscriptFinalTrimmed,
CanonicalRelPath: TranscriptPathFinalTrimmed,
ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed,
},
}
// RuntimeTranscriptArtifacts returns transcript mappings in pipeline order.
func RuntimeTranscriptArtifacts() []TranscriptArtifactSpec {
return cloneTranscriptSpecs(runtimeTranscriptArtifacts)
}
// LookupRuntimeTranscriptArtifact returns runtime transcript metadata by source ID.
func LookupRuntimeTranscriptArtifact(sourceID string) (TranscriptArtifactSpec, bool) {
trimmed := strings.TrimSpace(sourceID)
for _, spec := range runtimeTranscriptArtifacts {
if spec.SourceID == trimmed {
return spec, true
}
}
return TranscriptArtifactSpec{}, false
}
func cloneTranscriptSpecs(specs []TranscriptArtifactSpec) []TranscriptArtifactSpec {
if len(specs) == 0 {
return nil
}
out := make([]TranscriptArtifactSpec, len(specs))
copy(out, specs)
return out
}

View File

@@ -9,23 +9,38 @@ import (
"regexp" "regexp"
"strings" "strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
const ( const (
ArtifactTranscriptMerged = "narratio.transcript.merged" ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
ArtifactTranscriptPolished = "narratio.transcript.polished" ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
ArtifactTranscriptFull = "narratio.transcript.full" ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
ArtifactTranscriptTrimmed = "narratio.transcript.trimmed" ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
ArtifactBoundsSession = "narratio.bounds.session" ArtifactBoundsSession = "narratio.bounds.session"
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache" ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache" ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache"
) )
const (
TranscriptPathBase = artifactmodel.TranscriptPathBase
TranscriptPathPolished = artifactmodel.TranscriptPathPolished
TranscriptPathFinal = artifactmodel.TranscriptPathFinal
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
)
const (
TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
)
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID. // ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
var ErrSessionArtifactNotFound = errors.New("session artifact not found") var ErrSessionArtifactNotFound = errors.New("session artifact not found")
var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.[a-z][a-z0-9_]*$`) var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
var previousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`) var previousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
type artifactContentKind string type artifactContentKind string
@@ -44,42 +59,27 @@ type artifactSpec struct {
ContentKind artifactContentKind ContentKind artifactContentKind
} }
var artifactRegistry = map[string]artifactSpec{ var artifactRegistry = buildArtifactRegistry()
ArtifactTranscriptMerged: {
ID: ArtifactTranscriptMerged, func buildArtifactRegistry() map[string]artifactSpec {
CanonicalRelPath: "transcripts/merged.json", registry := map[string]artifactSpec{}
ProducerStage: "merge", for _, transcript := range RuntimeTranscriptArtifacts() {
OutputKind: "transcript_merged", registry[transcript.SourceID] = artifactSpec{
ContentKind: contentTranscriptJSON, ID: transcript.SourceID,
}, CanonicalRelPath: transcript.CanonicalRelPath,
ArtifactTranscriptPolished: { ProducerStage: transcript.ProducerStage,
ID: ArtifactTranscriptPolished, OutputKind: transcript.OutputKind,
CanonicalRelPath: "transcripts/processed.json", ContentKind: contentTranscriptJSON,
ProducerStage: "polish", }
OutputKind: "transcript_processed", }
ContentKind: contentTranscriptJSON, registry[ArtifactBoundsSession] = artifactSpec{
},
ArtifactTranscriptFull: {
ID: ArtifactTranscriptFull,
CanonicalRelPath: "transcripts/normalized.json",
ProducerStage: "normalize",
OutputKind: "transcript_normalized",
ContentKind: contentTranscriptJSON,
},
ArtifactTranscriptTrimmed: {
ID: ArtifactTranscriptTrimmed,
CanonicalRelPath: "transcripts/trimmed.json",
ProducerStage: "trim",
OutputKind: "transcript_trimmed",
ContentKind: contentTranscriptJSON,
},
ArtifactBoundsSession: {
ID: ArtifactBoundsSession, ID: ArtifactBoundsSession,
CanonicalRelPath: "artifacts/session_bounds.json", CanonicalRelPath: "artifacts/session_bounds.json",
ProducerStage: "trim", ProducerStage: "trim",
OutputKind: "session_bounds", OutputKind: "session_bounds",
ContentKind: contentJSON, ContentKind: contentJSON,
}, }
return registry
} }
// ResolvedSessionArtifact describes one session-level artifact lookup result. // ResolvedSessionArtifact describes one session-level artifact lookup result.
@@ -122,6 +122,15 @@ func IsConfiguredArtifactSource(source string) bool {
return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source)) return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source))
} }
// ConfiguredArtifactName extracts <name> from narratio.artifact.<name>.
func ConfiguredArtifactName(source string) (string, bool) {
matches := configuredArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source))
if len(matches) != 2 {
return "", false
}
return matches[1], true
}
// IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.<name>. // IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.<name>.
func IsPreviousSessionArtifactSource(source string) bool { func IsPreviousSessionArtifactSource(source string) bool {
_, ok := PreviousSessionArtifactName(source) _, ok := PreviousSessionArtifactName(source)
@@ -261,7 +270,7 @@ func ResolvePreviousSessionArtifactWithCatalog(
ID: source, ID: source,
Path: candidate, Path: candidate,
ProducerStage: "prepare", ProducerStage: "prepare",
OutputKind: "previous_session_artifact", OutputKind: "previous_session_cache",
Provenance: ArtifactProvenancePreviousCacheManifestInput, Provenance: ArtifactProvenancePreviousCacheManifestInput,
}, nil }, nil
} }
@@ -275,7 +284,7 @@ func ResolvePreviousSessionArtifactWithCatalog(
ID: source, ID: source,
Path: fallback, Path: fallback,
ProducerStage: "prepare", ProducerStage: "prepare",
OutputKind: "previous_session_artifact", OutputKind: "previous_session_cache",
Provenance: ArtifactProvenancePreviousCacheFilesystem, Provenance: ArtifactProvenancePreviousCacheFilesystem,
}, nil }, nil
} }

View File

@@ -23,7 +23,7 @@ func TestNormalizeSessionArtifactSource(t *testing.T) {
{name: "legacy alias normalized unsupported", source: "normalized_transcript", wantErr: "unsupported artifact source"}, {name: "legacy alias normalized unsupported", source: "normalized_transcript", wantErr: "unsupported artifact source"},
{name: "legacy alias trimmed unsupported", source: "trimmed_transcript", wantErr: "unsupported artifact source"}, {name: "legacy alias trimmed unsupported", source: "trimmed_transcript", wantErr: "unsupported artifact source"},
{name: "configured source unsupported in built-in normalization", source: "narratio.artifact.session_recap", wantErr: "unsupported artifact source"}, {name: "configured source unsupported in built-in normalization", source: "narratio.artifact.session_recap", wantErr: "unsupported artifact source"},
{name: "canonical", source: ArtifactTranscriptTrimmed, wantID: ArtifactTranscriptTrimmed}, {name: "canonical", source: ArtifactTranscriptFinalTrimmed, wantID: ArtifactTranscriptFinalTrimmed},
{name: "unsupported", source: "narratio.unknown", wantErr: "unsupported artifact source"}, {name: "unsupported", source: "narratio.unknown", wantErr: "unsupported artifact source"},
} }
@@ -46,6 +46,58 @@ func TestNormalizeSessionArtifactSource(t *testing.T) {
} }
} }
func TestConfiguredArtifactSourceHelpers(t *testing.T) {
tests := []struct {
name string
source string
wantName string
wantMatch bool
}{
{
name: "valid",
source: "narratio.artifact.session_recap",
wantName: "session_recap",
wantMatch: true,
},
{
name: "valid with surrounding whitespace",
source: " narratio.artifact.player_handout ",
wantName: "player_handout",
wantMatch: true,
},
{
name: "missing name",
source: "narratio.artifact.",
wantMatch: false,
},
{
name: "invalid hyphen",
source: "narratio.artifact.session-recap",
wantMatch: false,
},
{
name: "built-in",
source: ArtifactTranscriptBase,
wantMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsConfiguredArtifactSource(tt.source); got != tt.wantMatch {
t.Fatalf("IsConfiguredArtifactSource(%q) = %t, want %t", tt.source, got, tt.wantMatch)
}
gotName, gotOK := ConfiguredArtifactName(tt.source)
if gotOK != tt.wantMatch {
t.Fatalf("ConfiguredArtifactName(%q) ok = %t, want %t", tt.source, gotOK, tt.wantMatch)
}
if gotName != tt.wantName {
t.Fatalf("ConfiguredArtifactName(%q) name = %q, want %q", tt.source, gotName, tt.wantName)
}
})
}
}
func TestPreviousSessionArtifactSourceHelpers(t *testing.T) { func TestPreviousSessionArtifactSourceHelpers(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -108,7 +160,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
if err := os.WriteFile(manifestPath, []byte(`{"segments":[]}`), 0o644); err != nil { if err := os.WriteFile(manifestPath, []byte(`{"segments":[]}`), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }
canonicalPath := filepath.Join(paths.TranscriptsDir, "normalized.json") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.json")
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err) t.Fatalf("MkdirAll() error = %v", err)
} }
@@ -118,10 +170,10 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
m := manifest.New("session", time.Now().UTC()) m := manifest.New("session", time.Now().UTC())
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{
{Kind: "transcript_normalized", LocalPath: manifestPath, ProducerRunID: "run-123"}, {Kind: "transcript_final", LocalPath: manifestPath, ProducerRunID: "run-123"},
}) })
resolved, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFull) resolved, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFinal)
if err != nil { if err != nil {
t.Fatalf("ResolveSessionArtifact() error = %v", err) t.Fatalf("ResolveSessionArtifact() error = %v", err)
} }
@@ -139,7 +191,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) { func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "trimmed.json") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err) t.Fatalf("MkdirAll() error = %v", err)
} }
@@ -147,7 +199,7 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptTrimmed) resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed)
if err != nil { if err != nil {
t.Fatalf("ResolveSessionArtifact() error = %v", err) t.Fatalf("ResolveSessionArtifact() error = %v", err)
} }
@@ -163,7 +215,7 @@ func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptTrimmed) _, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed)
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
@@ -175,7 +227,7 @@ func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) {
func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) { func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "processed.json") canonicalPath := filepath.Join(paths.TranscriptsDir, "polished.json")
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err) t.Fatalf("MkdirAll() error = %v", err)
} }
@@ -195,7 +247,7 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) { func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "trimmed.json") canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err) t.Fatalf("MkdirAll() error = %v", err)
} }
@@ -203,7 +255,7 @@ func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T)
t.Fatalf("WriteFile() error = %v", err) t.Fatalf("WriteFile() error = %v", err)
} }
resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, ArtifactTranscriptTrimmed, NewArtifactCatalog()) resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, ArtifactTranscriptFinalTrimmed, NewArtifactCatalog())
if err != nil { if err != nil {
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err) t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
} }

View File

@@ -215,10 +215,10 @@ func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
func runtimeBuiltInArtifactIDs() []string { func runtimeBuiltInArtifactIDs() []string {
return []string{ return []string{
ArtifactTranscriptMerged, ArtifactTranscriptBase,
ArtifactTranscriptPolished, ArtifactTranscriptPolished,
ArtifactTranscriptFull, ArtifactTranscriptFinal,
ArtifactTranscriptTrimmed, ArtifactTranscriptFinalTrimmed,
ArtifactBoundsSession, ArtifactBoundsSession,
} }
} }

View File

@@ -8,9 +8,9 @@ func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
t.Fatalf("RegisterBuiltIns() error = %v", err) t.Fatalf("RegisterBuiltIns() error = %v", err)
} }
entry, ok := catalog.Lookup(ArtifactTranscriptFull) entry, ok := catalog.Lookup(ArtifactTranscriptFinal)
if !ok { if !ok {
t.Fatalf("Lookup(%q) ok = false, want true", ArtifactTranscriptFull) t.Fatalf("Lookup(%q) ok = false, want true", ArtifactTranscriptFinal)
} }
if !entry.Planned { if !entry.Planned {
t.Fatalf("entry.Planned = false, want true") t.Fatalf("entry.Planned = false, want true")
@@ -18,8 +18,8 @@ func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
if entry.Executable { if entry.Executable {
t.Fatalf("entry.Executable = true, want false") t.Fatalf("entry.Executable = true, want false")
} }
if entry.CanonicalRelPath != "transcripts/normalized.json" { if entry.CanonicalRelPath != "transcripts/final.json" {
t.Fatalf("entry.CanonicalRelPath = %q, want transcripts/normalized.json", entry.CanonicalRelPath) t.Fatalf("entry.CanonicalRelPath = %q, want transcripts/final.json", entry.CanonicalRelPath)
} }
} }

View File

@@ -24,7 +24,7 @@ func TestCollectPreviousArtifactRequirements(t *testing.T) {
"session_recap": { "session_recap": {
Enabled: true, Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{ Inputs: map[string]config.ScriptoriumInputConfig{
"transcript": {Source: "narratio.transcript.trimmed", Required: true}, "transcript": {Source: "narratio.transcript.final_trimmed", Required: true},
}, },
}, },
}, },

View File

@@ -43,8 +43,8 @@ func TestS3KeyConstruction(t *testing.T) {
t.Fatalf("manifest key = %q", manifestKey) t.Fatalf("manifest key = %q", manifestKey)
} }
promoted := S3PromotedArtifactKey(sessionPrefix, "transcripts/trimmed.json") promoted := S3PromotedArtifactKey(sessionPrefix, "transcripts/final.trimmed.json")
if promoted != "dnd/campaigns/forsaken/sessions/2026-04-19/transcripts/trimmed.json" { if promoted != "dnd/campaigns/forsaken/sessions/2026-04-19/transcripts/final.trimmed.json" {
t.Fatalf("promoted key = %q", promoted) t.Fatalf("promoted key = %q", promoted)
} }

View File

@@ -0,0 +1,25 @@
package artifacts
import "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
type TranscriptArtifactSpec = artifactmodel.TranscriptArtifactSpec
// RuntimeTranscriptArtifacts returns the current runtime transcript mappings in pipeline order.
func RuntimeTranscriptArtifacts() []TranscriptArtifactSpec {
return artifactmodel.RuntimeTranscriptArtifacts()
}
// PlannedTranscriptArtifacts returns the target transcript mappings for the transcript naming roadmap.
func PlannedTranscriptArtifacts() []TranscriptArtifactSpec {
return artifactmodel.RuntimeTranscriptArtifacts()
}
// LookupRuntimeTranscriptArtifact returns current runtime transcript metadata by source ID.
func LookupRuntimeTranscriptArtifact(sourceID string) (TranscriptArtifactSpec, bool) {
return artifactmodel.LookupRuntimeTranscriptArtifact(sourceID)
}
// LookupPlannedTranscriptArtifact returns target transcript metadata by source ID.
func LookupPlannedTranscriptArtifact(sourceID string) (TranscriptArtifactSpec, bool) {
return artifactmodel.LookupRuntimeTranscriptArtifact(sourceID)
}

View File

@@ -0,0 +1,123 @@
package artifacts
import (
"reflect"
"testing"
)
func TestRuntimeTranscriptArtifacts(t *testing.T) {
want := []TranscriptArtifactSpec{
{
SourceID: ArtifactTranscriptBase,
CanonicalRelPath: TranscriptPathBase,
ProducerStage: "merge",
OutputKind: TranscriptOutputKindBase,
},
{
SourceID: ArtifactTranscriptPolished,
CanonicalRelPath: TranscriptPathPolished,
ProducerStage: "polish",
OutputKind: TranscriptOutputKindPolished,
},
{
SourceID: ArtifactTranscriptFinal,
CanonicalRelPath: TranscriptPathFinal,
ProducerStage: "normalize",
OutputKind: TranscriptOutputKindFinal,
},
{
SourceID: ArtifactTranscriptFinalTrimmed,
CanonicalRelPath: TranscriptPathFinalTrimmed,
ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed,
},
}
got := RuntimeTranscriptArtifacts()
if !reflect.DeepEqual(got, want) {
t.Fatalf("RuntimeTranscriptArtifacts() = %#v, want %#v", got, want)
}
for _, spec := range want {
gotSpec, ok := LookupRuntimeTranscriptArtifact(spec.SourceID)
if !ok {
t.Fatalf("LookupRuntimeTranscriptArtifact(%q) ok = false, want true", spec.SourceID)
}
if gotSpec != spec {
t.Fatalf("LookupRuntimeTranscriptArtifact(%q) = %#v, want %#v", spec.SourceID, gotSpec, spec)
}
}
}
func TestPlannedTranscriptArtifacts(t *testing.T) {
want := []TranscriptArtifactSpec{
{
SourceID: ArtifactTranscriptBase,
CanonicalRelPath: TranscriptPathBase,
ProducerStage: "merge",
OutputKind: TranscriptOutputKindBase,
},
{
SourceID: ArtifactTranscriptPolished,
CanonicalRelPath: TranscriptPathPolished,
ProducerStage: "polish",
OutputKind: TranscriptOutputKindPolished,
},
{
SourceID: ArtifactTranscriptFinal,
CanonicalRelPath: TranscriptPathFinal,
ProducerStage: "normalize",
OutputKind: TranscriptOutputKindFinal,
},
{
SourceID: ArtifactTranscriptFinalTrimmed,
CanonicalRelPath: TranscriptPathFinalTrimmed,
ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed,
},
}
got := PlannedTranscriptArtifacts()
if !reflect.DeepEqual(got, want) {
t.Fatalf("PlannedTranscriptArtifacts() = %#v, want %#v", got, want)
}
for _, spec := range want {
gotSpec, ok := LookupPlannedTranscriptArtifact(spec.SourceID)
if !ok {
t.Fatalf("LookupPlannedTranscriptArtifact(%q) ok = false, want true", spec.SourceID)
}
if gotSpec != spec {
t.Fatalf("LookupPlannedTranscriptArtifact(%q) = %#v, want %#v", spec.SourceID, gotSpec, spec)
}
}
}
func TestTranscriptArtifactSlicesAreCopies(t *testing.T) {
runtime := RuntimeTranscriptArtifacts()
runtime[0].SourceID = "changed"
if got := RuntimeTranscriptArtifacts()[0].SourceID; got != ArtifactTranscriptBase {
t.Fatalf("RuntimeTranscriptArtifacts()[0].SourceID = %q, want %q", got, ArtifactTranscriptBase)
}
planned := PlannedTranscriptArtifacts()
planned[0].SourceID = "changed"
if got := PlannedTranscriptArtifacts()[0].SourceID; got != ArtifactTranscriptBase {
t.Fatalf("PlannedTranscriptArtifacts()[0].SourceID = %q, want %q", got, ArtifactTranscriptBase)
}
}
func TestRuntimeArtifactRegistryUsesTranscriptSpecs(t *testing.T) {
for _, transcript := range RuntimeTranscriptArtifacts() {
spec, ok := artifactRegistry[transcript.SourceID]
if !ok {
t.Fatalf("artifactRegistry missing %q", transcript.SourceID)
}
if spec.CanonicalRelPath != transcript.CanonicalRelPath ||
spec.ProducerStage != transcript.ProducerStage ||
spec.OutputKind != transcript.OutputKind ||
spec.ContentKind != contentTranscriptJSON {
t.Fatalf("artifactRegistry[%q] = %#v, want transcript spec %#v", transcript.SourceID, spec, transcript)
}
}
}

View File

@@ -10,8 +10,6 @@ func TestCacheDefaults(t *testing.T) {
root: /tmp/narratio root: /tmp/narratio
whisperx: whisperx:
transcribe_url: https://example.com/transcribe transcribe_url: https://example.com/transcribe
analyzer:
timeout: 20m
notification: notification:
timeout: 10s timeout: 10s
`, `session_id: 2026-05-03 `, `session_id: 2026-05-03

View File

@@ -7,24 +7,52 @@ import (
"testing" "testing"
) )
func TestCampaignConfigDefaultSearchOrder(t *testing.T) { func TestPipelineCampaignRegistryStrictDecode(t *testing.T) {
want := []string{ dir := t.TempDir()
"/usr/local/etc/narratio/campaign.yml", pipelinePath := filepath.Join(dir, "pipeline.yml")
"/etc/narratio/campaign.yml", pipelineYAML := `workspace:
root: /tmp/narratio-work
campaigns:
root: /srv/narratio/campaigns
default_campaign_id: dilfs
whisperx:
transcribe_url: https://example.com/transcribe
notification:
timeout: 10s
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err)
} }
if len(DefaultCampaignConfigSearchPaths) != len(want) { cfg, err := LoadPipeline(pipelinePath)
t.Fatalf("DefaultCampaignConfigSearchPaths = %#v, want %#v", DefaultCampaignConfigSearchPaths, want) if err != nil {
t.Fatalf("LoadPipeline() error = %v", err)
} }
for i := range want { if cfg.Campaigns.Root != "/srv/narratio/campaigns" {
if DefaultCampaignConfigSearchPaths[i] != want[i] { t.Fatalf("campaigns.root = %q", cfg.Campaigns.Root)
t.Fatalf("DefaultCampaignConfigSearchPaths[%d] = %q, want %q", i, DefaultCampaignConfigSearchPaths[i], want[i]) }
} if cfg.Campaigns.DefaultCampaignID != "dilfs" {
t.Fatalf("campaigns.default_campaign_id = %q", cfg.Campaigns.DefaultCampaignID)
} }
} }
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) { func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
if CampaignID(cfg.Campaign) != "sample-campaign" {
t.Fatalf("CampaignID() = %q, want sample-campaign", CampaignID(cfg.Campaign))
}
}
func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
@@ -37,9 +65,39 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
} }
} }
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
_, 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 TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
if cfg.Campaign.SessionTemplateFile != "./session.template.yml" {
t.Fatalf("SessionTemplateFile = %q, want ./session.template.yml", cfg.Campaign.SessionTemplateFile)
}
}
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) { func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n", "campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
@@ -60,7 +118,7 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) { func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n", "campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n", "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n",
) )
@@ -78,7 +136,7 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
func TestCampaignSessionMismatchFails(t *testing.T) { func TestCampaignSessionMismatchFails(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
) )
@@ -93,7 +151,7 @@ func TestCampaignSessionMismatchFails(t *testing.T) {
func TestLoadMissingCampaignFileFails(t *testing.T) { func TestLoadMissingCampaignFileFails(t *testing.T) {
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t, pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n", "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
) )
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml") missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")
@@ -115,7 +173,7 @@ func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string
campaignPath := filepath.Join(dir, "campaign.yml") campaignPath := filepath.Join(dir, "campaign.yml")
sessionPath := filepath.Join(dir, "session.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" pipelineYAML := "workspace:\n root: " + filepath.ToSlash(filepath.Join(dir, "work")) + "\nwhisperx:\n transcribe_url: https://example.com/transcribe\nnotification:\n timeout: 10s\n"
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)
} }

View File

@@ -17,6 +17,7 @@ type Config struct {
// PipelineConfig contains durable pipeline-level settings. // PipelineConfig contains durable pipeline-level settings.
type PipelineConfig struct { type PipelineConfig struct {
Workspace WorkspaceConfig `yaml:"workspace"` Workspace WorkspaceConfig `yaml:"workspace"`
Campaigns CampaignsConfig `yaml:"campaigns"`
Storage StorageConfig `yaml:"storage"` Storage StorageConfig `yaml:"storage"`
Spool SpoolConfig `yaml:"spool"` Spool SpoolConfig `yaml:"spool"`
Cache CacheConfig `yaml:"cache"` Cache CacheConfig `yaml:"cache"`
@@ -28,14 +29,20 @@ type PipelineConfig struct {
Normalize *NormalizeConfig `yaml:"normalize"` Normalize *NormalizeConfig `yaml:"normalize"`
Trim *TrimConfig `yaml:"trim"` Trim *TrimConfig `yaml:"trim"`
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"` Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
Analyzer AnalyzerConfig `yaml:"analyzer"`
Notification NotificationConfig `yaml:"notification"` Notification NotificationConfig `yaml:"notification"`
} }
// CampaignsConfig configures the local campaign registry.
type CampaignsConfig struct {
Root string `yaml:"root"`
DefaultCampaignID string `yaml:"default_campaign_id"`
}
// CampaignConfig contains stable campaign-level identity and input defaults. // CampaignConfig contains stable campaign-level identity and input defaults.
type CampaignConfig struct { type CampaignConfig struct {
Campaign string `yaml:"campaign"` CampaignID string `yaml:"campaign_id"`
Inputs CampaignInputsConfig `yaml:"inputs"` SessionTemplateFile string `yaml:"session_template_file"`
Inputs CampaignInputsConfig `yaml:"inputs"`
} }
// CampaignInputsConfig contains stable campaign-level input file references. // CampaignInputsConfig contains stable campaign-level input file references.
@@ -69,8 +76,6 @@ type SecretsConfig struct {
// StorageConfig configures storage backends and related parameters. // StorageConfig configures storage backends and related parameters.
type StorageConfig struct { type StorageConfig struct {
Backend string `yaml:"backend"` Backend string `yaml:"backend"`
Bucket string `yaml:"bucket"`
Prefix string `yaml:"prefix"`
S3 *StorageS3Config `yaml:"s3"` S3 *StorageS3Config `yaml:"s3"`
} }
@@ -234,13 +239,6 @@ type ScriptoriumInputConfig struct {
Required bool `yaml:"required"` Required bool `yaml:"required"`
} }
// AnalyzerConfig configures analyzer adapter settings.
type AnalyzerConfig struct {
BinaryPath string `yaml:"binary_path"`
Timeout string `yaml:"timeout"`
Artifacts ArtifactSettings `yaml:"artifacts"`
}
// NotificationConfig configures notification backend settings. // NotificationConfig configures notification backend settings.
type NotificationConfig struct { type NotificationConfig struct {
Backend string `yaml:"backend"` Backend string `yaml:"backend"`
@@ -248,12 +246,6 @@ type NotificationConfig struct {
Timeout string `yaml:"timeout"` Timeout string `yaml:"timeout"`
} }
// ArtifactSettings configures generated artifact selection and paths.
type ArtifactSettings struct {
OutputDir string `yaml:"output_dir"`
Types []string `yaml:"types"`
}
// SessionInputsConfig contains per-session input references. // SessionInputsConfig contains per-session input references.
type SessionInputsConfig struct { type SessionInputsConfig struct {
AudioDir string `yaml:"audio_dir"` AudioDir string `yaml:"audio_dir"`

View File

@@ -1,18 +1,19 @@
package config package config
import "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
// Default filesystem locations for config lookup when config path flags are // Default filesystem locations for config lookup when config path flags are
// omitted. Order is highest to lowest precedence. // omitted. Order is highest to lowest precedence.
const ( const (
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml" DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml" DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml"
DefaultCampaignConfigPathUsrLocal = "/usr/local/etc/narratio/campaign.yml"
DefaultCampaignConfigPathEtc = "/etc/narratio/campaign.yml"
DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml" DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml"
DefaultSessionConfigPathEtc = "/etc/narratio/session.yml" DefaultSessionConfigPathEtc = "/etc/narratio/session.yml"
DefaultS3AccessKeyIDEnv = "OBJECT_STORAGE_KEY_ID" DefaultS3AccessKeyIDEnv = "OBJECT_STORAGE_KEY_ID"
DefaultS3SecretAccessKeyEnv = "OBJECT_STORAGE_KEY" DefaultS3SecretAccessKeyEnv = "OBJECT_STORAGE_KEY"
DefaultStorageS3RootPrefix = "dnd" DefaultStorageS3RootPrefix = "dnd"
DefaultWorkspaceRoot = "/var/lib/narratio" DefaultWorkspaceRoot = "/var/lib/narratio"
DefaultCampaignsRoot = "/usr/local/share/narratio/campaigns"
DefaultSpoolRoot = "/var/spool/narratio" DefaultSpoolRoot = "/var/spool/narratio"
DefaultCacheRoot = "/var/cache/narratio" DefaultCacheRoot = "/var/cache/narratio"
DefaultCacheS3Audio = true DefaultCacheS3Audio = true
@@ -40,32 +41,32 @@ const (
DefaultTrimBoundsTimeout = "10m" DefaultTrimBoundsTimeout = "10m"
DefaultTrimSeriatimReport = false DefaultTrimSeriatimReport = false
DefaultNormalizeOutputPath = "transcripts/normalized.json" DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
DefaultNormalizeOutputSchema = "seriatim-intermediate" DefaultNormalizeOutputSchema = "seriatim-intermediate"
DefaultNormalizeReport = true DefaultNormalizeReport = true
DefaultArchiveEnabled = true DefaultArchiveEnabled = true
DefaultArchiveUploadRun = true DefaultArchiveUploadRun = true
PathWorkDirSegment = "work" PathWorkDirSegment = "work"
PathInputsDirSegment = "inputs" PathInputsDirSegment = "inputs"
PathAudioDirSegment = "audio" PathAudioDirSegment = "audio"
PathTranscriptsSegment = "transcripts" PathTranscriptsSegment = "transcripts"
PathTranscriptsRaw = "transcripts/raw" PathTranscriptsRaw = "transcripts/raw"
PathTranscriptsTrimmed = "transcripts/trimmed" PathTranscriptsTrimmed = "transcripts/trimmed"
PathArtifactsDirSegment = "artifacts" PathArtifactsDirSegment = "artifacts"
PathReportsDirSegment = "reports" PathReportsDirSegment = "reports"
PathConfigDirSegment = "config" PathConfigDirSegment = "config"
PathLogsDirSegment = "logs" PathLogsDirSegment = "logs"
PathCurrentDirSegment = "current" PathCurrentDirSegment = "current"
PathRunsDirSegment = "runs" PathRunsDirSegment = "runs"
PathPreviousDirSegment = "previous" PathPreviousDirSegment = "previous"
PathManifestFile = "manifest.json" PathManifestFile = "manifest.json"
PathLockFile = ".lock" PathLockFile = ".lock"
PathTranscriptMerged = "transcripts/merged.json" PathTranscriptBase = artifactmodel.TranscriptPathBase
PathTranscriptProcessed = "transcripts/processed.json" PathTranscriptPolished = artifactmodel.TranscriptPathPolished
PathTranscriptNormalized = "transcripts/normalized.json" PathTranscriptFinal = artifactmodel.TranscriptPathFinal
PathTranscriptTrimmed = "transcripts/trimmed.json" PathTranscriptFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
S3CampaignsSegment = "campaigns" S3CampaignsSegment = "campaigns"
S3SessionsSegment = "sessions" S3SessionsSegment = "sessions"
@@ -78,7 +79,7 @@ const (
// DefaultArchivePromoteArtifacts defines the default archive promotion rules. // DefaultArchivePromoteArtifacts defines the default archive promotion rules.
// Callers should copy this slice before mutating. // Callers should copy this slice before mutating.
var DefaultArchivePromoteArtifacts = []ArchivePromotionRule{ var DefaultArchivePromoteArtifacts = []ArchivePromotionRule{
{Source: "narratio.transcript.trimmed", Dest: PathTranscriptTrimmed}, {Source: artifactmodel.SourceTranscriptFinalTrimmed, Dest: PathTranscriptFinalTrimmed},
} }
// DefaultPipelineConfigSearchPaths defines the default search order for // DefaultPipelineConfigSearchPaths defines the default search order for
@@ -91,16 +92,6 @@ var DefaultPipelineConfigSearchPaths = []string{
DefaultPipelineConfigPathEtc, DefaultPipelineConfigPathEtc,
} }
// DefaultCampaignConfigSearchPaths defines the default search order for
// campaign.yml when callers do not provide an explicit path.
//
// Keep this in a variable so future defaults can be extended without changing
// call sites.
var DefaultCampaignConfigSearchPaths = []string{
DefaultCampaignConfigPathUsrLocal,
DefaultCampaignConfigPathEtc,
}
// DefaultSessionConfigSearchPaths defines the default search order for // DefaultSessionConfigSearchPaths defines the default search order for
// session.yml when callers do not provide an explicit path. // session.yml when callers do not provide an explicit path.
// //

View File

@@ -36,14 +36,14 @@ func LoadSession(path string) (*SessionConfig, error) {
return LoadSessionWithOptions(path, SessionLoadOptions{}) return LoadSessionWithOptions(path, SessionLoadOptions{})
} }
// SessionLoadOptions configures session template rendering behavior. // SessionLoadOptions configures expected session identity checks.
type SessionLoadOptions struct { type SessionLoadOptions struct {
SessionID string SessionID string
PreviousSessionID string PreviousSessionID string
} }
// LoadSessionWithOptions loads session configuration from a YAML file with // LoadSessionWithOptions loads session configuration from a YAML file with
// strict field checking after template rendering. // strict field checking.
func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) { func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) {
sessionBytes, err := os.ReadFile(path) sessionBytes, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -53,20 +53,19 @@ func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfi
} }
// LoadSessionBytesWithOptions loads session configuration from YAML bytes with // LoadSessionBytesWithOptions loads session configuration from YAML bytes with
// strict field checking after template rendering. // strict field checking.
func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOptions) (*SessionConfig, error) { func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOptions) (*SessionConfig, error) {
rendered, err := renderSessionTemplate(string(data), opts) if err := rejectSessionTemplatePlaceholders(label, string(data)); err != nil {
if err != nil {
return nil, fmt.Errorf("load session config: %w", err) return nil, fmt.Errorf("load session config: %w", err)
} }
var cfg SessionConfig var cfg SessionConfig
if err := decodeStrictYAMLFromReader("session", label, strings.NewReader(rendered), &cfg); err != nil { if err := decodeStrictYAMLFromReader("session", label, strings.NewReader(string(data)), &cfg); err != nil {
return nil, fmt.Errorf("load session config: %w", err) return nil, fmt.Errorf("load session config: %w", err)
} }
if strings.TrimSpace(opts.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != strings.TrimSpace(opts.SessionID) { if strings.TrimSpace(opts.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != strings.TrimSpace(opts.SessionID) {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"load session config: session file %q: session_id mismatch: --session-id %q does not match rendered session_id %q", "load session config: session file %q: session_id mismatch: --session-id %q does not match session_id %q",
label, label,
strings.TrimSpace(opts.SessionID), strings.TrimSpace(opts.SessionID),
strings.TrimSpace(cfg.SessionID), strings.TrimSpace(cfg.SessionID),
@@ -76,7 +75,7 @@ func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOpti
strings.TrimSpace(cfg.PreviousSessionID) != "" && strings.TrimSpace(cfg.PreviousSessionID) != "" &&
strings.TrimSpace(cfg.PreviousSessionID) != strings.TrimSpace(opts.PreviousSessionID) { strings.TrimSpace(cfg.PreviousSessionID) != strings.TrimSpace(opts.PreviousSessionID) {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match rendered previous_session_id %q", "load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match previous_session_id %q",
label, label,
strings.TrimSpace(opts.PreviousSessionID), strings.TrimSpace(opts.PreviousSessionID),
strings.TrimSpace(cfg.PreviousSessionID), strings.TrimSpace(cfg.PreviousSessionID),
@@ -124,7 +123,7 @@ func Load(pipelinePath string, paths ...string) (*Config, error) {
} }
// LoadWithSessionOptions loads and resolves combined pipeline, campaign, and // LoadWithSessionOptions loads and resolves combined pipeline, campaign, and
// session configuration with session template options. // session configuration with expected session identity checks.
func LoadWithSessionOptions(pipelinePath, campaignPath, 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 {
@@ -195,7 +194,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
return ResolvedStableInputs{}, fmt.Errorf("session config is required") return ResolvedStableInputs{}, fmt.Errorf("session config is required")
} }
campaignName := strings.TrimSpace(campaignCfg.Campaign) campaignName := CampaignID(campaignCfg)
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign) sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName { if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName {
return ResolvedStableInputs{}, fmt.Errorf( return ResolvedStableInputs{}, fmt.Errorf(
@@ -235,6 +234,14 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
return stable, nil return stable, nil
} }
// CampaignID returns the canonical campaign identity from campaign config.
func CampaignID(cfg *CampaignConfig) string {
if cfg == nil {
return ""
}
return strings.TrimSpace(cfg.CampaignID)
}
func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath string) ResolvedInputFile { func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath string) ResolvedInputFile {
if strings.TrimSpace(sessionValue) != "" { if strings.TrimSpace(sessionValue) != "" {
return ResolvedInputFile{ return ResolvedInputFile{
@@ -275,75 +282,33 @@ func decodeStrictYAMLFromReader(kind, path string, r io.Reader, out any) error {
return nil return nil
} }
var sessionTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`) var sessionTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^}]*\}\}`)
var sessionTemplateVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) { func rejectSessionTemplatePlaceholders(label, content string) error {
sessionID := strings.TrimSpace(opts.SessionID) placeholders := sessionTemplatePlaceholderPattern.FindAllString(content, -1)
previousSessionID := strings.TrimSpace(opts.PreviousSessionID) if len(placeholders) == 0 {
rendered := content return nil
if sessionID != "" {
rendered = replaceTemplateVariable(rendered, "session_id", sessionID)
} }
if previousSessionID != "" {
rendered = replaceTemplateVariable(rendered, "previous_session_id", previousSessionID)
}
unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1)
if len(unresolved) > 0 {
seenVars := map[string]struct{}{}
vars := make([]string, 0, len(unresolved))
for _, m := range unresolved {
if len(m) > 1 {
name := m[1]
if _, ok := seenVars[name]; ok {
continue
}
seenVars[name] = struct{}{}
vars = append(vars, name)
}
}
sort.Strings(vars)
if len(vars) > 0 {
hints := unresolvedTemplateHints(vars)
return "", fmt.Errorf(
"session file template rendering failed: unresolved template variable(s): %s%s",
strings.Join(vars, ", "),
hints,
)
}
return "", fmt.Errorf("session file template rendering failed: unresolved template placeholders remain")
}
return rendered, nil
}
func replaceTemplateVariable(content, name, value string) string {
rendered := strings.ReplaceAll(content, "{{"+name+"}}", value)
rendered = strings.ReplaceAll(rendered, "{{ "+name+" }}", value)
return rendered
}
func unresolvedTemplateHints(vars []string) string {
seen := map[string]struct{}{} seen := map[string]struct{}{}
flags := make([]string, 0, 2) vars := make([]string, 0, len(placeholders))
for _, name := range vars { for _, placeholder := range placeholders {
switch name { name := strings.TrimSpace(placeholder)
case "session_id": if match := sessionTemplateVariablePattern.FindStringSubmatch(placeholder); len(match) > 1 {
if _, ok := seen["--session-id"]; !ok { name = match[1]
seen["--session-id"] = struct{}{}
flags = append(flags, "--session-id")
}
case "previous_session_id":
if _, ok := seen["--previous-session-id"]; !ok {
seen["--previous-session-id"] = struct{}{}
flags = append(flags, "--previous-session-id")
}
} }
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
vars = append(vars, name)
} }
if len(flags) == 0 { sort.Strings(vars)
return "" return fmt.Errorf(
} "session file %q contains template placeholder(s): %s; session.yml must be concrete; run narratio session init to generate it",
return "; pass " + strings.Join(flags, " and ") + " when using those template variable(s)" label,
strings.Join(vars, ", "),
)
} }
func shortName(path, fallback string) string { func shortName(path, fallback string) string {
@@ -359,6 +324,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
return return
} }
applyWorkspaceDefaults(&cfg.Workspace) applyWorkspaceDefaults(&cfg.Workspace)
applyCampaignsDefaults(&cfg.Campaigns)
applyStorageDefaults(&cfg.Storage) applyStorageDefaults(&cfg.Storage)
applySpoolDefaults(&cfg.Spool) applySpoolDefaults(&cfg.Spool)
applyCacheDefaults(&cfg.Cache) applyCacheDefaults(&cfg.Cache)
@@ -374,6 +340,15 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
applyScriptoriumDefaults(cfg.Scriptorium) applyScriptoriumDefaults(cfg.Scriptorium)
} }
func applyCampaignsDefaults(cfg *CampaignsConfig) {
if cfg == nil {
return
}
if cfg.Root == "" {
cfg.Root = DefaultCampaignsRoot
}
}
func applyWorkspaceDefaults(cfg *WorkspaceConfig) { func applyWorkspaceDefaults(cfg *WorkspaceConfig) {
if cfg == nil { if cfg == nil {
return return

View File

@@ -27,8 +27,6 @@ seriatim:
binary: seriatim binary: seriatim
audita: audita:
binary: audita binary: audita
analyzer:
timeout: 20m
notification: notification:
timeout: 15s timeout: 15s
`, `,
@@ -48,8 +46,6 @@ inputs:
root: /tmp/narratio root: /tmp/narratio
whisperx: whisperx:
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
analyzer:
timeout: 20m
notification: notification:
timeout: 15s timeout: 15s
`, `,
@@ -67,8 +63,6 @@ inputs:
name: "workspace root defaults when omitted", name: "workspace root defaults when omitted",
pipelineYAML: `whisperx: pipelineYAML: `whisperx:
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
analyzer:
timeout: 20m
notification: notification:
timeout: 15s timeout: 15s
`, `,
@@ -97,6 +91,24 @@ inputs:
`, `,
wantLoadErr: "pipeline file", wantLoadErr: "pipeline file",
}, },
{
name: "legacy analyzer section fails strict decode",
pipelineYAML: `workspace:
root: /tmp/narratio
whisperx:
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
analyzer:
timeout: 20m
`,
sessionYAML: `session_id: 2026-05-03
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`,
wantLoadErr: "strict decode failed",
},
{ {
name: "unknown whisperx field fails", name: "unknown whisperx field fails",
pipelineYAML: `workspace: pipelineYAML: `workspace:
@@ -841,8 +853,8 @@ inputs:
if cfg.Pipeline.Normalize == nil { if cfg.Pipeline.Normalize == nil {
t.Fatal("normalize config should be present via defaults") t.Fatal("normalize config should be present via defaults")
} }
if cfg.Pipeline.Normalize.OutputPath != "transcripts/normalized.json" { if cfg.Pipeline.Normalize.OutputPath != "transcripts/final.json" {
t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/normalized.json") t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/final.json")
} }
if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" { if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" {
t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate") t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate")
@@ -904,7 +916,7 @@ func TestValidateMissingAudioSource(t *testing.T) {
Report: boolPtr(true), Report: boolPtr(true),
}, },
}, },
Campaign: &CampaignConfig{Campaign: "sample-campaign"}, Campaign: &CampaignConfig{CampaignID: "sample-campaign"},
Session: &SessionConfig{ Session: &SessionConfig{
SessionID: "2026-05-03", SessionID: "2026-05-03",
Campaign: "sample-campaign", Campaign: "sample-campaign",
@@ -934,7 +946,6 @@ func TestExamplesLoadAndValidate(t *testing.T) {
name string name string
pipelineFile string pipelineFile string
sessionFile string sessionFile string
sessionOpts SessionLoadOptions
}{ }{
{ {
name: "minimal pipeline with local audio session", name: "minimal pipeline with local audio session",
@@ -951,31 +962,15 @@ func TestExamplesLoadAndValidate(t *testing.T) {
pipelineFile: "pipeline.full.annotated.yml", pipelineFile: "pipeline.full.annotated.yml",
sessionFile: "session.local-audio.yml", sessionFile: "session.local-audio.yml",
}, },
{
name: "template session renders with session_id option",
pipelineFile: "pipeline.minimal.yml",
sessionFile: "session.template.yml",
sessionOpts: SessionLoadOptions{
SessionID: "2026-05-03",
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
pipelinePath := filepath.Join(examplesDir, tt.pipelineFile) pipelinePath := filepath.Join(examplesDir, tt.pipelineFile)
campaignPath := filepath.Join(examplesDir, "campaign.yml") campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml")
sessionPath := filepath.Join(examplesDir, tt.sessionFile) sessionPath := filepath.Join(examplesDir, tt.sessionFile)
var ( cfg, err := Load(pipelinePath, campaignPath, sessionPath)
cfg *Config
err error
)
if strings.TrimSpace(tt.sessionOpts.SessionID) == "" {
cfg, err = Load(pipelinePath, sessionPath)
} else {
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)
} }
@@ -1009,7 +1004,7 @@ func writeConfigFiles(t *testing.T, pipelineYAML, sessionYAML string) (string, s
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil { if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err) t.Fatalf("write pipeline.yml: %v", err)
} }
campaignYAML := `campaign: ` + campaignNameFromSessionYAML(sessionYAML) + ` campaignYAML := `campaign_id: ` + campaignNameFromSessionYAML(sessionYAML) + `
inputs: inputs:
speakers_file: ./speakers.yml speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml autocorrect_file: ./autocorrect.yml

View File

@@ -21,8 +21,8 @@ func TestNormalizeLoadAndValidate(t *testing.T) {
if cfg.Pipeline.Normalize == nil { if cfg.Pipeline.Normalize == nil {
t.Fatal("normalize config should be present via defaults") t.Fatal("normalize config should be present via defaults")
} }
if cfg.Pipeline.Normalize.OutputPath != "transcripts/normalized.json" { if cfg.Pipeline.Normalize.OutputPath != "transcripts/final.json" {
t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/normalized.json") t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/final.json")
} }
if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" { if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" {
t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate") t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate")
@@ -58,7 +58,7 @@ func TestNormalizeLoadAndValidate(t *testing.T) {
{ {
name: "invalid normalize output schema fails", name: "invalid normalize output schema fails",
normalizeYAML: `normalize: normalizeYAML: `normalize:
output_path: transcripts/normalized.json output_path: transcripts/final.json
output_schema: not-a-schema output_schema: not-a-schema
report: true report: true
`, `,
@@ -76,7 +76,7 @@ func TestNormalizeLoadAndValidate(t *testing.T) {
{ {
name: "unknown normalize field fails strict decoding", name: "unknown normalize field fails strict decoding",
normalizeYAML: `normalize: normalizeYAML: `normalize:
output_path: transcripts/normalized.json output_path: transcripts/final.json
output_schema: seriatim-intermediate output_schema: seriatim-intermediate
report: true report: true
bogus: true bogus: true

View File

@@ -6,6 +6,7 @@ import (
) )
func TestScriptoriumLoadAndValidate(t *testing.T) { func TestScriptoriumLoadAndValidate(t *testing.T) {
legacyPreviousSource := "previous_session_" + "artifact"
tests := []struct { tests := []struct {
name string name string
scriptoriumYAML string scriptoriumYAML string
@@ -94,7 +95,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
wantValidateErr: "pipeline.scriptorium.timeout must be a valid duration", wantValidateErr: "pipeline.scriptorium.timeout must be a valid duration",
}, },
{ {
name: "optional previous recap input is accepted", name: "legacy previous session artifact source fails validation",
scriptoriumYAML: `scriptorium: scriptoriumYAML: `scriptorium:
binary: scriptorium binary: scriptorium
artifacts: artifacts:
@@ -107,7 +108,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
source: narratio.transcript.polished source: narratio.transcript.polished
required: true required: true
previous_recap: previous_recap:
source: previous_session_artifact source: ` + legacyPreviousSource + `
artifact: session_recap artifact: session_recap
path: "" path: ""
required: false required: false
@@ -115,6 +116,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
session_id: true session_id: true
output_kind: session_recap output_kind: session_recap
`, `,
wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "` + legacyPreviousSource + `" is unsupported`,
}, },
{ {
name: "canonical previous-session source is accepted", name: "canonical previous-session source is accepted",
@@ -196,7 +198,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
output_path: artifacts/session_recap.md output_path: artifacts/session_recap.md
inputs: inputs:
transcript: transcript:
source: narratio.transcript.trimmed source: narratio.transcript.final_trimmed
required: true required: true
`, `,
}, },
@@ -285,7 +287,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
output_path: artifacts/session_recap.md output_path: artifacts/session_recap.md
inputs: inputs:
transcript: transcript:
source: narratio.transcript.trimmed source: narratio.transcript.final_trimmed
required: true required: true
player_handout: player_handout:
enabled: true enabled: true
@@ -505,6 +507,42 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
} }
} }
func TestScriptoriumLegacyTranscriptSourcesRejected(t *testing.T) {
legacyTranscriptSources := []string{
"narratio.transcript." + "merged",
"narratio.transcript." + "full",
"narratio.transcript." + "trimmed",
}
for _, source := range legacyTranscriptSources {
t.Run(source, func(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + `
scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: ` + source + `
required: true
`
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
err = Validate(cfg)
wantErr := `pipeline.scriptorium.artifacts.session_recap.inputs.transcript.source "` + source + `" is unsupported`
if err == nil || !strings.Contains(err.Error(), wantErr) {
t.Fatalf("Validate() error = %v, want to contain %q", err, wantErr)
}
})
}
}
const testPipelineBaseYAML = `workspace: const testPipelineBaseYAML = `workspace:
root: /tmp/narratio root: /tmp/narratio
whisperx: whisperx:

View File

@@ -7,7 +7,7 @@ import (
"testing" "testing"
) )
func TestLoadSessionWithOptionsRendersCompactPlaceholder(t *testing.T) { func TestLoadSessionWithOptionsRejectsCompactPlaceholder(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: "{{session_id}}" sessionYAML := `session_id: "{{session_id}}"
@@ -22,40 +22,14 @@ inputs:
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)
} }
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"}) _, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"})
if err != nil { if err == nil {
t.Fatalf("LoadSessionWithOptions() error = %v", err) t.Fatal("expected error, got nil")
}
if cfg.SessionID != "2026-04-04" {
t.Fatalf("SessionID = %q, want 2026-04-04", cfg.SessionID)
} }
assertConcreteSessionTemplateError(t, err, "session_id")
} }
func TestLoadSessionWithOptionsRendersSpacedPlaceholder(t *testing.T) { func TestLoadSessionWithOptionsRejectsSpacedPlaceholder(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: "{{ session_id }}"
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"})
if err != nil {
t.Fatalf("LoadSessionWithOptions() error = %v", err)
}
if cfg.SessionID != "2026-04-04" {
t.Fatalf("SessionID = %q, want 2026-04-04", cfg.SessionID)
}
}
func TestLoadSessionWithOptionsRendersPreviousSessionPlaceholder(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: "{{ session_id }}" sessionYAML := `session_id: "{{ session_id }}"
@@ -71,74 +45,14 @@ inputs:
t.Fatalf("write session.yml: %v", err) t.Fatalf("write session.yml: %v", err)
} }
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{ _, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{
SessionID: "2026-04-04", SessionID: "2026-04-04",
PreviousSessionID: "2026-03-28", PreviousSessionID: "2026-03-28",
}) })
if err != nil {
t.Fatalf("LoadSessionWithOptions() error = %v", err)
}
if cfg.PreviousSessionID != "2026-03-28" {
t.Fatalf("PreviousSessionID = %q, want 2026-03-28", cfg.PreviousSessionID)
}
}
func TestLoadSessionWithOptionsUnresolvedPlaceholderFails(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: "{{ session_id }}"
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{})
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if !strings.Contains(err.Error(), "unresolved template variable") { assertConcreteSessionTemplateError(t, err, "session_id", "previous_session_id")
t.Fatalf("error = %q, want unresolved-variable context", err.Error())
}
if !strings.Contains(err.Error(), "session_id") {
t.Fatalf("error = %q, want session_id variable", err.Error())
}
}
func TestLoadSessionWithOptionsUnresolvedPreviousSessionPlaceholderFails(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: 2026-05-03
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "unresolved template variable") {
t.Fatalf("error = %q, want unresolved-variable context", err.Error())
}
if !strings.Contains(err.Error(), "previous_session_id") {
t.Fatalf("error = %q, want previous_session_id variable", err.Error())
}
if !strings.Contains(err.Error(), "--previous-session-id") {
t.Fatalf("error = %q, want previous-session-id guidance", err.Error())
}
} }
func TestLoadSessionWithOptionsMismatchFails(t *testing.T) { func TestLoadSessionWithOptionsMismatchFails(t *testing.T) {
@@ -163,6 +77,9 @@ inputs:
if !strings.Contains(err.Error(), "session_id mismatch") { if !strings.Contains(err.Error(), "session_id mismatch") {
t.Fatalf("error = %q, want mismatch context", err.Error()) t.Fatalf("error = %q, want mismatch context", err.Error())
} }
if strings.Contains(err.Error(), "rendered") {
t.Fatalf("error = %q, should not mention rendered session", err.Error())
}
} }
func TestLoadSessionWithOptionsPreviousSessionMismatchFails(t *testing.T) { func TestLoadSessionWithOptionsPreviousSessionMismatchFails(t *testing.T) {
@@ -191,12 +108,15 @@ inputs:
if !strings.Contains(err.Error(), "previous_session_id mismatch") { if !strings.Contains(err.Error(), "previous_session_id mismatch") {
t.Fatalf("error = %q, want mismatch context", err.Error()) t.Fatalf("error = %q, want mismatch context", err.Error())
} }
if strings.Contains(err.Error(), "rendered") {
t.Fatalf("error = %q, should not mention rendered session", err.Error())
}
} }
func TestLoadSessionWithOptionsUnknownFieldStillRejectedAfterRendering(t *testing.T) { func TestLoadSessionWithOptionsUnknownFieldStillRejected(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml") sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: "{{ session_id }}" sessionYAML := `session_id: 2026-04-04
campaign: sample-campaign campaign: sample-campaign
unknown_field: true unknown_field: true
inputs: inputs:
@@ -242,7 +162,7 @@ inputs:
} }
} }
func TestLoadSessionBytesWithOptionsUsesSameTemplateAndStrictDecode(t *testing.T) { func TestLoadSessionBytesWithOptionsRejectsPlaceholder(t *testing.T) {
sessionYAML := []byte(`session_id: "{{ session_id }}" sessionYAML := []byte(`session_id: "{{ session_id }}"
campaign: sample-campaign campaign: sample-campaign
inputs: inputs:
@@ -250,7 +170,15 @@ inputs:
prefix: audio/ prefix: audio/
`) `)
cfg, err := LoadSessionBytesWithOptions("s3://bucket/session.yml", sessionYAML, SessionLoadOptions{SessionID: "2026-05-03"}) _, err := LoadSessionBytesWithOptions("s3://bucket/session.yml", sessionYAML, SessionLoadOptions{SessionID: "2026-05-03"})
if err == nil {
t.Fatal("expected error, got nil")
}
assertConcreteSessionTemplateError(t, err, "session_id")
}
func TestLoadSessionBytesWithOptionsStrictDecode(t *testing.T) {
cfg, err := LoadSessionBytesWithOptions("s3://bucket/session.yml", []byte("session_id: 2026-05-03\n"), SessionLoadOptions{})
if err != nil { if err != nil {
t.Fatalf("LoadSessionBytesWithOptions() error = %v", err) t.Fatalf("LoadSessionBytesWithOptions() error = %v", err)
} }
@@ -276,3 +204,18 @@ func TestLoadSessionBytesWithOptionsMismatchFails(t *testing.T) {
t.Fatalf("error = %q, want mismatch context", err.Error()) t.Fatalf("error = %q, want mismatch context", err.Error())
} }
} }
func assertConcreteSessionTemplateError(t *testing.T, err error, vars ...string) {
t.Helper()
if !strings.Contains(err.Error(), "session.yml must be concrete") {
t.Fatalf("error = %q, want concrete session guidance", err.Error())
}
if !strings.Contains(err.Error(), "run narratio session init") {
t.Fatalf("error = %q, want session init guidance", err.Error())
}
for _, name := range vars {
if !strings.Contains(err.Error(), name) {
t.Fatalf("error = %q, want variable %q", err.Error(), name)
}
}
}

View File

@@ -39,6 +39,49 @@ storage:
} }
} }
func TestStorageLegacyTopLevelFieldsFailStrictDecode(t *testing.T) {
tests := []struct {
name string
storageYAML string
wantField string
}{
{
name: "bucket",
storageYAML: `
storage:
bucket: my-dnd-archive
`,
wantField: "bucket",
},
{
name: "prefix",
storageYAML: `
storage:
prefix: dnd
`,
wantField: "prefix",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + tt.storageYAML
pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
_, err := LoadPipeline(pipelinePath)
if err == nil {
t.Fatal("LoadPipeline() error = nil, want strict decode error")
}
if !strings.Contains(err.Error(), "strict decode failed") {
t.Fatalf("LoadPipeline() error = %v, want strict decode failed", err)
}
if !strings.Contains(err.Error(), tt.wantField) {
t.Fatalf("LoadPipeline() error = %v, want field %q", err, tt.wantField)
}
})
}
}
func TestStorageS3CredentialEnvNamesLoadAndValidate(t *testing.T) { func TestStorageS3CredentialEnvNamesLoadAndValidate(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + ` pipelineYAML := testPipelineBaseYAML + `
storage: storage:
@@ -143,11 +186,11 @@ func TestSpoolAndArchiveDefaults(t *testing.T) {
if item.Required == nil || !*item.Required { if item.Required == nil || !*item.Required {
t.Fatalf("archive.promote_artifacts[0].required = %#v, want true", item.Required) t.Fatalf("archive.promote_artifacts[0].required = %#v, want true", item.Required)
} }
if item.Source != "narratio.transcript.trimmed" { if item.Source != "narratio.transcript.final_trimmed" {
t.Fatalf("archive.promote_artifacts[0].source = %q, want narratio.transcript.trimmed", item.Source) t.Fatalf("archive.promote_artifacts[0].source = %q, want narratio.transcript.final_trimmed", item.Source)
} }
if item.Dest != "transcripts/trimmed.json" { if item.Dest != "transcripts/final.trimmed.json" {
t.Fatalf("archive.promote_artifacts[0].dest = %q, want transcripts/trimmed.json", item.Dest) t.Fatalf("archive.promote_artifacts[0].dest = %q, want transcripts/final.trimmed.json", item.Dest)
} }
} }
@@ -161,8 +204,8 @@ func TestArchivePromotionValidation(t *testing.T) {
name: "absolute dest path rejected", name: "absolute dest path rejected",
ruleYML: `archive: ruleYML: `archive:
promote_artifacts: promote_artifacts:
- source: "narratio.transcript.trimmed" - source: "narratio.transcript.final_trimmed"
dest: "/transcripts/trimmed.json" dest: "/transcripts/final.trimmed.json"
`, `,
wantErr: "must be a relative path", wantErr: "must be a relative path",
}, },
@@ -170,7 +213,7 @@ func TestArchivePromotionValidation(t *testing.T) {
name: "traversal dest path rejected", name: "traversal dest path rejected",
ruleYML: `archive: ruleYML: `archive:
promote_artifacts: promote_artifacts:
- source: "narratio.transcript.trimmed" - source: "narratio.transcript.final_trimmed"
dest: "../trimmed.json" dest: "../trimmed.json"
`, `,
wantErr: "must not contain path traversal", wantErr: "must not contain path traversal",
@@ -180,7 +223,7 @@ func TestArchivePromotionValidation(t *testing.T) {
ruleYML: `archive: ruleYML: `archive:
promote_artifacts: promote_artifacts:
- source: "narratio.unknown" - source: "narratio.unknown"
dest: "transcripts/trimmed.json" dest: "transcripts/final.trimmed.json"
`, `,
wantErr: "source \"narratio.unknown\" is unsupported", wantErr: "source \"narratio.unknown\" is unsupported",
}, },
@@ -188,9 +231,9 @@ func TestArchivePromotionValidation(t *testing.T) {
name: "duplicate destination rejected", name: "duplicate destination rejected",
ruleYML: `archive: ruleYML: `archive:
promote_artifacts: promote_artifacts:
- source: "narratio.transcript.trimmed" - source: "narratio.transcript.final_trimmed"
dest: "artifacts/shared.md" dest: "artifacts/shared.md"
- source: "narratio.transcript.full" - source: "narratio.transcript.final"
dest: "artifacts/shared.md" dest: "artifacts/shared.md"
`, `,
wantErr: "duplicates another archive promotion destination", wantErr: "duplicates another archive promotion destination",
@@ -235,6 +278,35 @@ archive:
} }
} }
func TestArchivePromotionLegacyTranscriptSourcesRejected(t *testing.T) {
legacyTranscriptSources := []string{
"narratio.transcript." + "merged",
"narratio.transcript." + "full",
"narratio.transcript." + "trimmed",
}
for _, source := range legacyTranscriptSources {
t.Run(source, func(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + `
archive:
promote_artifacts:
- source: ` + source + `
dest: transcripts/final.trimmed.json
`
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
err = Validate(cfg)
wantErr := `source "` + source + `" is unsupported`
if err == nil || !strings.Contains(err.Error(), wantErr) {
t.Fatalf("Validate() error = %v, want to contain %q", err, wantErr)
}
})
}
}
func TestArchivePromotionDerivesDestinationWhenOmitted(t *testing.T) { func TestArchivePromotionDerivesDestinationWhenOmitted(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -246,9 +318,9 @@ func TestArchivePromotionDerivesDestinationWhenOmitted(t *testing.T) {
pipelineYML: testPipelineBaseYAML + ` pipelineYML: testPipelineBaseYAML + `
archive: archive:
promote_artifacts: promote_artifacts:
- source: narratio.transcript.full - source: narratio.transcript.final
`, `,
wantDest: "transcripts/normalized.json", wantDest: "transcripts/final.json",
}, },
{ {
name: "configured source derives configured output path", name: "configured source derives configured output path",
@@ -298,7 +370,7 @@ func TestArchiveLockValidation(t *testing.T) {
pipelineYML: testPipelineBaseYAML + ` pipelineYML: testPipelineBaseYAML + `
archive: archive:
locks: locks:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
reason: reviewed transcript reason: reviewed transcript
`, `,
}, },
@@ -339,8 +411,8 @@ archive:
pipelineYML: testPipelineBaseYAML + ` pipelineYML: testPipelineBaseYAML + `
archive: archive:
locks: locks:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
- source: " narratio.transcript.trimmed " - source: " narratio.transcript.final_trimmed "
`, `,
wantErr: "duplicates another archive lock source", wantErr: "duplicates another archive lock source",
}, },
@@ -367,12 +439,40 @@ archive:
} }
} }
func TestArchiveLockLegacyTranscriptSourcesRejected(t *testing.T) {
legacyTranscriptSources := []string{
"narratio.transcript." + "merged",
"narratio.transcript." + "full",
"narratio.transcript." + "trimmed",
}
for _, source := range legacyTranscriptSources {
t.Run(source, func(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + `
archive:
locks:
- source: ` + source + `
`
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
err = Validate(cfg)
wantErr := `source "` + source + `" is unsupported`
if err == nil || !strings.Contains(err.Error(), wantErr) {
t.Fatalf("Validate() error = %v, want to contain %q", err, wantErr)
}
})
}
}
func TestArchiveLockUnknownFieldFailsStrictDecode(t *testing.T) { func TestArchiveLockUnknownFieldFailsStrictDecode(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + ` pipelineYAML := testPipelineBaseYAML + `
archive: archive:
locks: locks:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/trimmed.json dest: transcripts/final.trimmed.json
` `
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
_, err := Load(pipelinePath, sessionPath) _, err := Load(pipelinePath, sessionPath)
@@ -385,8 +485,8 @@ func TestArchiveLegacyFromToFailsStrictDecode(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + ` pipelineYAML := testPipelineBaseYAML + `
archive: archive:
promote_artifacts: promote_artifacts:
- from: transcripts/trimmed.json - from: transcripts/final.trimmed.json
to: transcripts/trimmed.json to: transcripts/final.trimmed.json
` `
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML) pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
_, err := Load(pipelinePath, sessionPath) _, err := Load(pipelinePath, sessionPath)
@@ -397,27 +497,27 @@ archive:
func TestArchiveLockStoreBytesStrictDecodeAndValidation(t *testing.T) { func TestArchiveLockStoreBytesStrictDecodeAndValidation(t *testing.T) {
store, err := LoadArchiveLockStoreBytes("locks.yml", []byte(`locks: store, err := LoadArchiveLockStoreBytes("locks.yml", []byte(`locks:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
reason: reviewed reason: reviewed
`), nil) `), nil)
if err != nil { if err != nil {
t.Fatalf("LoadArchiveLockStoreBytes() error = %v", err) t.Fatalf("LoadArchiveLockStoreBytes() error = %v", err)
} }
if len(store.Locks) != 1 || store.Locks[0].Source != "narratio.transcript.trimmed" || store.Locks[0].Reason != "reviewed" { if len(store.Locks) != 1 || store.Locks[0].Source != "narratio.transcript.final_trimmed" || store.Locks[0].Reason != "reviewed" {
t.Fatalf("locks = %#v", store.Locks) t.Fatalf("locks = %#v", store.Locks)
} }
_, err = LoadArchiveLockStoreBytes("locks.yml", []byte(`locks: _, err = LoadArchiveLockStoreBytes("locks.yml", []byte(`locks:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/trimmed.json dest: transcripts/final.trimmed.json
`), nil) `), nil)
if err == nil || !strings.Contains(err.Error(), "strict decode failed") { if err == nil || !strings.Contains(err.Error(), "strict decode failed") {
t.Fatalf("unknown field error = %v, want strict decode failed", err) t.Fatalf("unknown field error = %v, want strict decode failed", err)
} }
_, err = LoadArchiveLockStoreBytes("locks.yml", []byte(`locks: _, err = LoadArchiveLockStoreBytes("locks.yml", []byte(`locks:
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
- source: narratio.transcript.trimmed - source: narratio.transcript.final_trimmed
`), nil) `), nil)
if err == nil || !strings.Contains(err.Error(), "duplicates another archive lock source") { if err == nil || !strings.Contains(err.Error(), "duplicates another archive lock source") {
t.Fatalf("duplicate error = %v", err) t.Fatalf("duplicate error = %v", err)
@@ -426,19 +526,19 @@ func TestArchiveLockStoreBytesStrictDecodeAndValidation(t *testing.T) {
func TestMergeArchiveLockRulesStaticWins(t *testing.T) { func TestMergeArchiveLockRulesStaticWins(t *testing.T) {
merged := MergeArchiveLockRules( merged := MergeArchiveLockRules(
[]ArchiveLockRule{{Source: "narratio.transcript.trimmed", Reason: "static"}}, []ArchiveLockRule{{Source: "narratio.transcript.final_trimmed", Reason: "static"}},
[]ArchiveLockRule{ []ArchiveLockRule{
{Source: "narratio.transcript.trimmed", Reason: "remote"}, {Source: "narratio.transcript.final_trimmed", Reason: "remote"},
{Source: "narratio.transcript.full", Reason: "remote full"}, {Source: "narratio.transcript.final", Reason: "remote full"},
}, },
) )
if len(merged) != 2 { if len(merged) != 2 {
t.Fatalf("merged len = %d, want 2: %#v", len(merged), merged) t.Fatalf("merged len = %d, want 2: %#v", len(merged), merged)
} }
if merged[0].Source != "narratio.transcript.trimmed" || merged[0].Reason != "static" { if merged[0].Source != "narratio.transcript.final_trimmed" || merged[0].Reason != "static" {
t.Fatalf("merged[0] = %#v, want static lock", merged[0]) t.Fatalf("merged[0] = %#v, want static lock", merged[0])
} }
if merged[1].Source != "narratio.transcript.full" { if merged[1].Source != "narratio.transcript.final" {
t.Fatalf("merged[1] = %#v, want remote full lock", merged[1]) t.Fatalf("merged[1] = %#v, want remote full lock", merged[1])
} }
} }

View File

@@ -17,7 +17,7 @@ func TestTrimLoadAndValidate(t *testing.T) {
name: "valid trim config", name: "valid trim config",
trimYAML: `trim: trimYAML: `trim:
enabled: true enabled: true
output_path: transcripts/trimmed.json output_path: transcripts/final.trimmed.json
bounds: bounds:
prompt_id: dnd_session.bounds prompt_id: dnd_session.bounds
profile_id: "" profile_id: ""
@@ -45,7 +45,7 @@ func TestTrimLoadAndValidate(t *testing.T) {
{ {
name: "enabled omitted defaults disabled", name: "enabled omitted defaults disabled",
trimYAML: `trim: trimYAML: `trim:
output_path: transcripts/trimmed.json output_path: transcripts/final.trimmed.json
bounds: bounds:
prompt_id: dnd_session.bounds prompt_id: dnd_session.bounds
transcript_input_name: transcript transcript_input_name: transcript
@@ -65,7 +65,7 @@ func TestTrimLoadAndValidate(t *testing.T) {
name: "missing prompt id fails when enabled", name: "missing prompt id fails when enabled",
trimYAML: `trim: trimYAML: `trim:
enabled: true enabled: true
output_path: transcripts/trimmed.json output_path: transcripts/final.trimmed.json
bounds: bounds:
transcript_input_name: transcript transcript_input_name: transcript
output_path: artifacts/session_bounds.json output_path: artifacts/session_bounds.json
@@ -76,7 +76,7 @@ func TestTrimLoadAndValidate(t *testing.T) {
name: "missing transcript input name fails when enabled", name: "missing transcript input name fails when enabled",
trimYAML: `trim: trimYAML: `trim:
enabled: true enabled: true
output_path: transcripts/trimmed.json output_path: transcripts/final.trimmed.json
bounds: bounds:
prompt_id: dnd_session.bounds prompt_id: dnd_session.bounds
output_path: artifacts/session_bounds.json output_path: artifacts/session_bounds.json
@@ -87,7 +87,7 @@ func TestTrimLoadAndValidate(t *testing.T) {
name: "missing bounds output path fails when enabled", name: "missing bounds output path fails when enabled",
trimYAML: `trim: trimYAML: `trim:
enabled: true enabled: true
output_path: transcripts/trimmed.json output_path: transcripts/final.trimmed.json
bounds: bounds:
prompt_id: dnd_session.bounds prompt_id: dnd_session.bounds
transcript_input_name: transcript transcript_input_name: transcript
@@ -109,7 +109,7 @@ func TestTrimLoadAndValidate(t *testing.T) {
name: "invalid timeout fails", name: "invalid timeout fails",
trimYAML: `trim: trimYAML: `trim:
enabled: true enabled: true
output_path: transcripts/trimmed.json output_path: transcripts/final.trimmed.json
bounds: bounds:
prompt_id: dnd_session.bounds prompt_id: dnd_session.bounds
transcript_input_name: transcript transcript_input_name: transcript
@@ -122,7 +122,7 @@ func TestTrimLoadAndValidate(t *testing.T) {
name: "render debug true requires render output path", name: "render debug true requires render output path",
trimYAML: `trim: trimYAML: `trim:
enabled: true enabled: true
output_path: transcripts/trimmed.json output_path: transcripts/final.trimmed.json
bounds: bounds:
prompt_id: dnd_session.bounds prompt_id: dnd_session.bounds
transcript_input_name: transcript transcript_input_name: transcript
@@ -135,7 +135,7 @@ func TestTrimLoadAndValidate(t *testing.T) {
name: "unknown trim field fails strict decoding", name: "unknown trim field fails strict decoding",
trimYAML: `trim: trimYAML: `trim:
enabled: true enabled: true
output_path: transcripts/trimmed.json output_path: transcripts/final.trimmed.json
bounds: bounds:
prompt_id: dnd_session.bounds prompt_id: dnd_session.bounds
transcript_input_name: transcript transcript_input_name: transcript

View File

@@ -7,6 +7,8 @@ import (
"regexp" "regexp"
"strings" "strings"
"time" "time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
) )
// Validate checks resolved configuration for required fields and parseable durations. // Validate checks resolved configuration for required fields and parseable durations.
@@ -44,8 +46,8 @@ func validateCampaign(cfg *CampaignConfig) error {
if cfg == nil { if cfg == nil {
return fmt.Errorf("campaign config is required") return fmt.Errorf("campaign config is required")
} }
if strings.TrimSpace(cfg.Campaign) == "" { if CampaignID(cfg) == "" {
return fmt.Errorf("campaign.campaign is required") return fmt.Errorf("campaign.campaign_id is required")
} }
return nil return nil
} }
@@ -87,9 +89,6 @@ func validatePipeline(cfg *PipelineConfig) error {
if err := validateScriptorium(cfg.Scriptorium); err != nil { if err := validateScriptorium(cfg.Scriptorium); err != nil {
return err return err
} }
if err := validateDuration("pipeline.analyzer.timeout", cfg.Analyzer.Timeout); err != nil {
return err
}
if err := validateDuration("pipeline.notification.timeout", cfg.Notification.Timeout); err != nil { if err := validateDuration("pipeline.notification.timeout", cfg.Notification.Timeout); err != nil {
return err return err
} }
@@ -227,12 +226,11 @@ func MergeArchiveLockRules(staticLocks, remoteLocks []ArchiveLockRule) []Archive
func archiveSourceKnown(source string, scriptorium *ScriptoriumConfig) (string, error) { func archiveSourceKnown(source string, scriptorium *ScriptoriumConfig) (string, error) {
trimmed := strings.TrimSpace(source) trimmed := strings.TrimSpace(source)
if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(trimmed); ok {
return "", nil
}
switch trimmed { switch trimmed {
case "narratio.transcript.merged", case "narratio.bounds.session":
"narratio.transcript.polished",
"narratio.transcript.full",
"narratio.transcript.trimmed",
"narratio.bounds.session":
return "", nil return "", nil
} }
matches := narratioArtifactSourceRE.FindStringSubmatch(trimmed) matches := narratioArtifactSourceRE.FindStringSubmatch(trimmed)
@@ -251,15 +249,10 @@ func archiveSourceKnown(source string, scriptorium *ScriptoriumConfig) (string,
func deriveArchivePromotionDest(source string, scriptorium *ScriptoriumConfig) (string, error) { func deriveArchivePromotionDest(source string, scriptorium *ScriptoriumConfig) (string, error) {
trimmed := strings.TrimSpace(source) trimmed := strings.TrimSpace(source)
if spec, ok := artifactmodel.LookupRuntimeTranscriptArtifact(trimmed); ok {
return spec.CanonicalRelPath, nil
}
switch trimmed { switch trimmed {
case "narratio.transcript.merged":
return PathTranscriptMerged, nil
case "narratio.transcript.polished":
return PathTranscriptProcessed, nil
case "narratio.transcript.full":
return PathTranscriptNormalized, nil
case "narratio.transcript.trimmed":
return PathTranscriptTrimmed, nil
case "narratio.bounds.session": case "narratio.bounds.session":
return filepath.ToSlash(filepath.Join(PathArtifactsDirSegment, "session_bounds.json")), nil return filepath.ToSlash(filepath.Join(PathArtifactsDirSegment, "session_bounds.json")), nil
} }
@@ -719,17 +712,10 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
} }
func isStaticSupportedScriptoriumInputSource(source string) bool { func isStaticSupportedScriptoriumInputSource(source string) bool {
if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(source); ok {
return true
}
switch source { switch source {
case "previous_session_artifact":
return true
case "narratio.transcript.merged":
return true
case "narratio.transcript.polished":
return true
case "narratio.transcript.full":
return true
case "narratio.transcript.trimmed":
return true
case "narratio.bounds.session": case "narratio.bounds.session":
return true return true
default: default:

View File

@@ -11,7 +11,7 @@ import (
func TestValidBoundsOutputProducesKeepSelector(t *testing.T) { func TestValidBoundsOutputProducesKeepSelector(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
boundsPath := filepath.Join(dir, "bounds.json") boundsPath := filepath.Join(dir, "bounds.json")
transcriptPath := filepath.Join(dir, "processed.json") transcriptPath := filepath.Join(dir, "polished.json")
writeBoundsTestFile(t, boundsPath, `{ writeBoundsTestFile(t, boundsPath, `{
"confidence":"high", "confidence":"high",
@@ -103,7 +103,7 @@ func TestBoundsNonExistentEndIDFails(t *testing.T) {
func TestInvalidTranscriptJSONFails(t *testing.T) { func TestInvalidTranscriptJSONFails(t *testing.T) {
bounds := SessionBounds{TrimAction: "trim", StartSegmentID: intPtr(1), EndSegmentID: intPtr(2)} bounds := SessionBounds{TrimAction: "trim", StartSegmentID: intPtr(1), EndSegmentID: intPtr(2)}
path := filepath.Join(t.TempDir(), "processed.json") path := filepath.Join(t.TempDir(), "polished.json")
writeBoundsTestFile(t, path, "not-json") writeBoundsTestFile(t, path, "not-json")
err := ValidateSessionBoundsAgainstTranscript(bounds, path) err := ValidateSessionBoundsAgainstTranscript(bounds, path)
@@ -117,7 +117,7 @@ func TestInvalidTranscriptJSONFails(t *testing.T) {
func TestTranscriptWithoutSegmentsFails(t *testing.T) { func TestTranscriptWithoutSegmentsFails(t *testing.T) {
bounds := SessionBounds{TrimAction: "trim", StartSegmentID: intPtr(1), EndSegmentID: intPtr(2)} bounds := SessionBounds{TrimAction: "trim", StartSegmentID: intPtr(1), EndSegmentID: intPtr(2)}
path := filepath.Join(t.TempDir(), "processed.json") path := filepath.Join(t.TempDir(), "polished.json")
writeBoundsTestFile(t, path, `{"schema":"audita.processed.v1"}`) writeBoundsTestFile(t, path, `{"schema":"audita.processed.v1"}`)
err := ValidateSessionBoundsAgainstTranscript(bounds, path) err := ValidateSessionBoundsAgainstTranscript(bounds, path)
@@ -170,7 +170,7 @@ func TestNoTrimActionCopyIsSupported(t *testing.T) {
func writeTranscriptWithIDs(t *testing.T, ids ...int) string { func writeTranscriptWithIDs(t *testing.T, ids ...int) string {
t.Helper() t.Helper()
path := filepath.Join(t.TempDir(), "processed.json") path := filepath.Join(t.TempDir(), "polished.json")
if len(ids) == 0 { if len(ids) == 0 {
writeBoundsTestFile(t, path, `{"segments":[]}`) writeBoundsTestFile(t, path, `{"segments":[]}`)
return path return path

View File

@@ -23,7 +23,7 @@ func TestStageMarkHelpers(t *testing.T) {
} }
succeededAt := runningAt.Add(2 * time.Minute) succeededAt := runningAt.Add(2 * time.Minute)
outputs := []ArtifactRecord{{Kind: "transcript_processed", LocalPath: "transcripts/processed.json"}} outputs := []ArtifactRecord{{Kind: "transcript_polished", LocalPath: "transcripts/polished.json"}}
m.MarkStageSucceeded("transcribe", succeededAt, outputs) m.MarkStageSucceeded("transcribe", succeededAt, outputs)
if stage.Status != StatusSucceeded { if stage.Status != StatusSucceeded {
t.Fatalf("status = %q, want %q", stage.Status, StatusSucceeded) t.Fatalf("status = %q, want %q", stage.Status, StatusSucceeded)
@@ -35,7 +35,7 @@ func TestStageMarkHelpers(t *testing.T) {
t.Fatalf("outputs len = %d, want 1", len(stage.Outputs)) t.Fatalf("outputs len = %d, want 1", len(stage.Outputs))
} }
outputs[0].LocalPath = "mutated.json" outputs[0].LocalPath = "mutated.json"
if stage.Outputs[0].LocalPath != "transcripts/processed.json" { if stage.Outputs[0].LocalPath != "transcripts/polished.json" {
t.Fatalf("stage outputs should be copied, got %#v", stage.Outputs) t.Fatalf("stage outputs should be copied, got %#v", stage.Outputs)
} }

View File

@@ -21,7 +21,7 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC) now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
m.MarkStageRunning("prepare", now) m.MarkStageRunning("prepare", now)
m.MarkStageSucceeded("prepare", now.Add(2*time.Second), []ArtifactRecord{{Kind: "transcript", LocalPath: "transcripts/merged.json"}}) m.MarkStageSucceeded("prepare", now.Add(2*time.Second), []ArtifactRecord{{Kind: "transcript", LocalPath: "transcripts/base.json"}})
m.Campaign = "forsaken" m.Campaign = "forsaken"
m.RunID = "20260515T031522Z-a1b2c3d4" m.RunID = "20260515T031522Z-a1b2c3d4"
m.LocalWorkDir = "/var/lib/narratio/work/forsaken/2026-05-03/20260515T031522Z-a1b2c3d4" m.LocalWorkDir = "/var/lib/narratio/work/forsaken/2026-05-03/20260515T031522Z-a1b2c3d4"

View File

@@ -0,0 +1,495 @@
package previouscache
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
const (
InputKindManifest = "previous_manifest"
InputKindArtifact = "previous_artifact"
InputSource = "previous_session_archive.current"
)
type Plan struct {
Records []Record
SkippedMissing []string
PreviousRunID string
}
type Record struct {
Kind string
RequirementName string
Required bool
LocalRelativePath string
LocalPath string
RemoteKey string
S3Bucket string
}
func BuildPlan(
ctx context.Context,
cfg *config.Config,
paths artifacts.SessionPaths,
requirements []artifacts.PreviousArtifactRequirement,
store storage.ObjectStore,
) (*Plan, error) {
if len(requirements) == 0 {
return &Plan{}, nil
}
if cfg == nil || cfg.Session == nil || cfg.Pipeline == nil {
return nil, fmt.Errorf("resolved config with session/pipeline is required")
}
orderedRequirements := append([]artifacts.PreviousArtifactRequirement(nil), requirements...)
sort.Slice(orderedRequirements, func(i, j int) bool {
return orderedRequirements[i].Name < orderedRequirements[j].Name
})
requiredNames := requiredPreviousArtifactNames(orderedRequirements)
optionalNames := optionalPreviousArtifactNames(orderedRequirements)
previousSessionID := strings.TrimSpace(cfg.Session.PreviousSessionID)
if previousSessionID == "" {
if len(requiredNames) > 0 {
return nil, fmt.Errorf(
"previous_session_id is required for required previous-session artifacts: %s",
strings.Join(requiredNames, ", "),
)
}
return &Plan{SkippedMissing: optionalNames}, nil
}
if store == nil {
return nil, fmt.Errorf("previous-session artifact hydration requires object store backend")
}
if cfg.Pipeline.Storage.S3 == nil {
return nil, fmt.Errorf("pipeline.storage.s3 configuration is required for previous-session artifact hydration")
}
campaign := strings.TrimSpace(cfg.Session.Campaign)
if campaign == "" {
return nil, fmt.Errorf("session campaign is required for previous-session artifact hydration")
}
bucket := strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket)
if bucket == "" {
return nil, fmt.Errorf("pipeline.storage.s3.bucket is required for previous-session artifact hydration")
}
previousSessionPrefix := artifacts.S3SessionPrefix(
cfg.Pipeline.Storage.S3.RootPrefix,
campaign,
previousSessionID,
)
currentManifestKey, currentRunIDKey := artifacts.ResolveArchiveCurrentStateKeys(previousSessionPrefix)
result := &Plan{}
runPointerExists, err := store.Exists(ctx, currentRunIDKey)
if err != nil {
return nil, fmt.Errorf("check previous-session current run pointer %q: %w", currentRunIDKey, err)
}
if !runPointerExists {
if len(requiredNames) > 0 {
return nil, fmt.Errorf("required previous-session artifacts unavailable: remote current run pointer missing: %q", currentRunIDKey)
}
result.SkippedMissing = optionalNames
return result, nil
}
runIDTemp, err := downloadObjectToTemp(ctx, store, currentRunIDKey, "narratio-previous-run-id-*.txt")
if err != nil {
return nil, fmt.Errorf("download previous-session current run pointer %q: %w", currentRunIDKey, err)
}
defer func() { _ = os.Remove(runIDTemp) }()
runIDBytes, err := os.ReadFile(runIDTemp)
if err != nil {
return nil, fmt.Errorf("read previous-session current run pointer %q: %w", currentRunIDKey, err)
}
previousRunID := strings.TrimSpace(string(runIDBytes))
if previousRunID == "" {
return nil, fmt.Errorf("previous-session current run pointer %q is empty", currentRunIDKey)
}
result.PreviousRunID = previousRunID
manifestExists, err := store.Exists(ctx, currentManifestKey)
if err != nil {
return nil, fmt.Errorf("check previous-session current manifest %q: %w", currentManifestKey, err)
}
if !manifestExists {
if len(requiredNames) > 0 {
return nil, fmt.Errorf("required previous-session artifacts unavailable: remote current manifest missing: %q", currentManifestKey)
}
result.SkippedMissing = optionalNames
return result, nil
}
manifestTemp, err := downloadObjectToTemp(ctx, store, currentManifestKey, "narratio-previous-manifest-*.json")
if err != nil {
return nil, fmt.Errorf("download previous-session current manifest %q: %w", currentManifestKey, err)
}
defer func() { _ = os.Remove(manifestTemp) }()
manifestStore := &manifest.LocalStore{}
previousManifest, err := manifestStore.Load(ctx, manifestTemp)
if err != nil {
return nil, fmt.Errorf("decode downloaded previous-session manifest %q: %w", currentManifestKey, err)
}
if strings.TrimSpace(previousManifest.SessionID) != previousSessionID {
return nil, fmt.Errorf(
"previous-session manifest session_id %q does not match configured previous_session_id %q",
strings.TrimSpace(previousManifest.SessionID),
previousSessionID,
)
}
if strings.TrimSpace(previousManifest.Campaign) != campaign {
return nil, fmt.Errorf(
"previous-session manifest campaign %q does not match current campaign %q",
strings.TrimSpace(previousManifest.Campaign),
campaign,
)
}
if strings.TrimSpace(previousManifest.RunID) == "" {
return nil, fmt.Errorf("previous-session manifest run_id is required")
}
if strings.TrimSpace(previousManifest.RunID) != previousRunID {
return nil, fmt.Errorf(
"previous-session current run pointer %q references run %q but current manifest run_id is %q",
currentRunIDKey,
previousRunID,
strings.TrimSpace(previousManifest.RunID),
)
}
manifestRel, err := relativeToSession(paths, paths.PreviousManifestPath)
if err != nil {
return nil, err
}
result.Records = append(result.Records, Record{
Kind: InputKindManifest,
LocalRelativePath: manifestRel,
LocalPath: paths.PreviousManifestPath,
RemoteKey: currentManifestKey,
S3Bucket: bucket,
})
for _, requirement := range orderedRequirements {
candidates := artifactRelativePathCandidates(requirement.Name, previousManifest, cfg)
if len(candidates) == 0 {
if requirement.Required {
return nil, fmt.Errorf(
"required previous-session artifact %q is unavailable in previous-session manifest/archive",
requirement.Name,
)
}
result.SkippedMissing = append(result.SkippedMissing, requirement.Name)
continue
}
selectedRel := ""
selectedKey := ""
for _, candidate := range candidates {
remoteKey := artifacts.S3PromotedArtifactKey(previousSessionPrefix, candidate)
exists, err := store.Exists(ctx, remoteKey)
if err != nil {
return nil, fmt.Errorf("check previous-session artifact object %q: %w", remoteKey, err)
}
if !exists {
continue
}
selectedRel = candidate
selectedKey = remoteKey
break
}
if selectedRel == "" {
if requirement.Required {
return nil, fmt.Errorf(
"required previous-session artifact %q object missing from archive candidate keys",
requirement.Name,
)
}
result.SkippedMissing = append(result.SkippedMissing, requirement.Name)
continue
}
localPath := artifacts.SessionPreviousArtifactPath(paths, selectedRel)
localRel, err := relativeToSession(paths, localPath)
if err != nil {
return nil, err
}
result.Records = append(result.Records, Record{
Kind: InputKindArtifact,
RequirementName: requirement.Name,
Required: requirement.Required,
LocalRelativePath: localRel,
LocalPath: localPath,
RemoteKey: selectedKey,
S3Bucket: bucket,
})
}
sort.Strings(result.SkippedMissing)
sort.Slice(result.Records, func(i, j int) bool {
if result.Records[i].LocalRelativePath != result.Records[j].LocalRelativePath {
return result.Records[i].LocalRelativePath < result.Records[j].LocalRelativePath
}
return result.Records[i].RemoteKey < result.Records[j].RemoteKey
})
return result, nil
}
func requiredPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequirement) []string {
names := make([]string, 0, len(requirements))
for _, requirement := range requirements {
if requirement.Required {
names = append(names, strings.TrimSpace(requirement.Name))
}
}
sort.Strings(names)
return names
}
func optionalPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequirement) []string {
names := make([]string, 0, len(requirements))
for _, requirement := range requirements {
if requirement.Required {
continue
}
names = append(names, strings.TrimSpace(requirement.Name))
}
sort.Strings(names)
return names
}
func artifactRelativePathCandidates(
artifactName string,
previousManifest *manifest.Manifest,
cfg *config.Config,
) []string {
candidates := []string{}
appendCandidate := func(v string) {
normalized, err := normalizeArchiveRelativePath(v)
if err != nil {
return
}
candidates = append(candidates, normalized)
}
sourceID := artifacts.ConfiguredArtifactSourceID(artifactName)
if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok {
appendCandidate(rel)
base := path.Base(rel)
for _, promoted := range manifestPromotedPaths(previousManifest) {
if path.Base(promoted) == base {
appendCandidate(promoted)
}
}
}
if cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
if artifactCfg, ok := cfg.Pipeline.Scriptorium.Artifacts[artifactName]; ok {
appendCandidate(artifactCfg.OutputPath)
}
}
return dedupeOrderedStrings(candidates)
}
func manifestArtifactRelativePathBySourceID(previousManifest *manifest.Manifest, sourceID string) (string, bool) {
if previousManifest == nil || len(previousManifest.Stages) == 0 {
return "", false
}
sourceID = strings.TrimSpace(sourceID)
if sourceID == "" {
return "", false
}
stageNames := make([]string, 0, len(previousManifest.Stages))
if _, ok := previousManifest.Stages["analyze"]; ok {
stageNames = append(stageNames, "analyze")
}
for stageName := range previousManifest.Stages {
if stageName == "analyze" {
continue
}
stageNames = append(stageNames, stageName)
}
start := 0
if len(stageNames) > 0 && stageNames[0] == "analyze" {
start = 1
}
sort.Strings(stageNames[start:])
for _, stageName := range stageNames {
sr := previousManifest.Stages[stageName]
if sr == nil {
continue
}
for _, out := range sr.Outputs {
if strings.TrimSpace(out.SourceID) != sourceID {
continue
}
rel, ok := deriveManifestRelativePath(previousManifest, out.LocalPath)
if ok {
return rel, true
}
}
}
return "", false
}
func deriveManifestRelativePath(previousManifest *manifest.Manifest, localPath string) (string, bool) {
trimmed := strings.TrimSpace(localPath)
if trimmed == "" {
return "", false
}
if !filepath.IsAbs(trimmed) {
normalized, err := normalizeArchiveRelativePath(filepath.ToSlash(trimmed))
if err != nil {
return "", false
}
return normalized, true
}
sessionRoot, ok := manifestSessionRoot(previousManifest)
if !ok {
return "", false
}
rel, err := filepath.Rel(sessionRoot, trimmed)
if err != nil {
return "", false
}
normalized, err := normalizeArchiveRelativePath(filepath.ToSlash(rel))
if err != nil {
return "", false
}
return normalized, true
}
func manifestSessionRoot(previousManifest *manifest.Manifest) (string, bool) {
if previousManifest == nil {
return "", false
}
runRoot := filepath.Clean(strings.TrimSpace(previousManifest.LocalWorkDir))
runID := strings.TrimSpace(previousManifest.RunID)
if runRoot == "" || runID == "" {
return "", false
}
if filepath.Base(runRoot) != runID {
return "", false
}
runsDir := filepath.Dir(runRoot)
if filepath.Base(runsDir) != config.PathRunsDirSegment {
return "", false
}
return filepath.Dir(runsDir), true
}
func manifestPromotedPaths(previousManifest *manifest.Manifest) []string {
if previousManifest == nil || len(previousManifest.Stages) == 0 {
return nil
}
sr := previousManifest.Stages["archive"]
if sr == nil || sr.Metadata == nil {
return nil
}
raw, ok := sr.Metadata["promoted_paths"]
if !ok {
return nil
}
values, ok := raw.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
asString, ok := value.(string)
if !ok {
continue
}
normalized, err := normalizeArchiveRelativePath(asString)
if err != nil {
continue
}
out = append(out, normalized)
}
return dedupeOrderedStrings(out)
}
func normalizeArchiveRelativePath(rel string) (string, error) {
trimmed := strings.TrimSpace(rel)
if trimmed == "" {
return "", fmt.Errorf("relative path is required")
}
cleaned := filepath.ToSlash(filepath.Clean(filepath.FromSlash(trimmed)))
if cleaned == "." || cleaned == "" {
return "", fmt.Errorf("relative path is required")
}
if filepath.IsAbs(trimmed) || strings.HasPrefix(cleaned, "/") || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("path must be a clean relative path")
}
return cleaned, nil
}
func relativeToSession(paths artifacts.SessionPaths, localPath string) (string, error) {
root := filepath.Clean(paths.Root)
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("session root is required")
}
rel, err := filepath.Rel(root, filepath.Clean(localPath))
if err != nil {
return "", fmt.Errorf("resolve previous-cache relative path: %w", err)
}
normalized, err := normalizeArchiveRelativePath(filepath.ToSlash(rel))
if err != nil {
return "", fmt.Errorf("resolve previous-cache relative path: %w", err)
}
return normalized, nil
}
func dedupeOrderedStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
func downloadObjectToTemp(ctx context.Context, store storage.ObjectStore, key, pattern string) (string, error) {
tmp, err := os.CreateTemp("", pattern)
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
path := tmp.Name()
if err := tmp.Close(); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("close temp file: %w", err)
}
if err := store.Download(ctx, key, path); err != nil {
_ = os.Remove(path)
return "", err
}
return path, nil
}

View File

@@ -0,0 +1,180 @@
package previouscache
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestBuildPlanResolvesPromotedArtifactFromPreviousManifest(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"artifacts/session_recap.md"}))
previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID)
store.SeedObject(storage.FakeObject{Key: previousPrefix + "artifacts/session_recap.md", Data: []byte("# recap\n")})
plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}, store)
if err != nil {
t.Fatalf("BuildPlan() error = %v", err)
}
if plan.PreviousRunID != "previous-run" {
t.Fatalf("PreviousRunID = %q, want previous-run", plan.PreviousRunID)
}
got := recordRelPaths(plan.Records)
want := []string{"previous/artifacts/session_recap.md", "previous/manifest.json"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("record rel paths = %#v, want %#v", got, want)
}
}
func TestBuildPlanFallsBackToConfiguredOutputPath(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil))
previousPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.PreviousSessionID)
store.SeedObject(storage.FakeObject{Key: previousPrefix + "artifacts/session_recap.md", Data: []byte("# recap\n")})
plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}, store)
if err != nil {
t.Fatalf("BuildPlan() error = %v", err)
}
if len(plan.Records) != 2 {
t.Fatalf("records len = %d, want 2", len(plan.Records))
}
if plan.Records[0].LocalRelativePath != "previous/artifacts/session_recap.md" {
t.Fatalf("artifact local relative path = %q", plan.Records[0].LocalRelativePath)
}
}
func TestBuildPlanSkipsMissingOptionalPreviousArtifact(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil))
plan, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: false},
}, store)
if err != nil {
t.Fatalf("BuildPlan() error = %v", err)
}
if strings.Join(plan.SkippedMissing, ",") != "session_recap" {
t.Fatalf("SkippedMissing = %#v, want session_recap", plan.SkippedMissing)
}
if len(plan.Records) != 1 || plan.Records[0].Kind != InputKindManifest {
t.Fatalf("records = %#v, want manifest only", plan.Records)
}
}
func TestBuildPlanMissingRequiredPreviousArtifactFails(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "", nil))
_, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}, store)
if err == nil || !strings.Contains(err.Error(), `required previous-session artifact "session_recap" object missing`) {
t.Fatalf("BuildPlan() error = %v, want required missing error", err)
}
}
func TestBuildPlanValidatesPreviousManifestIdentity(t *testing.T) {
cfg, paths := previousCacheTestConfig(t)
store := &storage.FakeBackend{}
manifest := previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", nil)
manifest.SessionID = "wrong-session"
seedPreviousCurrent(t, store, cfg, manifest)
_, err := BuildPlan(context.Background(), cfg, paths, []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}, store)
if err == nil || !strings.Contains(err.Error(), "does not match configured previous_session_id") {
t.Fatalf("BuildPlan() error = %v, want identity validation error", err)
}
}
func previousCacheTestConfig(t *testing.T) (*config.Config, artifacts.SessionPaths) {
t.Helper()
workspaceRoot := t.TempDir()
cfg := &config.Config{
Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: workspaceRoot},
Storage: config.StorageConfig{S3: &config.StorageS3Config{
Bucket: "test-bucket",
RootPrefix: "dnd",
}},
Scriptorium: &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
OutputPath: "artifacts/session_recap.md",
},
}},
},
Session: &config.SessionConfig{
Campaign: "sample-campaign",
SessionID: "2026-05-03",
PreviousSessionID: "2026-04-26",
},
}
return cfg, artifacts.NewLocalStore(workspaceRoot).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
}
func seedPreviousCurrent(t *testing.T, store *storage.FakeBackend, cfg *config.Config, m *manifest.Manifest) {
t.Helper()
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(previousPrefix)
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("previous-run\n")})
data, err := marshalManifestForPreviousCacheTest(m)
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: data})
}
func previousManifestWithOutput(t *testing.T, cfg *config.Config, rel string, promoted []string) *manifest.Manifest {
t.Helper()
m := manifest.New(cfg.Session.PreviousSessionID, time.Date(2026, 4, 26, 10, 0, 0, 0, time.UTC))
m.Campaign = cfg.Session.Campaign
m.RunID = "previous-run"
m.LocalWorkDir = filepath.Join("/var/lib/narratio/work", cfg.Session.Campaign, cfg.Session.PreviousSessionID, "runs", m.RunID)
if strings.TrimSpace(rel) != "" {
m.MarkStageSucceeded("analyze", time.Date(2026, 4, 26, 10, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{
{SourceID: "narratio.artifact.session_recap", LocalPath: filepath.Join(filepath.Dir(filepath.Dir(m.LocalWorkDir)), filepath.FromSlash(rel))},
})
}
if promoted != nil {
if m.Stages["archive"] == nil {
m.MarkStageSucceeded("archive", time.Date(2026, 4, 26, 10, 2, 0, 0, time.UTC), nil)
}
m.Stages["archive"].Metadata = map[string]any{"promoted_paths": promoted}
}
return m
}
func marshalManifestForPreviousCacheTest(m *manifest.Manifest) ([]byte, error) {
data, err := json.Marshal(m)
if err != nil {
return nil, err
}
return append(data, '\n'), nil
}
func recordRelPaths(records []Record) []string {
out := make([]string, 0, len(records))
for _, record := range records {
out = append(out, record.LocalRelativePath)
}
return out
}

View File

@@ -24,9 +24,9 @@ func (analyzeStage) Name() string { return "analyze" }
func (analyzeStage) Declares() IODecl { func (analyzeStage) Declares() IODecl {
return IODecl{ return IODecl{
Inputs: []artifacts.Ref{ Inputs: []artifacts.Ref{
{Kind: "transcript_processed", Category: "transcripts", RelativePath: "transcripts/processed.json"}, {Kind: "transcript_polished", Category: "transcripts", RelativePath: "transcripts/polished.json"},
{Kind: "transcript_normalized", Category: "transcripts", RelativePath: "transcripts/normalized.json"}, {Kind: "transcript_final", Category: "transcripts", RelativePath: "transcripts/final.json"},
{Kind: "transcript_trimmed", Category: "transcripts", RelativePath: "transcripts/trimmed.json"}, {Kind: "transcript_final_trimmed", Category: "transcripts", RelativePath: "transcripts/final.trimmed.json"},
}, },
Outputs: nil, Outputs: nil,
} }
@@ -83,7 +83,7 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
}}, nil }}, nil
} }
runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog(paths, env.Config.Pipeline.Scriptorium, env.SelectedAnalyzeArtifacts) runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog(paths, env.Config.Pipeline.Scriptorium, env.SelectedArtifactKeys)
if err != nil { if err != nil {
return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err) return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err)
} }
@@ -562,7 +562,7 @@ func discoverProcessedTranscript(m *manifest.Manifest, paths artifacts.SessionPa
if m != nil && m.Stages != nil { if m != nil && m.Stages != nil {
if sr := m.Stages["polish"]; sr != nil { if sr := m.Stages["polish"]; sr != nil {
for _, out := range sr.Outputs { for _, out := range sr.Outputs {
if out.Kind != "transcript_processed" { if out.Kind != "transcript_polished" {
continue continue
} }
p := strings.TrimSpace(out.LocalPath) p := strings.TrimSpace(out.LocalPath)
@@ -581,7 +581,7 @@ func discoverProcessedTranscript(m *manifest.Manifest, paths artifacts.SessionPa
} }
} }
fallback := filepath.Join(paths.TranscriptsDir, "processed.json") fallback := filepath.Join(paths.TranscriptsDir, "polished.json")
if info, err := os.Stat(fallback); err == nil && !info.IsDir() { if info, err := os.Stat(fallback); err == nil && !info.IsDir() {
return filepath.Clean(fallback), "fallback.transcripts_dir", nil return filepath.Clean(fallback), "fallback.transcripts_dir", nil
} }
@@ -602,8 +602,8 @@ type analyzeTranscriptInputs struct {
func discoverAnalyzeTranscriptRefs(m *manifest.Manifest, paths artifacts.SessionPaths) analyzeTranscriptInputs { func discoverAnalyzeTranscriptRefs(m *manifest.Manifest, paths artifacts.SessionPaths) analyzeTranscriptInputs {
processedPath, processedSource := discoverAnalyzeArtifactRef(m, paths, artifacts.ArtifactTranscriptPolished) processedPath, processedSource := discoverAnalyzeArtifactRef(m, paths, artifacts.ArtifactTranscriptPolished)
normalizedPath, normalizedSource := discoverAnalyzeArtifactRef(m, paths, artifacts.ArtifactTranscriptFull) normalizedPath, normalizedSource := discoverAnalyzeArtifactRef(m, paths, artifacts.ArtifactTranscriptFinal)
trimmedPath, trimmedSource := discoverAnalyzeArtifactRef(m, paths, artifacts.ArtifactTranscriptTrimmed) trimmedPath, trimmedSource := discoverAnalyzeArtifactRef(m, paths, artifacts.ArtifactTranscriptFinalTrimmed)
return analyzeTranscriptInputs{ return analyzeTranscriptInputs{
ProcessedPath: processedPath, ProcessedPath: processedPath,
ProcessedSource: processedSource, ProcessedSource: processedSource,
@@ -649,15 +649,6 @@ func resolveScriptoriumInput(
return "", false, nil, err return "", false, nil, err
} }
switch source { switch source {
case "previous_session_artifact":
if strings.TrimSpace(inputCfg.Path) == "" {
return "", false, nil, nil
}
resolved := resolveInputPathForRead(paths, sessionDir, inputCfg.Path)
if err := requireFile(resolved, "scriptorium input "+inputName); err != nil {
return "", false, nil, nil
}
return resolved, true, nil, nil
default: default:
resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
if err == nil { if err == nil {
@@ -678,9 +669,9 @@ func resolveScriptoriumInput(
switch normalized { switch normalized {
case artifacts.ArtifactTranscriptPolished: case artifacts.ArtifactTranscriptPolished:
return "", false, nil, nil return "", false, nil, nil
case artifacts.ArtifactTranscriptFull: case artifacts.ArtifactTranscriptFinal:
return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first") return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
case artifacts.ArtifactTranscriptTrimmed: case artifacts.ArtifactTranscriptFinalTrimmed:
return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first") return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first")
default: default:
return "", false, nil, nil return "", false, nil, nil

View File

@@ -19,7 +19,7 @@ import (
func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) { func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
result, err := (analyzeStage{}).Run(context.Background(), env, m) result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil { if err != nil {
@@ -36,7 +36,7 @@ func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) {
if req.ProfileID != "local-quality" { if req.ProfileID != "local-quality" {
t.Fatalf("profile id = %q, want local-quality", req.ProfileID) t.Fatalf("profile id = %q, want local-quality", req.ProfileID)
} }
if req.InputPaths["transcript"] != filepath.Join(paths.TranscriptsDir, "trimmed.json") { if req.InputPaths["transcript"] != filepath.Join(paths.TranscriptsDir, "final.trimmed.json") {
t.Fatalf("transcript input = %q, want trimmed transcript path", req.InputPaths["transcript"]) t.Fatalf("transcript input = %q, want trimmed transcript path", req.InputPaths["transcript"])
} }
if req.OutputPath != filepath.Join(paths.ArtifactsDir, "session_recap.md") { if req.OutputPath != filepath.Join(paths.ArtifactsDir, "session_recap.md") {
@@ -72,7 +72,7 @@ func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) {
func TestAnalyzeRenderDebugFalseDoesNotCallRenderArtifact(t *testing.T) { func TestAnalyzeRenderDebugFalseDoesNotCallRenderArtifact(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.RenderDebug = false env.Config.Pipeline.Scriptorium.RenderDebug = false
@@ -88,7 +88,7 @@ func TestAnalyzeRenderDebugFalseDoesNotCallRenderArtifact(t *testing.T) {
func TestAnalyzeRenderDebugArtifactOverrideFalseWinsOverGlobalTrue(t *testing.T) { func TestAnalyzeRenderDebugArtifactOverrideFalseWinsOverGlobalTrue(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.RenderDebug = true env.Config.Pipeline.Scriptorium.RenderDebug = true
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
@@ -111,7 +111,7 @@ func TestAnalyzeRenderDebugArtifactOverrideFalseWinsOverGlobalTrue(t *testing.T)
func TestAnalyzeRenderDebugTrueCallsRenderBeforeRun(t *testing.T) { func TestAnalyzeRenderDebugTrueCallsRenderBeforeRun(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.RenderDebug = true env.Config.Pipeline.Scriptorium.RenderDebug = true
runner := &orderedScriptoriumRunner{ runner := &orderedScriptoriumRunner{
@@ -132,7 +132,7 @@ func TestAnalyzeRenderDebugTrueCallsRenderBeforeRun(t *testing.T) {
func TestAnalyzeRenderOutputPathIsRecorded(t *testing.T) { func TestAnalyzeRenderOutputPathIsRecorded(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.RenderDebug = true env.Config.Pipeline.Scriptorium.RenderDebug = true
result, err := (analyzeStage{}).Run(context.Background(), env, m) result, err := (analyzeStage{}).Run(context.Background(), env, m)
@@ -156,7 +156,7 @@ func TestAnalyzeRenderOutputPathIsRecorded(t *testing.T) {
func TestAnalyzeRenderFailurePreventsRun(t *testing.T) { func TestAnalyzeRenderFailurePreventsRun(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.RenderDebug = true env.Config.Pipeline.Scriptorium.RenderDebug = true
fake.RenderErr = errors.New("render boom") fake.RenderErr = errors.New("render boom")
@@ -178,7 +178,7 @@ func TestAnalyzeRenderFailurePreventsRun(t *testing.T) {
func TestAnalyzeRenderInvalidJSONFailsClearly(t *testing.T) { func TestAnalyzeRenderInvalidJSONFailsClearly(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.RenderDebug = true env.Config.Pipeline.Scriptorium.RenderDebug = true
runner := &orderedScriptoriumRunner{ runner := &orderedScriptoriumRunner{
RenderBody: `not-json`, RenderBody: `not-json`,
@@ -201,7 +201,7 @@ func TestAnalyzeRenderInvalidJSONFailsClearly(t *testing.T) {
func TestAnalyzeRunStillSucceedsWhenRenderSucceeds(t *testing.T) { func TestAnalyzeRunStillSucceedsWhenRenderSucceeds(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.RenderDebug = true env.Config.Pipeline.Scriptorium.RenderDebug = true
result, err := (analyzeStage{}).Run(context.Background(), env, m) result, err := (analyzeStage{}).Run(context.Background(), env, m)
@@ -219,10 +219,10 @@ func TestAnalyzeRunStillSucceedsWhenRenderSucceeds(t *testing.T) {
} }
} }
func TestAnalyzeOmitsOptionalPreviousRecapWhenUnavailable(t *testing.T) { func TestAnalyzeOmitsOptionalCanonicalPreviousRecapWhenUnavailable(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{
Enabled: true, Enabled: true,
@@ -236,9 +236,7 @@ func TestAnalyzeOmitsOptionalPreviousRecapWhenUnavailable(t *testing.T) {
Required: true, Required: true,
}, },
"previous_recap": { "previous_recap": {
Source: "previous_session_artifact", Source: "narratio.previous_session.artifact.session_recap",
Artifact: "session_recap",
Path: "",
Required: false, Required: false,
}, },
}, },
@@ -263,7 +261,7 @@ func TestAnalyzeUsesRunLocalPathsAndPromotesCanonical(t *testing.T) {
m.Campaign = "sample-campaign" m.Campaign = "sample-campaign"
m.RunID = "20260518T010203Z-abcdef12" m.RunID = "20260518T010203Z-abcdef12"
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
result, err := (analyzeStage{}).Run(context.Background(), env, m) result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil { if err != nil {
@@ -343,12 +341,12 @@ func (r *orderedScriptoriumRunner) RunArtifact(_ context.Context, req scriptoriu
}, nil }, nil
} }
func TestAnalyzeIncludesPreviousRecapWhenConfiguredAndAvailable(t *testing.T) { func TestAnalyzeIncludesCanonicalPreviousRecapWhenPreparedCacheExists(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`)
previousRecapPath := filepath.Join(filepath.Dir(env.Config.SessionPath), "previous", "session_recap.md") previousRecapPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousRecapPath, "previous recap\n") writeAnalyzeFile(t, previousRecapPath, "previous recap\n")
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{
@@ -363,9 +361,7 @@ func TestAnalyzeIncludesPreviousRecapWhenConfiguredAndAvailable(t *testing.T) {
Required: true, Required: true,
}, },
"previous_recap": { "previous_recap": {
Source: "previous_session_artifact", Source: "narratio.previous_session.artifact.session_recap",
Artifact: "session_recap",
Path: "./previous/session_recap.md",
Required: false, Required: false,
}, },
}, },
@@ -384,10 +380,10 @@ func TestAnalyzeIncludesPreviousRecapWhenConfiguredAndAvailable(t *testing.T) {
} }
} }
func TestAnalyzeFailsWhenRequiredPreviousRecapMissing(t *testing.T) { func TestAnalyzeFailsWhenRequiredCanonicalPreviousRecapMissing(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{
Enabled: true, Enabled: true,
@@ -399,8 +395,7 @@ func TestAnalyzeFailsWhenRequiredPreviousRecapMissing(t *testing.T) {
Required: true, Required: true,
}, },
"previous_recap": { "previous_recap": {
Source: "previous_session_artifact", Source: "narratio.previous_session.artifact.session_recap",
Path: "./missing/previous_recap.md",
Required: true, Required: true,
}, },
}, },
@@ -410,15 +405,15 @@ func TestAnalyzeFailsWhenRequiredPreviousRecapMissing(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
if !strings.Contains(err.Error(), `required input "previous_recap"`) { if !strings.Contains(err.Error(), "run narratio run-stage --force prepare") {
t.Fatalf("error = %q, want required input context", err.Error()) t.Fatalf("error = %q, want guidance to run force prepare", err.Error())
} }
} }
func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *testing.T) { func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
playerHandoutPath := filepath.Join(paths.ArtifactsDir, "player_handout.md") playerHandoutPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
writeAnalyzeFile(t, playerHandoutPath, "handout\n") writeAnalyzeFile(t, playerHandoutPath, "handout\n")
@@ -448,7 +443,7 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes
func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) { func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
playerHandoutPath := filepath.Join(paths.ArtifactsDir, "player_handout.md") playerHandoutPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
writeAnalyzeFile(t, playerHandoutPath, "handout\n") writeAnalyzeFile(t, playerHandoutPath, "handout\n")
@@ -517,7 +512,7 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
func TestAnalyzeRunsMultipleIndependentArtifactsInDeterministicOrder(t *testing.T) { func TestAnalyzeRunsMultipleIndependentArtifactsInDeterministicOrder(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: true, Enabled: true,
@@ -526,7 +521,7 @@ func TestAnalyzeRunsMultipleIndependentArtifactsInDeterministicOrder(t *testing.
OutputPath: "artifacts/player_handout.md", OutputPath: "artifacts/player_handout.md",
Inputs: map[string]config.ScriptoriumInputConfig{ Inputs: map[string]config.ScriptoriumInputConfig{
"transcript": { "transcript": {
Source: "narratio.transcript.trimmed", Source: "narratio.transcript.final_trimmed",
Required: true, Required: true,
}, },
}, },
@@ -558,7 +553,7 @@ func TestAnalyzeRunsMultipleIndependentArtifactsInDeterministicOrder(t *testing.
func TestAnalyzeRunsDependenciesBeforeDependents(t *testing.T) { func TestAnalyzeRunsDependenciesBeforeDependents(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: true, Enabled: true,
@@ -572,7 +567,7 @@ func TestAnalyzeRunsDependenciesBeforeDependents(t *testing.T) {
Required: true, Required: true,
}, },
"transcript": { "transcript": {
Source: "narratio.transcript.trimmed", Source: "narratio.transcript.final_trimmed",
Required: true, Required: true,
}, },
}, },
@@ -599,7 +594,7 @@ func TestAnalyzeRunsDependenciesBeforeDependents(t *testing.T) {
func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) { func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: true, Enabled: true,
@@ -608,12 +603,12 @@ func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) {
OutputPath: "artifacts/player_handout.md", OutputPath: "artifacts/player_handout.md",
Inputs: map[string]config.ScriptoriumInputConfig{ Inputs: map[string]config.ScriptoriumInputConfig{
"transcript": { "transcript": {
Source: "narratio.transcript.trimmed", Source: "narratio.transcript.final_trimmed",
Required: true, Required: true,
}, },
}, },
} }
env.SelectedAnalyzeArtifacts = []string{"player_handout"} env.SelectedArtifactKeys = []string{"player_handout"}
result, err := (analyzeStage{}).Run(context.Background(), env, m) result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil { if err != nil {
@@ -633,7 +628,7 @@ func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) {
func TestAnalyzeMetadataIncludesMultipleGeneratedArtifacts(t *testing.T) { func TestAnalyzeMetadataIncludesMultipleGeneratedArtifacts(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: true, Enabled: true,
@@ -642,7 +637,7 @@ func TestAnalyzeMetadataIncludesMultipleGeneratedArtifacts(t *testing.T) {
OutputPath: "artifacts/player_handout.md", OutputPath: "artifacts/player_handout.md",
Inputs: map[string]config.ScriptoriumInputConfig{ Inputs: map[string]config.ScriptoriumInputConfig{
"transcript": { "transcript": {
Source: "narratio.transcript.trimmed", Source: "narratio.transcript.final_trimmed",
Required: true, Required: true,
}, },
}, },
@@ -668,7 +663,7 @@ func TestAnalyzeMetadataIncludesMultipleGeneratedArtifacts(t *testing.T) {
func TestAnalyzeFailsWhenRequiredConfiguredArtifactMissing(t *testing.T) { func TestAnalyzeFailsWhenRequiredConfiguredArtifactMissing(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{ sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{
@@ -693,7 +688,7 @@ func TestAnalyzeFailsWhenRequiredConfiguredArtifactMissing(t *testing.T) {
func TestAnalyzeOmitsOptionalMissingConfiguredArtifactInput(t *testing.T) { func TestAnalyzeOmitsOptionalMissingConfiguredArtifactInput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{ sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{
@@ -740,7 +735,7 @@ func TestAnalyzeRequiredPreviousSessionArtifactInputGuidesPrepareForce(t *testin
func TestAnalyzeResolvesCanonicalPreviousSessionArtifactFromManifestInput(t *testing.T) { func TestAnalyzeResolvesCanonicalPreviousSessionArtifactFromManifestInput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md") previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousPath, "previous recap\n") writeAnalyzeFile(t, previousPath, "previous recap\n")
@@ -771,7 +766,7 @@ func TestAnalyzeResolvesCanonicalPreviousSessionArtifactFromManifestInput(t *tes
func TestAnalyzeOmitsOptionalMissingCanonicalPreviousSessionArtifact(t *testing.T) { func TestAnalyzeOmitsOptionalMissingCanonicalPreviousSessionArtifact(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
sessionRecap.Inputs["previous_recap"] = config.ScriptoriumInputConfig{ sessionRecap.Inputs["previous_recap"] = config.ScriptoriumInputConfig{
@@ -795,7 +790,7 @@ func TestAnalyzeOmitsOptionalMissingCanonicalPreviousSessionArtifact(t *testing.
func TestAnalyzeRenderDebugWithCanonicalPreviousSessionInput(t *testing.T) { func TestAnalyzeRenderDebugWithCanonicalPreviousSessionInput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.RenderDebug = true env.Config.Pipeline.Scriptorium.RenderDebug = true
previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md") previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
@@ -830,7 +825,7 @@ func TestAnalyzeRenderDebugWithCanonicalPreviousSessionInput(t *testing.T) {
func TestAnalyzeDoesNotCallObjectStoreForCanonicalPreviousSessionInput(t *testing.T) { func TestAnalyzeDoesNotCallObjectStoreForCanonicalPreviousSessionInput(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md") previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousPath, "previous recap\n") writeAnalyzeFile(t, previousPath, "previous recap\n")
@@ -861,7 +856,7 @@ func TestAnalyzeDoesNotCallObjectStoreForCanonicalPreviousSessionInput(t *testin
func TestAnalyzeFailsWhenOutputPathMissing(t *testing.T) { func TestAnalyzeFailsWhenOutputPathMissing(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.OutputPath = "" artifact.OutputPath = ""
@@ -896,7 +891,7 @@ func TestAnalyzeFailsWhenTrimmedTranscriptMissing(t *testing.T) {
func TestAnalyzeSupportsProcessedTranscriptSourceWhenConfigured(t *testing.T) { func TestAnalyzeSupportsProcessedTranscriptSourceWhenConfigured(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "processed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "polished.json"), `{"segments":[]}`)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
@@ -912,7 +907,7 @@ func TestAnalyzeSupportsProcessedTranscriptSourceWhenConfigured(t *testing.T) {
if len(fake.RunRequests) != 1 { if len(fake.RunRequests) != 1 {
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests)) t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
} }
if fake.RunRequests[0].InputPaths["transcript"] != filepath.Join(paths.TranscriptsDir, "processed.json") { if fake.RunRequests[0].InputPaths["transcript"] != filepath.Join(paths.TranscriptsDir, "polished.json") {
t.Fatalf("transcript input = %q, want processed transcript path", fake.RunRequests[0].InputPaths["transcript"]) t.Fatalf("transcript input = %q, want processed transcript path", fake.RunRequests[0].InputPaths["transcript"])
} }
} }
@@ -920,11 +915,11 @@ func TestAnalyzeSupportsProcessedTranscriptSourceWhenConfigured(t *testing.T) {
func TestAnalyzeSupportsCanonicalTrimmedTranscriptSourceWhenConfigured(t *testing.T) { func TestAnalyzeSupportsCanonicalTrimmedTranscriptSourceWhenConfigured(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
Source: "narratio.transcript.trimmed", Source: "narratio.transcript.final_trimmed",
Required: true, Required: true,
} }
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
@@ -936,7 +931,7 @@ func TestAnalyzeSupportsCanonicalTrimmedTranscriptSourceWhenConfigured(t *testin
if len(fake.RunRequests) != 1 { if len(fake.RunRequests) != 1 {
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests)) t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
} }
if fake.RunRequests[0].InputPaths["transcript"] != filepath.Join(paths.TranscriptsDir, "trimmed.json") { if fake.RunRequests[0].InputPaths["transcript"] != filepath.Join(paths.TranscriptsDir, "final.trimmed.json") {
t.Fatalf("transcript input = %q, want trimmed transcript path", fake.RunRequests[0].InputPaths["transcript"]) t.Fatalf("transcript input = %q, want trimmed transcript path", fake.RunRequests[0].InputPaths["transcript"])
} }
} }
@@ -944,12 +939,12 @@ func TestAnalyzeSupportsCanonicalTrimmedTranscriptSourceWhenConfigured(t *testin
func TestAnalyzeSupportsNormalizedTranscriptSourceWhenConfigured(t *testing.T) { func TestAnalyzeSupportsNormalizedTranscriptSourceWhenConfigured(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
normalizedPath := filepath.Join(paths.TranscriptsDir, "normalized.json") normalizedPath := filepath.Join(paths.TranscriptsDir, "final.json")
writeAnalyzeFile(t, normalizedPath, `{"segments":[{"id":1}]}`) writeAnalyzeFile(t, normalizedPath, `{"segments":[{"id":1}]}`)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
Source: "narratio.transcript.full", Source: "narratio.transcript.final",
Required: true, Required: true,
} }
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
@@ -969,17 +964,17 @@ func TestAnalyzeSupportsNormalizedTranscriptSourceWhenConfigured(t *testing.T) {
func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) { func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
fallbackPath := filepath.Join(paths.TranscriptsDir, "normalized.json") fallbackPath := filepath.Join(paths.TranscriptsDir, "final.json")
manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json") manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json")
writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`) writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`)
writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`) writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`)
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{
{Kind: "transcript_normalized", LocalPath: manifestPath}, {Kind: "transcript_final", LocalPath: manifestPath},
}) })
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
Source: "narratio.transcript.full", Source: "narratio.transcript.final",
Required: true, Required: true,
} }
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
@@ -999,17 +994,17 @@ func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t
func TestAnalyzeSupportsNormalizedTranscriptSourceFromManifestOutput(t *testing.T) { func TestAnalyzeSupportsNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
fallbackPath := filepath.Join(paths.TranscriptsDir, "normalized.json") fallbackPath := filepath.Join(paths.TranscriptsDir, "final.json")
manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json") manifestPath := filepath.Join(paths.ArtifactsDir, "normalized.from-manifest.json")
writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`) writeAnalyzeFile(t, fallbackPath, `{"segments":[{"id":999}]}`)
writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`) writeAnalyzeFile(t, manifestPath, `{"segments":[{"id":10}]}`)
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{ m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{
{Kind: "transcript_normalized", LocalPath: manifestPath}, {Kind: "transcript_final", LocalPath: manifestPath},
}) })
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
Source: "narratio.transcript.full", Source: "narratio.transcript.final",
Required: true, Required: true,
} }
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
@@ -1030,7 +1025,7 @@ func TestAnalyzeFailsWhenNormalizedTranscriptMissing(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{ artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
Source: "narratio.transcript.full", Source: "narratio.transcript.final",
Required: true, Required: true,
} }
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
@@ -1053,7 +1048,7 @@ func TestAnalyzeFailsWhenNormalizedTranscriptMissing(t *testing.T) {
func TestAnalyzeFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) { func TestAnalyzeFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{not-json`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{not-json`)
_, err := (analyzeStage{}).Run(context.Background(), env, m) _, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
@@ -1067,7 +1062,7 @@ func TestAnalyzeFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) {
func TestAnalyzeFailsWhenProcessedTranscriptMissingSegmentsArray(t *testing.T) { func TestAnalyzeFailsWhenProcessedTranscriptMissingSegmentsArray(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"not_segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"not_segments":[]}`)
_, err := (analyzeStage{}).Run(context.Background(), env, m) _, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil { if err == nil {
@@ -1081,7 +1076,7 @@ func TestAnalyzeFailsWhenProcessedTranscriptMissingSegmentsArray(t *testing.T) {
func TestAnalyzeRecordsRefsAndMetadata(t *testing.T) { func TestAnalyzeRecordsRefsAndMetadata(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
result, err := (analyzeStage{}).Run(context.Background(), env, m) result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil { if err != nil {
@@ -1110,7 +1105,7 @@ func TestAnalyzeRecordsRefsAndMetadata(t *testing.T) {
func TestAnalyzeHandlesAdapterError(t *testing.T) { func TestAnalyzeHandlesAdapterError(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
fake.RunErr = errors.New("adapter boom") fake.RunErr = errors.New("adapter boom")
_, err := (analyzeStage{}).Run(context.Background(), env, m) _, err := (analyzeStage{}).Run(context.Background(), env, m)
@@ -1125,7 +1120,7 @@ func TestAnalyzeHandlesAdapterError(t *testing.T) {
func TestAnalyzeHandlesValidationFailedResultAsError(t *testing.T) { func TestAnalyzeHandlesValidationFailedResultAsError(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
fake.RunResult = scriptorium.ArtifactResult{ fake.RunResult = scriptorium.ArtifactResult{
ValidationFailed: true, ValidationFailed: true,
ExitCode: 2, ExitCode: 2,
@@ -1144,7 +1139,7 @@ func TestAnalyzeHandlesValidationFailedResultAsError(t *testing.T) {
func TestAnalyzeSkipsWhenNoEnabledScriptoriumArtifactsConfigured(t *testing.T) { func TestAnalyzeSkipsWhenNoEnabledScriptoriumArtifactsConfigured(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{ env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{
Enabled: false, Enabled: false,
@@ -1204,13 +1199,11 @@ func setupAnalyzeEnv(t *testing.T) (*Env, *manifest.Manifest, *scriptorium.FakeR
Timeout: "2m", Timeout: "2m",
Inputs: map[string]config.ScriptoriumInputConfig{ Inputs: map[string]config.ScriptoriumInputConfig{
"transcript": { "transcript": {
Source: "narratio.transcript.trimmed", Source: "narratio.transcript.final_trimmed",
Required: true, Required: true,
}, },
"previous_recap": { "previous_recap": {
Source: "previous_session_artifact", Source: "narratio.previous_session.artifact.session_recap",
Artifact: "session_recap",
Path: "",
Required: false, Required: false,
}, },
}, },

Some files were not shown because too many files have changed in this diff Show More