CLI cleanup to consolidate session-related subcommands

This commit is contained in:
2026-05-22 22:09:17 -05:00
parent cee52aa092
commit 7657ec3ad6
31 changed files with 1341 additions and 830 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.
```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).

View File

@@ -3,10 +3,10 @@
## Shortest Useful Command
```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`, `campaign.yml`, and local `session.yml`. If local session discovery misses and S3 storage is configured, the positional session ID loads remote `session.yml` from the canonical session prefix.
Default discovery checks system config locations only. Pass `--config`, `--campaign`, and `--session` to use files from the current working directory.
@@ -14,89 +14,82 @@ Ordinary local and remote `session.yml` files must be concrete YAML. Templates b
## Command Overview
Implemented commands:
Top-level commands:
- `run`: execute pipeline stages and persist manifest state.
- `plan`: validate config, prepare workspace layout, and print stage run/skip decisions.
- `resume`: continue from first non-succeeded stage unless forced.
- `status`: read an existing manifest or inspect local/remote state for a session.
- `run-stage`: execute exactly one stage.
- `analyze`: force-rerun the analyze stage.
- `publish`: force-rerun the archive stage.
- `restore`: restore durable local session state from the committed remote archive state.
- `session validate`: run read-only preflight checks for a session.
- `session init`: create local or remote `session.yml`.
- `artifacts list`: list effective artifact source IDs.
- `locks`: list, add, and remove archive promotion locks.
- `clean`: remove local workspace/spool state for one session or all local sessions.
- `run <session_id>`: execute pipeline stages and persist manifest state.
- `run-stage <stage> <session_id>`: execute exactly one stage.
- `resume <session_id>`: continue from first non-succeeded stage unless forced.
- `analyze <session_id>`: force-rerun the analyze stage.
- `publish <session_id>`: force-rerun the archive stage.
- `clean <session_id>|--all`: remove local workspace/spool state.
- `session <subcommand>`: session-scoped helper commands.
Session subcommands:
- `session init <session_id>`: create local or remote `session.yml`.
- `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.
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
### `run`
Most session-aware commands accept:
- `--config <path>`: optional explicit `pipeline.yml` path.
- `--campaign <path>`: optional explicit `campaign.yml` path.
- `--session <path>`: optional explicit `session.yml` path.
- `--session-id <value>`: expected session identifier and remote session lookup value.
- `--session <path>`: optional explicit concrete `session.yml` path.
- `--previous-session-id <value>`: expected previous session identifier.
- `--force`: force stage execution.
- `--artifacts <names>`: configured artifact keys to execute and publish (repeatable or comma-separated).
### `plan`
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.
- `--config <path>`
- `--campaign <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--force`
## Command Reference
### `run`
```bash
narratio run <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
```
Purpose:
- Execute configured stages in canonical order.
Success output:
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
Common failure cases:
- missing system default config/campaign/session paths when flags are omitted.
- missing 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`
- `--config <path>`
- `--campaign <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--force`
- `--artifacts <names>`: configured artifact keys to execute and publish (repeatable or comma-separated).
```bash
narratio resume <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
```
Purpose:
- Continue from session-manifest stage status.
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`
- `--config <path>`
- `--campaign <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--force`
- `--artifacts <names>`: configured artifact keys to execute or publish (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>`: configured artifact keys to execute (repeatable or comma-separated).
`analyze` is force-by-design and does not accept `--force`.
### `publish`
- `--config <path>`
- `--campaign <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--artifacts <names>`: configured artifact keys to publish (repeatable or comma-separated).
`publish` is force-by-design and does not accept `--force` or a stage positional argument.
```bash
narratio run-stage <stage> <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
```
Valid stage names:
@@ -110,378 +103,39 @@ Valid stage names:
- `archive`
- `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>`: optional explicit `pipeline.yml` path.
- `--campaign <path>`: optional explicit `campaign.yml` path.
- `--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.
When `campaign.yml` sets `session_template_file`, `session init` renders that template before writing the concrete session file. Template variables are supplied by these flags: `--session-id`, `--previous-session-id`, `--date`, `--title`, `--audio-s3-prefix`, and `--audio-dir`.
### `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 expected previous session identifier.
- `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`.
- templated `session.yml`; run `narratio session init` to generate concrete YAML.
- concrete session identity 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 and 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 concrete `session.yml` locally or in object storage.
Syntax:
```bash
narratio session init --session-id <id> --output ./session.yml
narratio session init --session-id <id> --remote
narratio session init --config <pipeline.yml> --campaign <campaign.yml> --session-id <id> --remote
```
Behavior:
- exactly one of `--output` or `--remote` is required.
- `--config` and `--campaign` are optional overrides; omitted values use normal default config discovery.
- 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.
### `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.final_trimmed
narratio locks remove --session-id 2026-04-04 narratio.transcript.final_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:
- `narratio run-stage: stage=<name> executed=<n> skipped=<n> force=<true|false>; manifest=<path>`
`--artifacts` behavior:
- accepted only when `<stage>` is `analyze` or `archive`.
- names are normalized (trimmed, deduplicated, sorted).
- unknown configured artifact keys fail.
- for `archive`, unselected configured artifact promotions are skipped; built-in transcript and bounds promotions still run.
Common failure cases:
- missing stage positional arg.
- unknown stage name.
- using `--artifacts` with any stage other than `analyze` or `archive`.
`--artifacts` is accepted only for `analyze` and `archive`.
### `analyze`
Purpose:
- Force-rerun the analyze stage.
- Provide a shorter equivalent for `narratio run-stage --force analyze`.
Syntax:
```bash
narratio analyze [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--artifacts <name[,name...]>]
narratio analyze <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--artifacts <name[,name...]>]
```
Success output:
- `narratio analyze: executed=<n> skipped=<n> force=true; manifest=<path>`
Purpose:
- Force-rerun the analyze stage.
- Shorter equivalent for `narratio run-stage analyze <session_id> --force`.
Common failure cases:
- positional arguments.
- `--force`, because force is implicit.
- unknown configured artifact keys.
`analyze` is force-by-design and does not accept `--force`.
### `publish`
```bash
narratio publish <session_id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--artifacts <name[,name...]>]
```
Purpose:
- Force-rerun the archive stage.
- Provide a shorter equivalent for `narratio run-stage --force archive`.
- Shorter equivalent for `narratio run-stage archive <session_id> --force`.
Syntax:
```bash
narratio publish [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--artifacts <name[,name...]>]
```
Success output:
- `narratio publish: executed=<n> skipped=<n> force=true; manifest=<path>`
Common failure cases:
- positional arguments.
- `--force`, because force is implicit.
- unknown configured artifact keys.
- archive-stage failures such as missing required promotion sources or locked storage errors.
### `restore`
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, matching what `prepare` would hydrate.
- `audio/**` is restored only with `--include-audio`.
Syntax:
```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.
- required previous-session current state or artifact missing.
- local conflicts without `--force`.
- session lock conflict.
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.
`publish` is force-by-design and does not accept `--force` or a stage positional argument.
### `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
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 <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--dry-run] [--clear-cache]
narratio clean --all [--config <pipeline.yml>] [--dry-run] [--clear-cache]
```
@@ -499,68 +153,173 @@ Cache behavior:
- `--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`.
Common failure cases:
- missing `--session-id` when `--all` is not set.
- combining `--all` with `--campaign`, `--session`, `--session-id`, or `--previous-session-id`.
- unsafe cleanup target, such as a symlink, a non-directory session target, a configured root directory, or a path outside the configured root.
### `session plan`
```bash
narratio session plan <session_id> [--config <pipeline.yml>] [--campaign <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 <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 <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 <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` and `--campaign` are optional overrides; omitted values use normal default config discovery.
- 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 <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 <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 <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
narratio session locks add <session_id> <source> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--reason <text>] [--force]
narratio session locks remove <session_id> <source> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>]
```
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
Default-discovery run:
```bash
narratio run --session-id 2026-04-04
narratio run 2026-04-04
```
Run only selected analyze artifacts:
```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:
```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:
```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 --session-id 2026-04-04
narratio publish 2026-04-04
```
Preview restore actions without writes:
```bash
narratio restore --session-id 2026-04-04 --dry-run
narratio session restore 2026-04-04 --dry-run
```
Restore and then force analyze:
```bash
narratio restore --session-id 2026-04-04
narratio analyze --session-id 2026-04-04
narratio session restore 2026-04-04
narratio analyze 2026-04-04
```
Rehydrate canonical previous-session inputs after artifact-input changes:
```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:
```bash
narratio clean --session-id 2026-04-04 --dry-run
narratio clean --session-id 2026-04-04
narratio restore --session-id 2026-04-04 --include-audio
narratio clean 2026-04-04 --dry-run
narratio clean 2026-04-04
narratio session restore 2026-04-04 --include-audio
```
Clean all local sessions while keeping cached S3 audio:
@@ -569,17 +328,6 @@ Clean all local sessions while keeping cached S3 audio:
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`, `analyze`, and `publish` print `manifest=<path>` on success.
## `--artifacts` and `--force`
- `--artifacts` filters which configured artifacts are executable when analyze runs and which configured artifact promotions archive publishes.

View File

@@ -11,10 +11,17 @@ Narratio loads three YAML files:
These commands load and validate all three files before running:
- `narratio run`
- `narratio plan`
- `narratio resume`
- `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:
@@ -31,10 +38,17 @@ Behavior:
These commands use the same config discovery behavior:
- `narratio run`
- `narratio plan`
- `narratio resume`
- `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:
@@ -59,7 +73,7 @@ Session config lookup:
1. `/usr/local/etc/narratio/session.yml`
2. `/etc/narratio/session.yml`
- 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`
- 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`.
@@ -71,7 +85,7 @@ Template behavior for local and remote `session.yml` loaded by downstream comman
- downstream commands do not render templates.
- 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 `--session-id`, load fails.
- 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`:
@@ -139,7 +153,7 @@ Why this is sufficient:
Minimal local-file usage:
```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 ./campaign.yml --session ./session.yml
```
Previous-session-enabled variant:
@@ -152,7 +166,7 @@ inputs:
```
```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 ./campaign.yml --session ./session.yml --previous-session-id 2026-04-26
```
## 5. Production-oriented config set
@@ -246,7 +260,7 @@ For S3-first operation, upload the same `session.yml` content to:
Then run with explicit or discovered pipeline/campaign config and no `--session`:
```bash
narratio run --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 /usr/local/etc/narratio/campaign.yml --previous-session-id 2026-04-26
```
Operational notes:
@@ -406,7 +420,7 @@ Remote mutable lock store:
- path: `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/locks.yml`.
- 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.
Restore-related implications:

View File

@@ -1,11 +1,12 @@
# Internal: Command Restore
## 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:
- 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`, `--session`, `--previous-session-id`, `--dry-run`, `--force`, `--include-audio`.
- Resolved/validated `pipeline.yml` and `session.yml`.
- Configured remote object store.
- Remote committed current-state markers (`current/run_id.txt`, `current/manifest.json`).

View File

@@ -48,7 +48,7 @@ None directly in this subsystem. Stages may use object storage adapters and then
- 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`.
- `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 --clear-cache` is the explicit opt-in for deleting matching S3 audio cache entries.

View File

@@ -11,22 +11,22 @@ For field-level configuration, see [docs/config.md](./config.md). For full comma
3. Run Narratio:
```bash
narratio run --session-id 2026-04-04
narratio run 2026-04-04
```
4. Read success output:
- `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:
- default config/campaign/session discovery checks system config locations unless `--config`, `--campaign`, and `--session` are 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.
Initialize a remote session skeleton:
```bash
narratio session init --session-id 2026-04-04 --remote
narratio session init 2026-04-04 --remote
```
Remote init uses normal default config discovery and writes `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. If `campaign.yml` sets `session_template_file`, init renders that template from the supplied flags and writes concrete YAML. Pass `--config` and `--campaign` when testing non-system config files. It fails if the object already exists unless `--force` is passed.
@@ -34,7 +34,7 @@ Remote init uses normal default config discovery and writes `{root_prefix}/campa
Validate before running:
```bash
narratio session validate --session-id 2026-04-04
narratio session validate 2026-04-04
```
## Restore workflow
@@ -44,19 +44,19 @@ Use restore when local durable session state is missing or stale and archive cur
Dry-run (no local writes):
```bash
narratio restore --session-id 2026-04-04 --dry-run
narratio session restore 2026-04-04 --dry-run
```
Execution:
```bash
narratio restore --session-id 2026-04-04
narratio session restore 2026-04-04
```
Post-restore analyze rerun pattern:
```bash
narratio analyze --session-id 2026-04-04
narratio analyze 2026-04-04
```
Restore source-of-truth:
@@ -73,9 +73,9 @@ Restore default scope:
Reset local state before restore testing:
```bash
narratio clean --session-id 2026-04-04 --dry-run
narratio clean --session-id 2026-04-04
narratio restore --session-id 2026-04-04 --include-audio
narratio clean 2026-04-04 --dry-run
narratio clean 2026-04-04
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.
@@ -136,18 +136,18 @@ Canonical previous-session input behavior:
- canonical sources use `narratio.previous_session.artifact.<artifact_key>`.
- 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:
- `narratio run-stage --session-id <id> --force prepare`
- or `narratio restore --session-id <id>` when remote archive current state is authoritative.
- `narratio run-stage prepare <id> --force`
- or `narratio session restore <id>` when remote archive current state is authoritative.
## Remote archive layout and publish contract
Preferred manual publish command:
```bash
narratio publish --session-id <id>
narratio publish <id>
```
`publish` is equivalent to `narratio run-stage --force archive`; use `run-stage` when you need the general single-stage command form.
`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:
@@ -176,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.
Lock helper behavior:
- `narratio locks --session-id <id>` lists effective static and remote locks.
- `narratio locks add --session-id <id> --reason <text> <source>` writes a remote lock.
- `narratio locks add --session-id <id> --force --reason <text> <source>` updates an existing remote lock reason.
- `narratio locks remove --session-id <id> <source>` removes only a remote lock.
- `narratio session locks <id>` lists effective static and remote locks.
- `narratio session locks add <id> <source> --reason <text>` writes a remote lock.
- `narratio session locks add <id> <source> --force --reason <text>` updates an existing remote lock reason.
- `narratio session locks remove <id> <source>` removes only a remote lock.
- `locks remove` cannot remove static pipeline locks.
- remote lock writes check whether the lock store exists, but are not compare-and-swap atomic.
@@ -214,7 +214,7 @@ Automatic cleanup toggles:
- `pipeline.workspace.cleanup_after_archive=true` deletes run-scoped local run directory.
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}`.
- `--dry-run` prints targets without deleting.
- `--clear-cache` also removes matching S3 audio cache files. Without it, cache is preserved.
@@ -248,25 +248,19 @@ Recommended recovery:
1. inspect state:
```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.
2. for one manifest file, run:
2. for restore-specific checks, run:
```bash
narratio status --manifest <manifest-path>
narratio session restore 2026-04-04 --dry-run
```
3. for restore-specific checks, run:
```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`.
3. fix root cause (config/input/credentials/storage/service availability).
4. continue with `resume`, or targeted `run-stage <stage> <id> --force` followed by `resume`.
## Restore report
@@ -283,9 +277,8 @@ Dry-run does not write restore report files.
## Operational caveats
- `status` with no config/session flags still requires explicit `--manifest`.
- `status --session-id <id>` uses normal config/session loading, including remote session fallback.
- `status --session-id <id>` includes the same promoted remote output availability view as `artifacts list --remote` when storage is configured.
- `session status <session_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.
- local and S3 audio input modes are mutually exclusive.
- archive publish requires upstream stages through `analyze` to be `succeeded`.
- required configured artifact promotions for unselected `--artifacts` keys are skipped intentionally; selected required promotions still fail if their files are missing.

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

@@ -6,7 +6,7 @@ Canonical operator troubleshooting guide for recurring implemented Narratio fail
## Config file discovery failure
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:
- `pipeline.yml`, `campaign.yml`, or `session.yml` is missing from system discovery paths.
@@ -40,7 +40,7 @@ Likely Cause:
Diagnostics:
```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 /path/to/campaign.yml --session ./session.yml
```
Safe Fix:
@@ -62,7 +62,7 @@ Likely Cause:
Diagnostics:
```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 /path/to/campaign.yml --session /path/to/session.yml
```
Safe Fix:
@@ -84,7 +84,7 @@ Likely Cause:
Diagnostics:
```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 /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout
```
Safe Fix:
@@ -95,22 +95,22 @@ Links:
- [docs/cli.md](./cli.md)
- [docs/config.md](./config.md)
## `run-stage --artifacts` on non-analyze stage
## `run-stage --artifacts` on unsupported stage
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:
- `--artifacts` was used with a non-`analyze` stage.
- `--artifacts` was used with a stage other than `analyze` or `archive`.
Diagnostics:
```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 /path/to/campaign.yml --session /path/to/session.yml --artifacts session_recap
```
Safe Fix:
- use `--artifacts` only with `run-stage ... analyze`.
- use `--artifacts` only with `run-stage analyze ...` or `run-stage archive ...`.
Links:
- [docs/cli.md](./cli.md)
@@ -129,7 +129,7 @@ Likely Cause:
Diagnostics:
```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 /path/to/campaign.yml --session /path/to/session.yml
```
Safe Fix:
@@ -153,8 +153,8 @@ Likely Cause:
Diagnostics:
```bash
narratio status --manifest /path/to/manifest.json
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 session status 2026-04-04
narratio run-stage analyze 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --artifacts player_handout
```
Safe Fix:
@@ -168,22 +168,21 @@ Links:
## Manifest/status path failure
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:
- wrong manifest path.
- wrong session ID.
- wrong config/campaign/session file selected.
- manifest removed after cleanup.
- `--manifest` omitted.
Diagnostics:
```bash
narratio status --manifest /path/to/manifest.json
ls -l /path/to/manifest.json
narratio session status 2026-04-04
```
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:
- [docs/cli.md](./cli.md)
@@ -192,7 +191,7 @@ Links:
## Session lock conflict (`.lock`)
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:
- another Narratio process is running same session.
@@ -217,7 +216,7 @@ Links:
## Restore remote current pointer or manifest missing
Symptom:
- `restore` fails with remote current pointer or current manifest errors.
- `session restore` fails with remote current pointer or current manifest errors.
Likely Cause:
- `current/run_id.txt` was never published.
@@ -227,7 +226,7 @@ Likely Cause:
Diagnostics:
```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 /path/to/campaign.yml --session /path/to/session.yml --dry-run
```
Safe Fix:
@@ -241,20 +240,20 @@ Links:
## Restore manifest identity mismatch
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:
- 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.
Diagnostics:
```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 /path/to/campaign.yml --session /path/to/session.yml --dry-run
```
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.
Links:
@@ -264,7 +263,7 @@ Links:
## Restore conflict without `--force`
Symptom:
- `restore` fails with `restore conflict` and conflict counts.
- `session restore` fails with `restore conflict` and conflict counts.
Likely Cause:
- local durable file differs from remote file for one or more planned restore paths.
@@ -272,7 +271,7 @@ Likely Cause:
Diagnostics:
```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 /path/to/campaign.yml --session /path/to/session.yml --dry-run
```
Safe Fix:
@@ -342,7 +341,7 @@ Likely Cause:
Diagnostics:
```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 /path/to/campaign.yml --session /path/to/session.yml
```
Safe Fix:
@@ -365,8 +364,8 @@ Likely Cause:
Diagnostics:
```bash
narratio status --manifest /path/to/manifest.json
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 session status 2026-04-04
narratio run-stage archive 2026-04-04 --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml
```
Safe Fix:

View File

@@ -21,7 +21,7 @@ func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
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", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&stdout,
&stderr,
)
@@ -55,12 +55,11 @@ func TestExecuteRunStageArchivePropagatesSelectedArtifacts(t *testing.T) {
var stderr bytes.Buffer
code := Execute(
[]string{
"run-stage",
"run-stage", "archive", "2026-05-03",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--artifacts", "session_recap",
"archive",
},
&stdout,
&stderr,
@@ -83,7 +82,7 @@ func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
[]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
&stdout,
&stderr,
)
@@ -110,7 +109,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
var out bytes.Buffer
err := RunStage(
context.Background(),
[]string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap", "analyze"},
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap"},
&out,
)
if err != nil {
@@ -138,7 +137,7 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
var out bytes.Buffer
err := Resume(
context.Background(),
[]string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
[]string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&out,
)
if err != nil {
@@ -173,7 +172,7 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{"analyze", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
&stdout,
&stderr,
)
@@ -210,6 +209,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
code := Execute(
[]string{
"analyze",
"2026-05-03",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
@@ -233,7 +233,7 @@ func TestExecuteAnalyzeUnknownArtifactFailsValidation(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{"analyze", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
&stdout,
&stderr,
)
@@ -251,7 +251,7 @@ func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
args []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"},
}
for _, tc := range cases {
@@ -272,7 +272,7 @@ func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"analyze"}, &stdout, &stderr)
code := Execute([]string{"analyze", "2026-05-03"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -307,7 +307,7 @@ func TestExecutePublishForceRunsArchive(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{"publish", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&stdout,
&stderr,
)
@@ -334,7 +334,7 @@ func TestExecutePublishRejectsUnsupportedArgsAndFlags(t *testing.T) {
args []string
want string
}{
{name: "positional", args: []string{"publish", "archive"}, want: "publish: unexpected positional arguments"},
{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 {
@@ -359,7 +359,7 @@ func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{"publish", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
&stdout,
&stderr,
)
@@ -374,7 +374,7 @@ func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
func TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"publish"}, &stdout, &stderr)
code := Execute([]string{"publish", "2026-05-03"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}

View File

@@ -16,6 +16,7 @@ import (
// Clean removes local workspace/spool state while preserving durable cache
// state unless cache cleanup is explicitly requested.
func Clean(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("clean", flag.ContinueOnError)
fs.SetOutput(io.Discard)
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 {
return fmt.Errorf("clean: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("clean: unexpected positional arguments")
if positionalSessionID == "" {
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 {
return cleanAllLocal(flags, dryRun, clearCache, out)
@@ -40,7 +50,7 @@ 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 {
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())
if err != nil {
@@ -85,7 +95,7 @@ func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writ
strings.TrimSpace(flags.sessionPath) != "" ||
strings.TrimSpace(flags.sessionID) != "" ||
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, --session, a session_id, or --previous-session-id")
}
resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath)
if err != nil {

View File

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

View File

@@ -7,7 +7,7 @@ import (
"strings"
)
var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage", "analyze", "publish", "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.
func Execute(args []string, stdout, stderr io.Writer) int {
@@ -24,10 +24,6 @@ func Execute(args []string, stdout, stderr io.Writer) int {
switch cmd {
case "run":
err = Run(ctx, cmdArgs, stdout)
case "plan":
err = Plan(ctx, cmdArgs, stdout)
case "status":
err = Status(ctx, cmdArgs, stdout)
case "resume":
err = Resume(ctx, cmdArgs, stdout)
case "run-stage":
@@ -36,14 +32,8 @@ func Execute(args []string, stdout, stderr io.Writer) int {
err = Analyze(ctx, cmdArgs, stdout)
case "publish":
err = Publish(ctx, cmdArgs, stdout)
case "restore":
err = Restore(ctx, cmdArgs, stdout)
case "session":
err = Session(ctx, cmdArgs, stdout)
case "artifacts":
err = Artifacts(ctx, cmdArgs, stdout)
case "locks":
err = Locks(ctx, cmdArgs, stdout)
case "clean":
err = Clean(ctx, cmdArgs, stdout)
default:

View File

@@ -25,18 +25,17 @@ func TestExecuteValidCommands(t *testing.T) {
defer srv.Close()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
manifestPath := writeManifestPathForExecute(t)
cases := []struct {
name string
args []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: "plan", args: []string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"},
{name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"},
{name: "resume", args: []string{"resume", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
{name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"},
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
{name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
}
for _, tc := range cases {
@@ -64,13 +63,13 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
args []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: "plan missing flags", args: []string{"plan"}, want: "plan: no pipeline config path provided and no default pipeline config found; searched:"},
{name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"},
{name: "resume missing flags", args: []string{"resume"}, want: "resume: no pipeline config path provided and no default pipeline config found; searched:"},
{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 config flags", args: []string{"run-stage", "polish"}, want: "run-stage: no pipeline config path provided and no default pipeline config found; searched:"},
{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 session", args: []string{"run"}, want: "run: session_id is required"},
{name: "plan old top-level removed", args: []string{"plan"}, want: `unknown command: "plan"`},
{name: "status old top-level removed", args: []string{"status"}, want: `unknown command: "status"`},
{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 stage name and session_id"},
{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", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
}
for _, tc := range cases {
@@ -99,7 +98,7 @@ func TestExecuteRunStageUnknownFails(t *testing.T) {
var stdout 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", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -117,7 +116,7 @@ func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) {
var stdout 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", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
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 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", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String())
}
stdout.Reset()
stderr.Reset()
code = Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "transcribe"}, &stdout, &stderr)
code = Execute([]string{"run-stage", "transcribe", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -238,7 +237,7 @@ inputs:
var stdout 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", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -286,7 +285,7 @@ inputs:
var stdout 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", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -315,7 +314,7 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
var stdout 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 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -336,7 +335,7 @@ func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) {
var stdout 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 {
t.Fatal("exit code = 0, want non-zero")
}

View File

@@ -39,7 +39,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
sessionID := strings.TrimSpace(sessionOpts.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(base.Pipeline.Storage.S3.RootPrefix, base.Campaign.Campaign, sessionID)

View File

@@ -44,7 +44,6 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&flags.campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
fs.StringVar(&flags.sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
}
@@ -58,18 +57,42 @@ func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
// Session dispatches session helper subcommands.
func Session(ctx context.Context, args []string, out io.Writer) error {
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] {
case "validate":
return SessionValidate(ctx, args[1:], out)
case "init":
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:
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.
func Artifacts(ctx context.Context, args []string, out io.Writer) error {
if len(args) == 0 {
@@ -85,6 +108,7 @@ func Artifacts(ctx context.Context, args []string, out io.Writer) error {
// SessionValidate performs a read-only session preflight.
func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("session validate", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var flags commonConfigFlags
@@ -92,8 +116,20 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("session validate: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("session validate: unexpected positional arguments")
if positionalSessionID == "" {
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{}
@@ -152,25 +188,30 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
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 {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("status", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var manifestPath string
var flags commonConfigFlags
fs.StringVar(&manifestPath, "manifest", "", "path to manifest.json")
addCommonConfigFlags(fs, &flags)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("status: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("status: unexpected positional arguments")
if positionalSessionID == "" {
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) != "" {
return statusManifest(ctx, manifestPath, out)
}
if flags.pipelinePath == "" && flags.campaignPath == "" && flags.sessionPath == "" && flags.sessionID == "" && flags.previousSessionID == "" {
return fmt.Errorf("status: --manifest is required")
if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("status: session_id is required")
}
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions())
if err != nil {
@@ -231,36 +272,20 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
writeLocks(out, cfg, locks)
}
fmt.Fprintln(out, "Next actions:")
fmt.Fprintf(out, "- narratio session validate --session-id %s\n", cfg.Session.SessionID)
fmt.Fprintf(out, "- narratio restore --session-id %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)
fmt.Fprintf(out, "- narratio session validate %s\n", cfg.Session.SessionID)
fmt.Fprintf(out, "- narratio session restore %s --dry-run\n", cfg.Session.SessionID)
return nil
}
// SessionInit creates a local or remote session.yml skeleton.
func SessionInit(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var pipelinePath, campaignPath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
var remote, force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionID, "session-id", "", "session identifier")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier")
fs.StringVar(&date, "date", "", "session date")
fs.StringVar(&title, "title", "", "session title")
@@ -272,11 +297,20 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("session init: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("session init: unexpected positional arguments")
if positionalSessionID == "" {
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(sessionID) == "" {
return fmt.Errorf("session init: --session-id is required")
return fmt.Errorf("session init: session_id is required")
}
if (strings.TrimSpace(output) == "") == !remote {
return fmt.Errorf("session init: specify exactly one target: --output <path> or --remote")
@@ -367,6 +401,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
// ArtifactsList lists effective artifact sources.
func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("artifacts list", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var flags commonConfigFlags
@@ -376,8 +411,20 @@ func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("artifacts list: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("artifacts list: unexpected positional arguments")
if positionalSessionID == "" {
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)
if err != nil {
@@ -412,6 +459,7 @@ func Locks(ctx context.Context, args []string, out io.Writer) error {
// LocksList lists effective archive locks.
func LocksList(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("locks", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var flags commonConfigFlags
@@ -419,11 +467,20 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("locks: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("locks: unexpected positional arguments")
if positionalSessionID == "" {
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) == "" {
return fmt.Errorf("locks: --session-id is required")
return fmt.Errorf("locks: session_id is required")
}
cfg, _, locks, _, err := loadHelperContext(ctx, flags, true)
if err != nil {
@@ -435,6 +492,13 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error {
// LocksAdd adds or updates one remote lock.
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.SetOutput(io.Discard)
var flags commonConfigFlags
@@ -446,13 +510,21 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("locks add: invalid flags: %w", err)
}
if fs.NArg() != 1 {
return fmt.Errorf("locks add: expected exactly one source id")
if source == "" {
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) == "" {
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)
if err != nil {
return fmt.Errorf("locks add: %w", err)
@@ -475,12 +547,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 {
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
}
// LocksRemove removes one remote lock.
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.SetOutput(io.Discard)
var flags commonConfigFlags
@@ -488,13 +567,21 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("locks remove: invalid flags: %w", err)
}
if fs.NArg() != 1 {
return fmt.Errorf("locks remove: expected exactly one source id")
if source == "" {
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) == "" {
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)
if err != nil {
return fmt.Errorf("locks remove: %w", err)
@@ -514,7 +601,7 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.ArchiveLockStore{Locks: remoteLocks}); err != nil {
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
}

View File

@@ -25,10 +25,9 @@ func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--title", "The Black Cabin",
"--remote",
}, &stdout, &stderr)
@@ -59,8 +58,7 @@ func TestExecuteSessionInitRemoteUsesDefaultConfigDiscovery(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--session-id", "2026-06-07",
"session", "init", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code != 0 {
@@ -84,8 +82,7 @@ func TestExecuteSessionInitLocalUsesDefaultConfigDiscovery(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--session-id", "2026-06-07",
"session", "init", "2026-06-07",
"--output", outputPath,
}, &stdout, &stderr)
if code != 0 {
@@ -126,10 +123,9 @@ inputs:
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"session", "init", "2026-06-07",
"--config", defaultPipeline,
"--campaign", explicitCampaign,
"--session-id", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code != 0 {
@@ -156,7 +152,7 @@ func TestExecuteSessionInitRequiresSessionID(t *testing.T) {
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "session init: --session-id is required") {
if !strings.Contains(stderr.String(), "session init: session_id is required") {
t.Fatalf("stderr = %q, want session-id required error", stderr.String())
}
}
@@ -173,7 +169,7 @@ func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "init", "--session-id", "2026-06-07", "--remote"}, &stdout, &stderr)
code := Execute([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -208,7 +204,7 @@ func TestExecuteSessionInitRemoteLoadsSecretsBeforeObjectStoreInit(t *testing.T)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"session", "init", "--session-id", "2026-06-07", "--remote"}, &stdout, &stderr)
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())
}
@@ -230,10 +226,9 @@ inputs:
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--previous-session-id", "2026-05-31",
"--date", "2026-06-07",
"--title", "The Black Cabin",
@@ -279,10 +274,9 @@ inputs:
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code != 0 {
@@ -318,10 +312,9 @@ inputs:
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--output", outputPath,
}, &stdout, &stderr)
if code != 0 {
@@ -349,10 +342,9 @@ inputs:
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code == 0 {
@@ -375,10 +367,9 @@ inputs:
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--title", "Unused Title",
"--remote",
}, &stdout, &stderr)
@@ -403,10 +394,9 @@ inputs:
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"session", "init", "2026-06-07",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code == 0 {
@@ -451,7 +441,7 @@ inputs:
var stdout 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", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
@@ -470,13 +460,11 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"locks", "add",
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--reason", "manual edit",
"narratio.transcript.final_trimmed",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -493,11 +481,10 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
stdout.Reset()
stderr.Reset()
code = Execute([]string{
"locks",
"session", "locks", "2026-05-03",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("locks list exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -509,12 +496,10 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
stdout.Reset()
stderr.Reset()
code = Execute([]string{
"locks", "remove",
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"narratio.transcript.final_trimmed",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -541,13 +526,11 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"locks", "add",
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--reason", "first",
"narratio.transcript.final_trimmed",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("initial locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -556,13 +539,11 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
stdout.Reset()
stderr.Reset()
code = Execute([]string{
"locks", "add",
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--reason", "second",
"narratio.transcript.final_trimmed",
}, &stdout, &stderr)
if code == 0 {
t.Fatal("duplicate locks add exit code = 0, want non-zero")
@@ -574,14 +555,12 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
stdout.Reset()
stderr.Reset()
code = Execute([]string{
"locks", "add",
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--reason", "second",
"--force",
"narratio.transcript.final_trimmed",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("forced locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -598,9 +577,9 @@ func TestExecuteLocksRequireSessionID(t *testing.T) {
args []string
want string
}{
{"list", []string{"locks"}, "locks: --session-id is required"},
{"add", []string{"locks", "add", "narratio.transcript.final_trimmed"}, "locks add: --session-id is required"},
{"remove", []string{"locks", "remove", "narratio.transcript.final_trimmed"}, "locks remove: --session-id is required"},
{"list", []string{"session", "locks"}, "locks: session_id is required"},
{"add", []string{"session", "locks", "add", "narratio.transcript.final_trimmed"}, "locks add: expected session_id and source id"},
{"remove", []string{"session", "locks", "remove", "narratio.transcript.final_trimmed"}, "locks remove: expected session_id and source id"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -628,12 +607,10 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"locks", "add",
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"narratio.transcript.final_trimmed",
}, &stdout, &stderr)
if code == 0 {
t.Fatal("locks add static lock exit code = 0, want non-zero")
@@ -645,12 +622,10 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
stdout.Reset()
stderr.Reset()
code = Execute([]string{
"locks", "remove",
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"narratio.transcript.final_trimmed",
}, &stdout, &stderr)
if code == 0 {
t.Fatal("locks remove static lock exit code = 0, want non-zero")
@@ -734,7 +709,7 @@ func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"artifacts", "list",
"session", "artifacts", "2026-05-03",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
@@ -770,7 +745,7 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"artifacts", "list",
"session", "artifacts", "2026-05-03",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
@@ -827,11 +802,10 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"status",
"session", "status", "2026-05-03",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -872,11 +846,10 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"status",
"session", "status", "2026-05-03",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
@@ -911,7 +884,7 @@ func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) {
var stdout 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", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}

View File

@@ -7,6 +7,7 @@ import (
"io"
"log/slog"
"os"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -16,6 +17,7 @@ import (
// Plan validates configuration, prepares the local workdir, and prints stage order.
func Plan(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
fs.SetOutput(io.Discard)
@@ -28,15 +30,26 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("plan: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("plan: unexpected positional arguments")
if positionalSessionID == "" {
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
}
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("plan: session_id is required")
}
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
SessionID: sessionID,
@@ -68,7 +81,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
runCount := 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
}
for _, d := range decisions {

View File

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

View File

@@ -29,14 +29,14 @@ inputs:
var stdout 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", campaignPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if storeInitCalls != 1 {
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())
}
if _, ok := fake.Objects[remoteKey]; !ok {
@@ -77,7 +77,7 @@ inputs:
var stdout 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", campaignPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
@@ -92,7 +92,7 @@ func TestExecuteExplicitLocalSessionPrecedenceSkipsRemote(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"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", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -110,7 +110,7 @@ func TestExecuteLocalSessionDiscoveryPrecedenceSkipsRemote(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -129,7 +129,7 @@ func TestExecuteRemoteSessionMissingObjectFailsClearly(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -149,12 +149,12 @@ func TestExecuteRemoteSessionRequiresSessionID(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
code := Execute([]string{"session", "plan", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "remote session loading requires --session-id") {
t.Fatalf("stderr = %q, want session-id guidance", stderr.String())
if !strings.Contains(stderr.String(), "plan: session_id is required") {
t.Fatalf("stderr = %q, want session_id guidance", stderr.String())
}
if storeInitCalls != 0 {
t.Fatalf("object store init calls = %d, want 0", storeInitCalls)
@@ -177,7 +177,7 @@ func TestExecuteRemoteSessionStorageInitErrorFailsClearly(t *testing.T) {
var stdout 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", campaignPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -196,7 +196,7 @@ func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -219,7 +219,7 @@ inputs:
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -238,7 +238,7 @@ func TestExecuteRemoteSessionMismatchFails(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}

View File

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

View File

@@ -32,7 +32,7 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
var stdout 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", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -74,7 +74,7 @@ func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -100,7 +100,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -115,7 +115,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
}
stdout.Reset()
stderr.Reset()
code = Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
code = Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -138,7 +138,7 @@ func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -171,7 +171,7 @@ func TestExecuteRestoreDryRunReportsPreviousCacheWithoutWriting(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -200,7 +200,7 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -232,7 +232,7 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -259,7 +259,7 @@ func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
@@ -285,7 +285,7 @@ func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -332,7 +332,7 @@ func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}

View File

@@ -18,7 +18,7 @@ func TestExecuteRestoreHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--help"}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "--help"}, &stdout, &stderr)
if code != 0 {
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())
}
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)
}
if !strings.Contains(out, "--include-audio") {
@@ -79,11 +79,10 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
var stderr bytes.Buffer
code := Execute(
[]string{
"restore",
"session", "restore", "2026-05-03",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--dry-run",
"--force",
"--include-audio",
@@ -124,7 +123,7 @@ func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
var stdout 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", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -146,7 +145,7 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
var stdout 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", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -174,7 +173,7 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
var stdout 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", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -263,11 +262,10 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
var stderr bytes.Buffer
code := Execute(
[]string{
"restore",
"session", "restore", "2026-05-03",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--dry-run",
},
&stdout,
@@ -315,7 +313,7 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
@@ -367,7 +365,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
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
restoreCode := Execute(
[]string{
"restore",
"session", "restore", cfg.Session.SessionID,
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", cfg.Session.SessionID,
},
&stdout,
&stderr,
@@ -85,14 +84,12 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
stderr.Reset()
runStageCode := Execute(
[]string{
"run-stage",
"run-stage", "analyze", cfg.Session.SessionID,
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", cfg.Session.SessionID,
"--force",
"--artifacts", "player_handout",
"analyze",
},
&stdout,
&stderr,
@@ -193,11 +190,10 @@ previous_session_id: 2026-04-26
var stderr bytes.Buffer
restoreCode := Execute(
[]string{
"restore",
"session", "restore", cfg.Session.SessionID,
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", cfg.Session.SessionID,
},
&stdout,
&stderr,
@@ -244,14 +240,12 @@ previous_session_id: 2026-04-26
stderr.Reset()
runStageCode := Execute(
[]string{
"run-stage",
"run-stage", "analyze", cfg.Session.SessionID,
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", cfg.Session.SessionID,
"--force",
"--artifacts", "session_recap",
"analyze",
},
&stdout,
&stderr,

View File

@@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"io"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -13,6 +14,7 @@ import (
// Resume continues execution from the first non-succeeded stage in the manifest.
func Resume(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("resume", flag.ContinueOnError)
fs.SetOutput(io.Discard)
@@ -26,7 +28,6 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
@@ -34,8 +35,20 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("resume: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("resume: unexpected positional arguments")
if positionalSessionID == "" {
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
}
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("resume: session_id is required")
}
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
SessionID: sessionID,

View File

@@ -32,7 +32,7 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer
err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
}
@@ -64,7 +64,7 @@ func TestResumeNoRemainingStages(t *testing.T) {
}
var out bytes.Buffer
err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
}
@@ -93,7 +93,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
}
var out bytes.Buffer
err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out)
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
}
@@ -111,7 +111,7 @@ func TestRunStageExecutesOnlySelectedStage(t *testing.T) {
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, &out)
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("RunStage() error = %v", err)
}
@@ -148,7 +148,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
}
var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, &out)
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("RunStage() error = %v", err)
}
@@ -157,7 +157,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
}
out.Reset()
err = RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &out)
err = RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out)
if err != nil {
t.Fatalf("RunStage(force) error = %v", err)
}
@@ -184,7 +184,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
}
var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &out)
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out)
if err != nil {
t.Fatalf("RunStage(force) error = %v", err)
}
@@ -203,7 +203,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
}
out.Reset()
err = Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
err = Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
}
@@ -220,7 +220,7 @@ func TestRunStageTrimExecutes(t *testing.T) {
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "trim"}, &out)
err := RunStage(context.Background(), []string{"trim", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("RunStage(trim) error = %v", err)
}
@@ -249,7 +249,7 @@ func TestRunStageNormalizeExecutes(t *testing.T) {
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1},{"id":2}]}`)
var out bytes.Buffer
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "normalize"}, &out)
err := RunStage(context.Background(), []string{"normalize", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("RunStage(normalize) error = %v", err)
}

View File

@@ -5,12 +5,14 @@ import (
"flag"
"fmt"
"io"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
// Run executes the pipeline plan and persists manifest state.
func Run(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
@@ -24,7 +26,6 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
@@ -32,8 +33,20 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("run: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("run: unexpected positional arguments")
if positionalSessionID == "" {
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
}
}
if strings.TrimSpace(sessionID) == "" {
return fmt.Errorf("run: session_id is required")
}
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
SessionID: sessionID,

View File

@@ -5,12 +5,21 @@ import (
"flag"
"fmt"
"io"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
// RunStage executes exactly one selected stage.
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.SetOutput(io.Discard)
@@ -24,7 +33,6 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute or publish (comma-separated or repeatable)")
@@ -32,14 +40,27 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("run-stage: invalid flags: %w", err)
}
if fs.NArg() != 1 {
return fmt.Errorf("run-stage: expected exactly one stage name")
if stageName == "" {
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()
if err != nil {
return fmt.Errorf("run-stage: invalid --artifacts: %w", err)
}
stageName := fs.Arg(0)
if len(normalizedArtifacts) > 0 && stageName != "analyze" && stageName != "archive" {
return fmt.Errorf("run-stage: --artifacts is only supported for stages \"analyze\" and \"archive\"")
}
@@ -73,6 +94,7 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
// Analyze force-runs the analyze stage.
func Analyze(ctx context.Context, args []string, out io.Writer) error {
positionalSessionID, args := pullLeadingSessionID(args)
fs := flag.NewFlagSet("analyze", flag.ContinueOnError)
fs.SetOutput(io.Discard)
@@ -85,15 +107,26 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute during analyze (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("analyze: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("analyze: unexpected positional arguments")
if positionalSessionID == "" {
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()
if err != nil {
@@ -127,6 +160,7 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
// 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)
@@ -139,15 +173,26 @@ func Publish(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
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 fs.NArg() != 0 {
return fmt.Errorf("publish: unexpected positional arguments")
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 {

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

@@ -33,9 +33,9 @@ inputs:
var out bytes.Buffer
err := Plan(context.Background(), []string{
"2026-04-04",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-04-04",
"--previous-session-id", "2026-03-28",
}, &out)
if err == nil {
@@ -54,7 +54,7 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var out bytes.Buffer
err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-04-04"}, &out)
err := Plan(context.Background(), []string{"2026-04-04", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
if err == nil {
t.Fatal("expected error, got nil")
}
@@ -82,10 +82,10 @@ inputs:
var out bytes.Buffer
err := Plan(context.Background(), []string{
"2026-05-03",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--previous-session-id", "2026-04-25",
}, &out)
if err == nil {
@@ -96,12 +96,12 @@ inputs:
}
}
func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) {
func TestRunStageAcceptsPositionalSessionIDAndParsesStageName(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
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", campaignPath, "--session", sessionPath}, &out)
if err != nil {
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", 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", 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", 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", campaignPath, "--session", sessionPath},
wantStage: "prepare",
wantForce: false,
},
{
name: "analyze",
args: []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
wantStage: "analyze",
wantForce: true,
},
{
name: "publish",
args: []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
wantStage: "archive",
wantForce: true,
},
{
name: "run-stage",
args: []string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign", 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", campaignPath, "--session", sessionPath},
want: "OK config",
},
{
name: "status",
args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
want: "Session: 2026-05-03",
},
{
name: "plan",
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
want: "narratio session plan: workdir prepared",
},
{
name: "artifacts",
args: []string{"session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
want: "Built-in:",
},
{
name: "locks",
args: []string{"session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign", 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", 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", 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", 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", 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 (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestStatusCommandReadsManifest(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) {
func TestStatusCommandRequiresSessionID(t *testing.T) {
var out bytes.Buffer
err := Status(context.Background(), nil, &out)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "--manifest is required") {
t.Fatalf("error = %q, want missing manifest flag", err.Error())
if !strings.Contains(err.Error(), "status: session_id is required") {
t.Fatalf("error = %q, want missing session_id error", err.Error())
}
}
func TestStatusCommandBadManifest(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)
}
func TestStatusCommandRejectsManifestFlag(t *testing.T) {
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 {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "decode manifest") {
t.Fatalf("error = %q, want decode error", err.Error())
if !strings.Contains(err.Error(), "status: invalid flags: flag provided but not defined: -manifest") {
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
}