From 7657ec3ad67e1b87167cfdf77ac84dc2875abe32 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 22 May 2026 22:09:17 -0500 Subject: [PATCH] CLI cleanup to consolidate session-related subcommands --- README.md | 2 +- docs/cli.md | 638 ++++++------------ docs/config.md | 34 +- docs/internal/command-restore.md | 5 +- docs/internal/workspace.md | 2 +- docs/operations.md | 61 +- docs/roadmap/cli.md | 255 +++++++ docs/troubleshooting.md | 59 +- .../app/analyze_artifacts_commands_test.go | 28 +- internal/app/clean.go | 18 +- internal/app/clean_test.go | 14 +- internal/app/commands.go | 12 +- internal/app/commands_test.go | 41 +- internal/app/config_loader.go | 2 +- internal/app/operator_helpers.go | 193 ++++-- internal/app/operator_helpers_test.go | 87 +-- internal/app/plan.go | 21 +- internal/app/plan_test.go | 10 +- internal/app/remote_session_test.go | 26 +- internal/app/restore.go | 21 +- internal/app/restore_execution_test.go | 22 +- internal/app/restore_test.go | 20 +- internal/app/restore_workflow_test.go | 14 +- internal/app/resume.go | 19 +- internal/app/resume_run_stage_test.go | 20 +- internal/app/run.go | 19 +- internal/app/run_stage.go | 65 +- internal/app/session_args.go | 43 ++ internal/app/session_cli_test.go | 10 +- internal/app/session_oriented_cli_test.go | 353 ++++++++++ internal/app/status_test.go | 57 +- 31 files changed, 1341 insertions(+), 830 deletions(-) create mode 100644 docs/roadmap/cli.md create mode 100644 internal/app/session_args.go create mode 100644 internal/app/session_oriented_cli_test.go diff --git a/README.md b/README.md index 0ba1910..0cbf0df 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/cli.md b/docs/cli.md index 461caf0..624852d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 `: execute pipeline stages and persist manifest state. +- `run-stage `: execute exactly one stage. +- `resume `: continue from first non-succeeded stage unless forced. +- `analyze `: force-rerun the analyze stage. +- `publish `: force-rerun the archive stage. +- `clean |--all`: remove local workspace/spool state. +- `session `: session-scoped helper commands. + +Session subcommands: + +- `session init `: create local or remote `session.yml`. +- `session validate `: run read-only preflight checks. +- `session status `: inspect local/remote session state. +- `session plan `: validate config, prepare workspace layout, and print stage run/skip decisions. +- `session restore `: restore durable local state from committed remote archive state. +- `session artifacts `: list effective artifact source IDs. +- `session locks `: list archive promotion locks. +- `session locks add `: add or update a remote lock. +- `session locks remove `: 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 `: optional explicit `pipeline.yml` path. - `--campaign `: optional explicit `campaign.yml` path. -- `--session `: optional explicit `session.yml` path. -- `--session-id `: expected session identifier and remote session lookup value. +- `--session `: optional explicit concrete `session.yml` path. - `--previous-session-id `: expected previous session identifier. -- `--force`: force stage execution. -- `--artifacts `: configured artifact keys to execute and publish (repeatable or comma-separated). -### `plan` +The positional `` 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 ` -- `--campaign ` -- `--session ` -- `--session-id ` -- `--previous-session-id ` -- `--force` +## Command Reference + +### `run` + +```bash +narratio run [--config ] [--campaign ] [--session ] [--previous-session-id ] [--force] [--artifacts ] +``` + +Purpose: +- Execute configured stages in canonical order. + +Success output: +- `narratio run: session ; executed= skipped=; manifest=` + +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 ` -- `--campaign ` -- `--session ` -- `--session-id ` -- `--previous-session-id ` -- `--force` -- `--artifacts `: configured artifact keys to execute and publish (repeatable or comma-separated). +```bash +narratio resume [--config ] [--campaign ] [--session ] [--previous-session-id ] [--force] [--artifacts ] +``` + +Purpose: +- Continue from session-manifest stage status. + +Success output: +- `narratio resume: session has no remaining stages` +- or `narratio resume: session ; executed= skipped=; manifest=` ### `run-stage` -- `--config ` -- `--campaign ` -- `--session ` -- `--session-id ` -- `--previous-session-id ` -- `--force` -- `--artifacts `: configured artifact keys to execute or publish (repeatable or comma-separated). -- positional ``: required stage name. - -### `analyze` - -- `--config ` -- `--campaign ` -- `--session ` -- `--session-id ` -- `--previous-session-id ` -- `--artifacts `: configured artifact keys to execute (repeatable or comma-separated). - -`analyze` is force-by-design and does not accept `--force`. - -### `publish` - -- `--config ` -- `--campaign ` -- `--session ` -- `--session-id ` -- `--previous-session-id ` -- `--artifacts `: 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 [--config ] [--campaign ] [--session ] [--previous-session-id ] [--force] [--artifacts ] +``` Valid stage names: @@ -110,378 +103,39 @@ Valid stage names: - `archive` - `notify` -### `restore` - -- `--config ` -- `--campaign ` -- `--session ` -- `--session-id ` -- `--previous-session-id ` -- `--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 `: required for session cleanup unless `--all` is set. -- `--config ` -- `--campaign ` -- `--session ` -- `--previous-session-id ` -- `--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 `: inspect one manifest file. -- `--config ` -- `--campaign ` -- `--session ` -- `--session-id ` -- `--previous-session-id ` - -### `session validate` - -- `--config ` -- `--campaign ` -- `--session ` -- `--session-id ` -- `--previous-session-id ` - -### `session init` - -- `--config `: optional explicit `pipeline.yml` path. -- `--campaign `: optional explicit `campaign.yml` path. -- `--session-id `: required. -- `--output `: 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 ` -- `--date ` -- `--title ` -- `--audio-s3-prefix `: defaults to `audio/` when neither audio flag is provided. -- `--audio-dir `: 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 ` -- `--campaign ` -- `--session ` -- `--session-id ` -- `--previous-session-id ` -- `--remote`: check remote availability for configured archive promotion destinations. - -### `locks` - -- `--session-id `: required for list, add, and remove. -- `--config `: optional explicit `pipeline.yml` path. -- `--campaign `: optional explicit `campaign.yml` path. -- `--session `: optional explicit `session.yml` path. -- `--previous-session-id `: optional expected previous session identifier. -- `add `: add a remote lock for one artifact or transcript source. -- `add --reason `: record an optional remote lock reason. -- `add --force`: update the reason for an existing remote lock. -- `remove `: remove one remote lock. - -## Command Reference - -### `run` - -Purpose: -- Execute configured stages in canonical order. - -Syntax: - -```bash -narratio run [--config ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] -``` - -Success output: -- `narratio run: session ; executed= skipped=; manifest=` - -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 ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--force] -``` - -Success output includes: -- `narratio plan: workdir prepared at ` -- one line per stage (`: run|skip`) -- `totals: run= skip=` - -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 ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] -``` - -Success output: -- `narratio resume: session has no remaining stages` -- or `narratio resume: session ; executed= skipped=; manifest=` - -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 -narratio status [--config ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] -``` - -Manifest output includes: -- `session_id: ` -- `updated_at: ` -- `stages:` entries (`- : `) - -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 ] [--campaign ] [--session ] [--session-id ] [--previous-session-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 --output ./session.yml -narratio session init --session-id --remote -narratio session init --config --campaign --session-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 ] [--campaign ] [--session ] [--session-id ] [--previous-session-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=` 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 -narratio locks add --session-id [--reason ] [--force] -narratio locks remove --session-id -``` - -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 ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--force] [--artifacts ] -``` - Success output: - `narratio run-stage: stage= executed= skipped= force=; manifest=` -`--artifacts` behavior: -- accepted only when `` 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 ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--artifacts ] +narratio analyze [--config ] [--campaign ] [--session ] [--previous-session-id ] [--artifacts ] ``` -Success output: -- `narratio analyze: executed= skipped= force=true; manifest=` +Purpose: +- Force-rerun the analyze stage. +- Shorter equivalent for `narratio run-stage analyze --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 [--config ] [--campaign ] [--session ] [--previous-session-id ] [--artifacts ] +``` + Purpose: - Force-rerun the archive stage. -- Provide a shorter equivalent for `narratio run-stage --force archive`. +- Shorter equivalent for `narratio run-stage archive --force`. -Syntax: - -```bash -narratio publish [--config ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--artifacts ] -``` - -Success output: -- `narratio publish: executed= skipped= force=true; manifest=` - -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 ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--dry-run] [--force] [--include-audio] -``` - -Success output (dry-run): -- `Restore plan for /` -- `Remote run: ` -- `Would download: ` -- `Would skip unchanged: ` -- `Conflicts: ` - -Success output (non-dry-run): -- `Restored session archive for /` -- `Remote run: ` -- `Downloaded: ` -- `Skipped unchanged: ` -- `Conflicts: ` - -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 [--config ] [--campaign ] [--session ] [--previous-session-id ] [--dry-run] [--clear-cache] +narratio clean [--config ] [--campaign ] [--session ] [--previous-session-id ] [--dry-run] [--clear-cache] narratio clean --all [--config ] [--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 [--config ] [--campaign ] [--session ] [--previous-session-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 ` +- one line per stage (`: run|skip`) +- `totals: run= skip=` + +### `session status` + +```bash +narratio session status [--config ] [--campaign ] [--session ] [--previous-session-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 [--config ] [--campaign ] [--session ] [--previous-session-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 --output ./session.yml +narratio session init --remote +narratio session init --config --campaign --remote +``` + +Additional flags: + +- `--previous-session-id ` +- `--date ` +- `--title ` +- `--audio-s3-prefix `: defaults to `audio/` when neither audio flag is provided. +- `--audio-dir `: 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 [--config ] [--campaign ] [--session ] [--previous-session-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 [--config ] [--campaign ] [--session ] [--previous-session-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=` when that destination differs from the source's canonical path. + +### `session locks` + +```bash +narratio session locks [--config ] [--campaign ] [--session ] [--previous-session-id ] +narratio session locks add [--config ] [--campaign ] [--session ] [--previous-session-id ] [--reason ] [--force] +narratio session locks remove [--config ] [--campaign ] [--session ] [--previous-session-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 -``` - -Get manifest path from previous output: -- `run`, `resume`, `run-stage`, `analyze`, and `publish` print `manifest=` on success. - ## `--artifacts` and `--force` - `--artifacts` filters which configured artifacts are executable when analyze runs and which configured artifact promotions archive publishes. diff --git a/docs/config.md b/docs/config.md index a50d390..495b39e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 ` 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 ` 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 ` 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 `` 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 ``, 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: diff --git a/docs/internal/command-restore.md b/docs/internal/command-restore.md index aea2102..364d105 100644 --- a/docs/internal/command-restore.md +++ b/docs/internal/command-restore.md @@ -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 `. +- 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`). diff --git a/docs/internal/workspace.md b/docs/internal/workspace.md index 512f37e..00580e5 100644 --- a/docs/internal/workspace.md +++ b/docs/internal/workspace.md @@ -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 ` removes the session work root and session spool root. +- `narratio clean ` 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. diff --git a/docs/operations.md b/docs/operations.md index 9b8fbc9..3e46f5e 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 ; executed= skipped=; manifest=` -- use `manifest=` with `status` for inspection. +- use `narratio session status ` 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 `` 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.`. - 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 --force prepare` - - or `narratio restore --session-id ` when remote archive current state is authoritative. + - `narratio run-stage prepare --force` + - or `narratio session restore ` when remote archive current state is authoritative. ## Remote archive layout and publish contract Preferred manual publish command: ```bash -narratio publish --session-id +narratio publish ``` -`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 --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 ` lists effective static and remote locks. -- `narratio locks add --session-id --reason ` writes a remote lock. -- `narratio locks add --session-id --force --reason ` updates an existing remote lock reason. -- `narratio locks remove --session-id ` removes only a remote lock. +- `narratio session locks ` lists effective static and remote locks. +- `narratio session locks add --reason ` writes a remote lock. +- `narratio session locks add --force --reason ` updates an existing remote lock reason. +- `narratio session locks remove ` 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 ` deletes `{workspace.root}/work/{campaign}/{session_id}` and `{spool.root}/{campaign}/{session_id}`. +- `narratio clean ` 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 +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 --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 ` uses normal config/session loading, including remote session fallback. -- `status --session-id ` includes the same promoted remote output availability view as `artifacts list --remote` when storage is configured. +- `session status ` uses normal config/session loading, including remote session fallback. +- `session status ` includes the same promoted remote output availability view as `session artifacts --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. diff --git a/docs/roadmap/cli.md b/docs/roadmap/cli.md new file mode 100644 index 0000000..245c41d --- /dev/null +++ b/docs/roadmap/cli.md @@ -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 [--remote|--output ] [--flags]` +- `narratio session validate [--flags]` +- `narratio session status [--flags]` +- `narratio session plan [--flags]` +- `narratio session restore [--flags]` +- `narratio session artifacts [--remote] [--flags]` +- `narratio session locks [--flags]` +- `narratio session locks add [--reason ] [--force] [--flags]` +- `narratio session locks remove [--flags]` + +Update top-level workflow commands to use positional session identifiers: + +- `narratio run [--flags]` +- `narratio resume [--flags]` +- `narratio analyze [--flags]` +- `narratio publish [--flags]` +- `narratio run-stage [--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 ` | `narratio run ` | +| `narratio resume --session-id ` | `narratio resume ` | +| `narratio analyze --session-id ` | `narratio analyze ` | +| `narratio publish --session-id ` | `narratio publish ` | +| `narratio run-stage [flags] --session-id ` | `narratio run-stage [flags]` | +| `narratio plan --session-id ` | `narratio session plan ` | +| `narratio status --session-id ` | `narratio session status ` | +| `narratio restore --session-id ` | `narratio session restore ` | +| `narratio artifacts list --session-id ` | `narratio session artifacts ` | +| `narratio locks --session-id ` | `narratio session locks ` | +| `narratio locks add --session-id ` | `narratio session locks add ` | +| `narratio locks remove --session-id ` | `narratio session locks remove ` | +| `narratio session validate --session-id ` | `narratio session validate ` | +| `narratio session init --session-id ` | `narratio session init ` | +| `narratio clean --session-id ` | `narratio clean ` | +| `narratio clean --all` | unchanged | + +`clean` remains top-level, but its session-scoped form should also move from +`--session-id` to positional `` 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 `. + - `Resume(ctx, args, out)` parses `resume `. + - `Analyze(ctx, args, out)` parses `analyze `. + - `Publish(ctx, args, out)` parses `publish `. + - `RunStage(ctx, args, out)` parses `run-stage `. + - `Clean(ctx, args, out)` parses `clean ` and keeps + `clean --all`. +- Extend `Session(ctx, args, out)` dispatch to support: + - `init ` + - `validate ` + - `status ` + - `plan ` + - `restore ` + - `artifacts ` + - `locks ` + - `locks add ` + - `locks remove ` +- 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 ` 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 + [--flags]`, except nested lock mutation forms, which use + `narratio session locks add|remove [--flags]`. +- `clean ` 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 ` loads local and remote sessions through the existing + config path. +- `resume `, `analyze `, and `publish ` + preserve current behavior. +- `run-stage ` preserves current run-stage output and + force/artifact-selection behavior. +- `session plan ` replaces top-level `plan`. +- `session status ` replaces top-level session status. +- `session validate ` replaces `session validate --session-id`. +- `session init ` writes the same local or remote concrete + `session.yml`. +- `session restore ` preserves restore planning/execution. +- `session artifacts --remote` preserves promoted-output + availability reporting. +- `session locks `, `session locks add `, and + `session locks remove ` preserve static/remote lock + semantics. +- `clean ` 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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ae02c8f..953b9e9 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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: diff --git a/internal/app/analyze_artifacts_commands_test.go b/internal/app/analyze_artifacts_commands_test.go index 91547c6..3aef53b 100644 --- a/internal/app/analyze_artifacts_commands_test.go +++ b/internal/app/analyze_artifacts_commands_test.go @@ -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") } diff --git a/internal/app/clean.go b/internal/app/clean.go index dd56a48..e97d0fc 100644 --- a/internal/app/clean.go +++ b/internal/app/clean.go @@ -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 { diff --git a/internal/app/clean_test.go b/internal/app/clean_test.go index dde1f1a..e32f389 100644 --- a/internal/app/clean_test.go +++ b/internal/app/clean_test.go @@ -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()) } } diff --git a/internal/app/commands.go b/internal/app/commands.go index 2fd52ca..83a6ac3 100644 --- a/internal/app/commands.go +++ b/internal/app/commands.go @@ -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: diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 572f30e..14712f3 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -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") } diff --git a/internal/app/config_loader.go b/internal/app/config_loader.go index 4a4f90f..a747860 100644 --- a/internal/app/config_loader.go +++ b/internal/app/config_loader.go @@ -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) diff --git a/internal/app/operator_helpers.go b/internal/app/operator_helpers.go index 79017bd..3ac5ef0 100644 --- a/internal/app/operator_helpers.go +++ b/internal/app/operator_helpers.go @@ -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 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 } diff --git a/internal/app/operator_helpers_test.go b/internal/app/operator_helpers_test.go index c83f3a0..30ebc7d 100644 --- a/internal/app/operator_helpers_test.go +++ b/internal/app/operator_helpers_test.go @@ -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()) } diff --git a/internal/app/plan.go b/internal/app/plan.go index 27619eb..b5b8e3d 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -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 { diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index dd68667..bf05690 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -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") } diff --git a/internal/app/remote_session_test.go b/internal/app/remote_session_test.go index 81323df..665a785 100644 --- a/internal/app/remote_session_test.go +++ b/internal/app/remote_session_test.go @@ -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") } diff --git a/internal/app/restore.go b/internal/app/restore.go index ca211d1..9ba54ec 100644 --- a/internal/app/restore.go +++ b/internal/app/restore.go @@ -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 ] [--campaign ] [--session ] [--session-id ] [--previous-session-id ] [--dry-run] [--force] [--include-audio]") + _, _ = fmt.Fprintln(out, "Usage: narratio session restore [--config ] [--campaign ] [--session ] [--previous-session-id ] [--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, diff --git a/internal/app/restore_execution_test.go b/internal/app/restore_execution_test.go index 1fbac70..a4d50f1 100644 --- a/internal/app/restore_execution_test.go +++ b/internal/app/restore_execution_test.go @@ -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") } diff --git a/internal/app/restore_test.go b/internal/app/restore_test.go index 969db67..78d5bb5 100644 --- a/internal/app/restore_test.go +++ b/internal/app/restore_test.go @@ -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 ") { 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()) } diff --git a/internal/app/restore_workflow_test.go b/internal/app/restore_workflow_test.go index 8a0afcb..e7a0a6c 100644 --- a/internal/app/restore_workflow_test.go +++ b/internal/app/restore_workflow_test.go @@ -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, diff --git a/internal/app/resume.go b/internal/app/resume.go index 2a5deb3..e373023 100644 --- a/internal/app/resume.go +++ b/internal/app/resume.go @@ -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, diff --git a/internal/app/resume_run_stage_test.go b/internal/app/resume_run_stage_test.go index 9e9f7dc..1c9594e 100644 --- a/internal/app/resume_run_stage_test.go +++ b/internal/app/resume_run_stage_test.go @@ -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) } diff --git a/internal/app/run.go b/internal/app/run.go index c06ae62..cdf7498 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -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, diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index 8793c44..670acdd 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -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 { diff --git a/internal/app/session_args.go b/internal/app/session_args.go new file mode 100644 index 0000000..c385c31 --- /dev/null +++ b/internal/app/session_args.go @@ -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) + } +} diff --git a/internal/app/session_cli_test.go b/internal/app/session_cli_test.go index def1049..47a55d3 100644 --- a/internal/app/session_cli_test.go +++ b/internal/app/session_cli_test.go @@ -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) } diff --git a/internal/app/session_oriented_cli_test.go b/internal/app/session_oriented_cli_test.go new file mode 100644 index 0000000..82e98b5 --- /dev/null +++ b/internal/app/session_oriented_cli_test.go @@ -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) +} diff --git a/internal/app/status_test.go b/internal/app/status_test.go index 8e7425a..971df21 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -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 -}