Compare commits
13 Commits
v0.12.0
...
71395bb076
| Author | SHA1 | Date | |
|---|---|---|---|
| 71395bb076 | |||
| 79737edf79 | |||
| df2c765b7f | |||
| f050b9dd54 | |||
| 9c9cb54339 | |||
| 7657ec3ad6 | |||
| cee52aa092 | |||
| e920f3a8d5 | |||
| 591c529a09 | |||
| 7324c5a686 | |||
| d0936fb022 | |||
| 2aa074c5cf | |||
| 782d0cf3b9 |
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
Narratio is a Go orchestration application that turns D&D session audio into polished transcripts and generated session artifacts.
|
Narratio is a Go orchestration application that turns D&D session audio into polished transcripts and generated session artifacts.
|
||||||
|
|
||||||
It coordinates transcription, merge/polish/normalize/trim processing, artifact generation, archive publishing, and resumable run state in one operator workflow.
|
It coordinates transcription, merge/polish/normalize/trim processing, artifact generation, publish-stage uploads, and resumable run state in one operator workflow.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio run --session-id 2026-04-04
|
narratio run 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
This command requires discoverable `pipeline.yml` and `session.yml` files (or explicit `--config` and `--session` flags).
|
This command requires discoverable `pipeline.yml` and `session.yml` files (or explicit `--config` and `--session` flags).
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ It coordinates specialized downstream systems rather than reimplementing their d
|
|||||||
- Audita handles transcript correction and polishing.
|
- Audita handles transcript correction and polishing.
|
||||||
- Scriptorium handles prompt execution and generated artifacts.
|
- Scriptorium handles prompt execution and generated artifacts.
|
||||||
|
|
||||||
Narratio owns orchestration, configuration loading, session/run state, local and remote path modeling, manifest persistence, stage sequencing, resume behavior, and archive semantics.
|
Narratio owns orchestration, configuration loading, session/run state, local and remote path modeling, manifest persistence, stage sequencing, resume behavior, and publish semantics.
|
||||||
|
|
||||||
Narratio should remain explicit and comprehensible. It is not intended to become a generic workflow engine.
|
Narratio should remain explicit and comprehensible. It is not intended to become a generic workflow engine.
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ It should record:
|
|||||||
- input and output refs;
|
- input and output refs;
|
||||||
- logs and generated config refs;
|
- logs and generated config refs;
|
||||||
- checksums or provenance where useful;
|
- checksums or provenance where useful;
|
||||||
- non-secret adapter and archive metadata.
|
- non-secret adapter and publish metadata.
|
||||||
|
|
||||||
Resume behavior should be manifest-driven. Filesystem state may be inspected and validated, but it should not replace manifest stage state as the source of run progress.
|
Resume behavior should be manifest-driven. Filesystem state may be inspected and validated, but it should not replace manifest stage state as the source of run progress.
|
||||||
|
|
||||||
@@ -117,19 +117,19 @@ Narratio should not become a secondary configuration system for downstream tools
|
|||||||
|
|
||||||
Local and remote paths are part of Narratio’s application contract.
|
Local and remote paths are part of Narratio’s application contract.
|
||||||
|
|
||||||
Code should use centralized path helpers for workspace, spool, session, run, artifact, log, config, and archive paths. Stages should avoid reconstructing canonical paths through scattered string concatenation.
|
Code should use centralized path helpers for workspace, spool, session, run, artifact, log, config, and publish/current paths. Stages should avoid reconstructing canonical paths through scattered string concatenation.
|
||||||
|
|
||||||
Storage backends should receive explicit bucket-relative keys. Storage implementations should not infer campaign, session, run, or root-prefix semantics.
|
Storage backends should receive explicit bucket-relative keys. Storage implementations should not infer campaign, session, run, or root-prefix semantics.
|
||||||
|
|
||||||
## Archive Invariants
|
## Publish Invariants
|
||||||
|
|
||||||
Archive behavior must preserve a clear commit boundary.
|
Publish behavior must preserve a clear commit boundary.
|
||||||
|
|
||||||
A remote run is current only after the archive stage has successfully uploaded the run record, required promoted outputs, `current/manifest.json`, and finally `current/run_id.txt`.
|
A remote run is current only after the publish stage has successfully uploaded the run record, required published outputs, `current/manifest.json`, and finally `current/run_id.txt`.
|
||||||
|
|
||||||
`current/run_id.txt` is the final remote commit marker and must be written last.
|
`current/run_id.txt` is the final remote commit marker and must be written last.
|
||||||
|
|
||||||
Failed, incomplete, skipped, or uncommitted archive attempts must not be presented as current remote state. Local cleanup is permitted only after successful archive commit and only when explicitly configured.
|
Failed, incomplete, skipped, or uncommitted publish attempts must not be presented as current remote state. Local cleanup is permitted only after successful publish commit and only when explicitly configured.
|
||||||
|
|
||||||
## Security and Privacy
|
## Security and Privacy
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ Rules:
|
|||||||
|
|
||||||
- Do not store raw secrets in pipeline or session YAML.
|
- Do not store raw secrets in pipeline or session YAML.
|
||||||
- Use environment variable names or secret-file references for secret handling.
|
- Use environment variable names or secret-file references for secret handling.
|
||||||
- Do not write raw secret values to manifests, logs, generated configs, or archive metadata.
|
- Do not write raw secret values to manifests, logs, generated configs, or publish metadata.
|
||||||
- Treat transcripts, generated artifacts, prompts, reports, and logs as potentially sensitive.
|
- Treat transcripts, generated artifacts, prompts, reports, and logs as potentially sensitive.
|
||||||
- Avoid logging transcript or prompt content unless there is a deliberate diagnostic reason.
|
- Avoid logging transcript or prompt content unless there is a deliberate diagnostic reason.
|
||||||
|
|
||||||
@@ -177,7 +177,7 @@ Tests should cover:
|
|||||||
- stage success, failure, skip, and resume behavior;
|
- stage success, failure, skip, and resume behavior;
|
||||||
- adapter command construction;
|
- adapter command construction;
|
||||||
- fake storage behavior;
|
- fake storage behavior;
|
||||||
- archive commit ordering;
|
- publish commit ordering;
|
||||||
- example config validity where practical.
|
- example config validity where practical.
|
||||||
|
|
||||||
Live S3, WhisperX, LLM, or subprocess integration tests should be explicit integration tests, not required for ordinary unit test runs.
|
Live S3, WhisperX, LLM, or subprocess integration tests should be explicit integration tests, not required for ordinary unit test runs.
|
||||||
|
|||||||
603
docs/cli.md
603
docs/cli.md
@@ -3,86 +3,68 @@
|
|||||||
## Shortest Useful Command
|
## Shortest Useful Command
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio run --session-id 2026-04-04
|
narratio run 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
This command uses default system discovery for `pipeline.yml`, `campaign.yml`, and local `session.yml`. If local session discovery misses and S3 storage is configured, `--session-id` can load remote `session.yml` from the canonical session prefix.
|
This runs the full pipeline for the given session ID using default config discovery and campaign selection.
|
||||||
|
|
||||||
Default discovery checks system config locations only. Pass `--config`, `--campaign`, and `--session` to use files from the current working directory.
|
|
||||||
|
|
||||||
## Command Overview
|
## Command Overview
|
||||||
|
Top-level commands:
|
||||||
|
|
||||||
Implemented commands:
|
- `run <session_id>`: execute the pipeline.
|
||||||
|
- `resume <session_id>`: continue from first non-succeeded stage.
|
||||||
|
- `run-stage <stage> <session_id>`: execute exactly one stage.
|
||||||
|
- `analyze <session_id>`: force-rerun analyze stage.
|
||||||
|
- `publish <session_id>`: force-rerun publish stage.
|
||||||
|
- `clean <session_id>|--all`: remove local workspace/spool state.
|
||||||
|
- `session <subcommand>`: session-scoped helper commands.
|
||||||
|
|
||||||
- `run`: execute pipeline stages and persist manifest state.
|
Session subcommands:
|
||||||
- `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.
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
Unknown commands print usage and exit non-zero.
|
- `session init <session_id>`
|
||||||
|
- `session plan <session_id>`
|
||||||
|
- `session validate <session_id>`
|
||||||
|
- `session status <session_id>`
|
||||||
|
- `session restore <session_id>`
|
||||||
|
- `session artifacts <session_id>`
|
||||||
|
- `session locks <session_id>`
|
||||||
|
- `session locks add <session_id> <source>`
|
||||||
|
- `session locks remove <session_id> <source>`
|
||||||
|
|
||||||
For config semantics, see [docs/config.md](./config.md). For operator lifecycle and recovery, see [docs/operations.md](./operations.md).
|
## Common Flags
|
||||||
|
Most session-aware commands accept:
|
||||||
|
|
||||||
## Complete Flag Reference
|
- `--config <pipeline.yml>`
|
||||||
|
- `--campaign <id>`
|
||||||
|
- `--campaign-file <campaign.yml>`
|
||||||
|
- `--session <session.yml>`
|
||||||
|
- `--previous-session-id <id>`
|
||||||
|
|
||||||
|
`--campaign` and `--campaign-file` are mutually exclusive.
|
||||||
|
|
||||||
|
## Command Reference
|
||||||
|
|
||||||
### `run`
|
### `run`
|
||||||
|
|
||||||
- `--config <path>`: optional explicit `pipeline.yml` path.
|
```bash
|
||||||
- `--campaign <path>`: optional explicit `campaign.yml` path.
|
narratio run <session_id> [--force] [--artifacts <name[,name...]>] [...common flags]
|
||||||
- `--session <path>`: optional explicit `session.yml` path.
|
```
|
||||||
- `--session-id <value>`: session template variable value.
|
|
||||||
- `--previous-session-id <value>`: previous-session template variable value.
|
|
||||||
- `--force`: force stage execution.
|
|
||||||
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
|
|
||||||
|
|
||||||
### `plan`
|
Runs stages in canonical order and writes manifest state.
|
||||||
|
|
||||||
- `--config <path>`
|
|
||||||
- `--campaign <path>`
|
|
||||||
- `--session <path>`
|
|
||||||
- `--session-id <value>`
|
|
||||||
- `--previous-session-id <value>`
|
|
||||||
- `--force`
|
|
||||||
|
|
||||||
### `resume`
|
### `resume`
|
||||||
|
|
||||||
- `--config <path>`
|
```bash
|
||||||
- `--campaign <path>`
|
narratio resume <session_id> [--force] [--artifacts <name[,name...]>] [...common flags]
|
||||||
- `--session <path>`
|
```
|
||||||
- `--session-id <value>`
|
|
||||||
- `--previous-session-id <value>`
|
Starts at the first non-succeeded stage from the session manifest.
|
||||||
- `--force`
|
|
||||||
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
|
|
||||||
|
|
||||||
### `run-stage`
|
### `run-stage`
|
||||||
|
|
||||||
- `--config <path>`
|
```bash
|
||||||
- `--campaign <path>`
|
narratio run-stage <stage> <session_id> [--force] [--artifacts <name[,name...]>] [...common flags]
|
||||||
- `--session <path>`
|
```
|
||||||
- `--session-id <value>`
|
|
||||||
- `--previous-session-id <value>`
|
|
||||||
- `--force`
|
|
||||||
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
|
|
||||||
- positional `<stage>`: required stage name.
|
|
||||||
|
|
||||||
### `analyze`
|
|
||||||
|
|
||||||
- `--config <path>`
|
|
||||||
- `--campaign <path>`
|
|
||||||
- `--session <path>`
|
|
||||||
- `--session-id <value>`
|
|
||||||
- `--previous-session-id <value>`
|
|
||||||
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
|
|
||||||
|
|
||||||
`analyze` is force-by-design and does not accept `--force`.
|
|
||||||
|
|
||||||
Valid stage names:
|
Valid stage names:
|
||||||
|
|
||||||
@@ -93,439 +75,156 @@ Valid stage names:
|
|||||||
- `normalize`
|
- `normalize`
|
||||||
- `trim`
|
- `trim`
|
||||||
- `analyze`
|
- `analyze`
|
||||||
- `archive`
|
- `publish`
|
||||||
- `notify`
|
- `notify`
|
||||||
|
|
||||||
### `restore`
|
`--artifacts` is accepted only for `analyze` and `publish`.
|
||||||
|
|
||||||
- `--config <path>`
|
|
||||||
- `--campaign <path>`
|
|
||||||
- `--session <path>`
|
|
||||||
- `--session-id <value>`
|
|
||||||
- `--previous-session-id <value>`
|
|
||||||
- `--dry-run`: plan restore actions without writing local files.
|
|
||||||
- `--force`: overwrite local conflicting files with remote archive files.
|
|
||||||
- `--include-audio`: include durable archived `audio/**` files in restore scope.
|
|
||||||
|
|
||||||
### `clean`
|
|
||||||
|
|
||||||
- `--session-id <value>`: required for session cleanup unless `--all` is set.
|
|
||||||
- `--config <path>`
|
|
||||||
- `--campaign <path>`
|
|
||||||
- `--session <path>`
|
|
||||||
- `--previous-session-id <value>`
|
|
||||||
- `--all`: clean all local session work/spool state using pipeline config only.
|
|
||||||
- `--dry-run`: print cleanup targets without deleting.
|
|
||||||
- `--clear-cache`: also remove matching S3 audio cache entries.
|
|
||||||
|
|
||||||
### `status`
|
|
||||||
|
|
||||||
- `--manifest <path>`: inspect one manifest file.
|
|
||||||
- `--config <path>`
|
|
||||||
- `--campaign <path>`
|
|
||||||
- `--session <path>`
|
|
||||||
- `--session-id <value>`
|
|
||||||
- `--previous-session-id <value>`
|
|
||||||
|
|
||||||
### `session validate`
|
|
||||||
|
|
||||||
- `--config <path>`
|
|
||||||
- `--campaign <path>`
|
|
||||||
- `--session <path>`
|
|
||||||
- `--session-id <value>`
|
|
||||||
- `--previous-session-id <value>`
|
|
||||||
|
|
||||||
### `session init`
|
|
||||||
|
|
||||||
- `--config <path>`: required.
|
|
||||||
- `--campaign <path>`: required.
|
|
||||||
- `--session-id <value>`: required.
|
|
||||||
- `--output <path>`: local `session.yml` target; mutually exclusive with `--remote`.
|
|
||||||
- `--remote`: write remote `session.yml` to the canonical session prefix; mutually exclusive with `--output`.
|
|
||||||
- `--previous-session-id <value>`
|
|
||||||
- `--date <value>`
|
|
||||||
- `--title <value>`
|
|
||||||
- `--audio-s3-prefix <prefix>`: defaults to `audio/` when neither audio flag is provided.
|
|
||||||
- `--audio-dir <path>`: local audio directory; mutually exclusive with `--audio-s3-prefix`.
|
|
||||||
- `--force`: overwrite existing local or remote target.
|
|
||||||
|
|
||||||
### `artifacts list`
|
|
||||||
|
|
||||||
- `--config <path>`
|
|
||||||
- `--campaign <path>`
|
|
||||||
- `--session <path>`
|
|
||||||
- `--session-id <value>`
|
|
||||||
- `--previous-session-id <value>`
|
|
||||||
- `--remote`: check remote availability for configured archive promotion destinations.
|
|
||||||
|
|
||||||
### `locks`
|
|
||||||
|
|
||||||
- `--session-id <value>`: required for list, add, and remove.
|
|
||||||
- `--config <path>`: optional explicit `pipeline.yml` path.
|
|
||||||
- `--campaign <path>`: optional explicit `campaign.yml` path.
|
|
||||||
- `--session <path>`: optional explicit `session.yml` path.
|
|
||||||
- `--previous-session-id <value>`: optional session template value.
|
|
||||||
- `add <source>`: add a remote lock for one artifact or transcript source.
|
|
||||||
- `add --reason <text>`: record an optional remote lock reason.
|
|
||||||
- `add --force`: update the reason for an existing remote lock.
|
|
||||||
- `remove <source>`: remove one remote lock.
|
|
||||||
|
|
||||||
## Command Reference
|
|
||||||
|
|
||||||
### `run`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Execute configured stages in canonical order.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio run [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
|
|
||||||
```
|
|
||||||
|
|
||||||
Success output:
|
|
||||||
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
|
|
||||||
|
|
||||||
Common failure cases:
|
|
||||||
- missing system default config/campaign/session paths when flags omitted.
|
|
||||||
- missing local session plus missing/unavailable remote `session.yml`.
|
|
||||||
- invalid template/rendered session mismatch.
|
|
||||||
- unknown/invalid `--artifacts` value.
|
|
||||||
- `--artifacts` with unknown configured artifact key.
|
|
||||||
|
|
||||||
### `plan`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Validate config, load secrets (if configured), prepare workdir, and print stage run/skip decisions.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio plan [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force]
|
|
||||||
```
|
|
||||||
|
|
||||||
Success output includes:
|
|
||||||
- `narratio plan: workdir prepared at <path>`
|
|
||||||
- one line per stage (`<stage>: run|skip`)
|
|
||||||
- `totals: run=<n> skip=<n>`
|
|
||||||
|
|
||||||
Common failure cases:
|
|
||||||
- same config/campaign/session discovery and validation failures as `run`.
|
|
||||||
- remote session fallback failures when local session discovery misses.
|
|
||||||
- secrets directory read failures when `pipeline.secrets.env_dir` is configured.
|
|
||||||
|
|
||||||
### `resume`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Continue from session-manifest stage status.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio resume [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
|
|
||||||
```
|
|
||||||
|
|
||||||
Success output:
|
|
||||||
- `narratio resume: session <session_id> has no remaining stages`
|
|
||||||
- or `narratio resume: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
|
|
||||||
|
|
||||||
Common failure cases:
|
|
||||||
- same discovery/template/validation failures as `run`.
|
|
||||||
- manifest load errors when existing manifest is unreadable.
|
|
||||||
- invalid or unknown artifact selections.
|
|
||||||
|
|
||||||
### `status`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Inspect one manifest file, or inspect configured local/remote state for a session.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio status --manifest <manifest.json>
|
|
||||||
narratio status [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>]
|
|
||||||
```
|
|
||||||
|
|
||||||
Manifest output includes:
|
|
||||||
- `session_id: <id>`
|
|
||||||
- `updated_at: <timestamp>`
|
|
||||||
- `stages:` entries (`- <stage>: <status>`)
|
|
||||||
|
|
||||||
Session output includes:
|
|
||||||
- session ID, campaign, workspace, session config source.
|
|
||||||
- local manifest state when present.
|
|
||||||
- remote current archive state when storage is configured.
|
|
||||||
- catalog-based remote output availability for expected transcript and artifact sources.
|
|
||||||
- effective archive locks and conservative next actions.
|
|
||||||
|
|
||||||
Common failure cases:
|
|
||||||
- missing `--manifest` when no config/session flags are provided.
|
|
||||||
- unreadable or invalid manifest path.
|
|
||||||
- invalid config or remote session fallback failure in session mode.
|
|
||||||
|
|
||||||
### `session validate`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Run read-only preflight checks for a session.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio session validate [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>]
|
|
||||||
```
|
|
||||||
|
|
||||||
Checks include:
|
|
||||||
- effective config and session source.
|
|
||||||
- stable input files.
|
|
||||||
- local or remote audio availability.
|
|
||||||
- previous-session requirements.
|
|
||||||
- archive promotions and effective locks.
|
|
||||||
|
|
||||||
Warnings do not fail the command. Any `ERROR` finding exits non-zero.
|
|
||||||
|
|
||||||
### `session init`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Create a strict-decoded session skeleton locally or in object storage.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio session init --config <pipeline.yml> --campaign <campaign.yml> --session-id <id> --output ./session.yml
|
|
||||||
narratio session init --config <pipeline.yml> --campaign <campaign.yml> --session-id <id> --remote
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- exactly one of `--output` or `--remote` is required.
|
|
||||||
- remote writes target `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`.
|
|
||||||
- existing local or remote targets fail unless `--force` is passed.
|
|
||||||
- remote writes use existence checks, not compare-and-swap.
|
|
||||||
|
|
||||||
### `artifacts list`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- List built-in, configured, previous-session, promoted, and locked artifact sources.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio artifacts list [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--remote]
|
|
||||||
```
|
|
||||||
|
|
||||||
`--remote` checks promoted top-level object availability through the storage adapter. Remote markers appear only in the `Promoted` section, which reports each configured archive promotion destination and includes `dest=<path>` when that destination differs from the source's canonical path.
|
|
||||||
|
|
||||||
### `locks`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Inspect and mutate source-based archive promotion locks for one session.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio locks --session-id <id>
|
|
||||||
narratio locks add --session-id <id> [--reason <text>] [--force] <source>
|
|
||||||
narratio locks remove --session-id <id> <source>
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- `--session-id` is required for list, add, and remove.
|
|
||||||
- optional `--config`, `--campaign`, and `--session` override default config discovery.
|
|
||||||
- list mode prints effective locks from static `pipeline.archive.locks` and remote `{session_prefix}/locks.yml`.
|
|
||||||
- `locks add` writes only the remote lock store and fails if the source is already locked by pipeline config.
|
|
||||||
- `locks remove` removes only remote locks and cannot remove static pipeline locks.
|
|
||||||
- `locks add --force` is required to update an existing remote lock reason.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio locks --session-id 2026-04-04
|
|
||||||
narratio locks add --session-id 2026-04-04 --reason "manual transcript review" narratio.transcript.trimmed
|
|
||||||
narratio locks remove --session-id 2026-04-04 narratio.transcript.trimmed
|
|
||||||
```
|
|
||||||
|
|
||||||
### `run-stage`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Execute exactly one stage.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio run-stage [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] <stage>
|
|
||||||
```
|
|
||||||
|
|
||||||
Success output:
|
|
||||||
- `narratio run-stage: stage=<name> executed=<n> skipped=<n> force=<true|false>; manifest=<path>`
|
|
||||||
|
|
||||||
`--artifacts` behavior:
|
|
||||||
- accepted only when `<stage>` is `analyze`.
|
|
||||||
- names are normalized (trimmed, deduplicated, sorted).
|
|
||||||
- unknown configured artifact keys fail.
|
|
||||||
|
|
||||||
Common failure cases:
|
|
||||||
- missing stage positional arg.
|
|
||||||
- unknown stage name.
|
|
||||||
- using `--artifacts` with any non-`analyze` stage.
|
|
||||||
|
|
||||||
### `analyze`
|
### `analyze`
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Force-rerun the analyze stage.
|
|
||||||
- Provide a shorter equivalent for `narratio run-stage --force analyze`.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio analyze [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--artifacts <name[,name...]>]
|
narratio analyze <session_id> [--artifacts <name[,name...]>] [...common flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
Success output:
|
Equivalent to `narratio run-stage analyze <session_id> --force`.
|
||||||
- `narratio analyze: executed=<n> skipped=<n> force=true; manifest=<path>`
|
|
||||||
|
|
||||||
Common failure cases:
|
### `publish`
|
||||||
- positional arguments.
|
|
||||||
- `--force`, because force is implicit.
|
|
||||||
- unknown configured artifact keys.
|
|
||||||
|
|
||||||
### `restore`
|
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Restore durable session state (`manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`, and optional `audio/**`) from the committed remote archive current state.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio restore [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--dry-run] [--force] [--include-audio]
|
narratio publish <session_id> [--artifacts <name[,name...]>] [...common flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
Success output (dry-run):
|
Equivalent to `narratio run-stage publish <session_id> --force`.
|
||||||
- `Restore plan for <campaign>/<session_id>`
|
|
||||||
- `Remote run: <run_id>`
|
|
||||||
- `Would download: <n>`
|
|
||||||
- `Would skip unchanged: <n>`
|
|
||||||
- `Conflicts: <n>`
|
|
||||||
|
|
||||||
Success output (non-dry-run):
|
|
||||||
- `Restored session archive for <campaign>/<session_id>`
|
|
||||||
- `Remote run: <run_id>`
|
|
||||||
- `Downloaded: <n>`
|
|
||||||
- `Skipped unchanged: <n>`
|
|
||||||
- `Conflicts: <n>`
|
|
||||||
|
|
||||||
Common failure cases:
|
|
||||||
- storage backend is not configured.
|
|
||||||
- remote `current/run_id.txt` missing/empty.
|
|
||||||
- remote `current/manifest.json` missing or invalid.
|
|
||||||
- remote manifest session/campaign mismatch.
|
|
||||||
- local conflicts without `--force`.
|
|
||||||
- session lock conflict.
|
|
||||||
|
|
||||||
When `--include-audio` is set, S3 audio files are restored through the shared audio cache. Cache hits avoid re-downloading large audio objects.
|
|
||||||
|
|
||||||
### `clean`
|
### `clean`
|
||||||
|
|
||||||
Purpose:
|
|
||||||
- Remove local Narratio work/spool state for testing, reruns, or recovery from corrupted local files.
|
|
||||||
- Preserve durable S3 audio cache state unless `--clear-cache` is passed.
|
|
||||||
|
|
||||||
Syntax:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio clean --session-id <id> [--config <pipeline.yml>] [--campaign <campaign.yml>] [--session <session.yml>] [--previous-session-id <id>] [--dry-run] [--clear-cache]
|
narratio clean <session_id> [--dry-run] [--clear-cache] [...common flags]
|
||||||
narratio clean --all [--config <pipeline.yml>] [--dry-run] [--clear-cache]
|
narratio clean --all [--dry-run] [--clear-cache] [--config <pipeline.yml>]
|
||||||
```
|
```
|
||||||
|
|
||||||
Session cleanup deletes:
|
- session mode deletes `{workspace.root}/work/{campaign}/{session_id}` and `{spool.root}/{campaign}/{session_id}`.
|
||||||
- `{workspace.root}/work/{campaign}/{session_id}`
|
- `--all` deletes all session work and spool children.
|
||||||
- `{spool.root}/{campaign}/{session_id}`
|
- cache is preserved unless `--clear-cache` is passed.
|
||||||
|
|
||||||
All-session cleanup deletes:
|
### `session plan`
|
||||||
- `{workspace.root}/work`
|
|
||||||
- the contents of `{spool.root}`, while preserving the spool root directory itself.
|
|
||||||
|
|
||||||
Cache behavior:
|
```bash
|
||||||
- cache is preserved by default.
|
narratio session plan <session_id> [--force] [...common flags]
|
||||||
- `--clear-cache` in session mode removes cached S3 audio files for the resolved session.
|
```
|
||||||
- `--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:
|
Validates config and session inputs, prepares workdir layout, and prints stage run/skip decisions.
|
||||||
- missing `--session-id` when `--all` is not set.
|
|
||||||
- combining `--all` with `--campaign`, `--session`, `--session-id`, or `--previous-session-id`.
|
### `session validate`
|
||||||
- unsafe cleanup target, such as a symlink, a non-directory session target, a configured root directory, or a path outside the configured root.
|
|
||||||
|
```bash
|
||||||
|
narratio session validate <session_id> [...common flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read-only preflight checks for config, inputs, audio availability, previous-session requirements, publish outputs, and effective locks.
|
||||||
|
|
||||||
|
### `session status`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session status <session_id> [...common flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows local manifest state, remote current state (when storage is configured), published-output availability, and effective locks.
|
||||||
|
|
||||||
|
### `session init`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session init <session_id> --output ./session.yml
|
||||||
|
narratio session init <session_id> --remote
|
||||||
|
narratio session init <session_id> --remote --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
|
||||||
|
- `--output <path>` or `--remote` (exactly one is required)
|
||||||
|
- `--previous-session-id <id>`
|
||||||
|
- `--date <date>`
|
||||||
|
- `--title <title>`
|
||||||
|
- `--audio-dir <path>`
|
||||||
|
- `--audio-s3-prefix <prefix>`
|
||||||
|
- `--force`
|
||||||
|
- common config/campaign flags
|
||||||
|
|
||||||
|
### `session restore`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session restore <session_id> [--dry-run] [--force] [--include-audio] [...common flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Restores durable local session files from committed remote current state.
|
||||||
|
|
||||||
|
Default restore scope:
|
||||||
|
|
||||||
|
- `manifest.json`
|
||||||
|
- `transcripts/**`
|
||||||
|
- `artifacts/**`
|
||||||
|
- `previous/**` when required by configured previous-session artifact inputs
|
||||||
|
|
||||||
|
`audio/**` is restored only when `--include-audio` is set.
|
||||||
|
|
||||||
|
### `session artifacts`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session artifacts <session_id> [--remote] [...common flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Lists built-in sources, configured artifact sources, previous-session sources, publish output rules, and lock status. With `--remote`, includes remote published-state markers.
|
||||||
|
|
||||||
|
### `session locks`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session locks <session_id> [...common flags]
|
||||||
|
narratio session locks add <session_id> <source> [--reason <text>] [--force] [...common flags]
|
||||||
|
narratio session locks remove <session_id> <source> [...common flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
- list mode prints effective locks from static `pipeline.publish.locks` and remote `{session_prefix}/locks.yml`.
|
||||||
|
- add/remove mutate only the remote lock store.
|
||||||
|
- static pipeline locks cannot be removed by lock commands.
|
||||||
|
|
||||||
|
## `--artifacts` Rules
|
||||||
|
|
||||||
|
- accepted on `run`, `resume`, `run-stage`, `analyze`, and `publish`.
|
||||||
|
- on `run-stage`, only valid for `analyze` and `publish`.
|
||||||
|
- filters configured analyze artifact execution.
|
||||||
|
- filters configured `pipeline.publish.outputs` entries for `narratio.artifact.<key>` sources.
|
||||||
|
- does not suppress built-in transcript/bounds publish outputs.
|
||||||
|
- does not imply `--force` for `run`, `resume`, or `run-stage`.
|
||||||
|
|
||||||
## Common Workflows
|
## Common Workflows
|
||||||
|
|
||||||
Default-discovery run:
|
Run full pipeline:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio run --session-id 2026-04-04
|
narratio run 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
Run only selected analyze artifacts:
|
Run only selected analyze artifacts:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio run --session-id 2026-04-04 --artifacts session_recap,player_handout
|
narratio run 2026-04-04 --artifacts session_recap,player_handout
|
||||||
```
|
```
|
||||||
|
|
||||||
Resume with selected analyze artifacts:
|
Force analyze only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio resume --session-id 2026-04-04 --artifacts player_handout
|
narratio analyze 2026-04-04 --artifacts player_handout
|
||||||
```
|
```
|
||||||
|
|
||||||
Force-rerun analyze with selected artifacts:
|
Force publish only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio analyze --session-id 2026-04-04 --artifacts player_handout
|
narratio publish 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
Preview restore actions without writes:
|
Restore preview then apply:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio restore --session-id 2026-04-04 --dry-run
|
narratio session restore 2026-04-04 --dry-run
|
||||||
|
narratio session restore 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
Restore and then force analyze:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio restore --session-id 2026-04-04
|
|
||||||
narratio analyze --session-id 2026-04-04
|
|
||||||
```
|
|
||||||
|
|
||||||
Rehydrate canonical previous-session inputs after artifact-input changes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio run-stage --session-id 2026-04-04 --force prepare
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Clean all local sessions while keeping cached S3 audio:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio clean --all
|
|
||||||
```
|
|
||||||
|
|
||||||
## Diagnostic / Recovery Commands
|
|
||||||
|
|
||||||
Inspect stage status:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio status --manifest <manifest.json>
|
|
||||||
```
|
|
||||||
|
|
||||||
Get manifest path from previous output:
|
|
||||||
- `run`, `resume`, `run-stage`, and `analyze` print `manifest=<path>` on success.
|
|
||||||
|
|
||||||
## `--artifacts` and `--force`
|
|
||||||
|
|
||||||
- `--artifacts` filters which configured artifacts are executable when analyze runs.
|
|
||||||
- `--artifacts` does not imply `--force`.
|
|
||||||
- if analyze is already `succeeded` and `--force` is not set, runner-level skip still applies.
|
|
||||||
|
|||||||
450
docs/config.md
450
docs/config.md
@@ -1,180 +1,94 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
## 1. Overview
|
## Overview
|
||||||
|
|
||||||
Narratio loads three YAML files:
|
Narratio loads three YAML files:
|
||||||
|
|
||||||
- `pipeline.yml`: pipeline-level runtime configuration.
|
- `pipeline.yml`: pipeline-level runtime settings.
|
||||||
- `campaign.yml`: stable campaign identity and campaign-level input defaults.
|
- `campaign.yml`: stable campaign identity and campaign-level input defaults.
|
||||||
- `session.yml`: per-session metadata and input selection, loaded locally or from the configured S3 backend.
|
- `session.yml`: per-session metadata and input selection.
|
||||||
|
|
||||||
These commands load and validate all three files before running:
|
Commands that load and validate all three files include:
|
||||||
|
|
||||||
- `narratio run`
|
- `narratio run`
|
||||||
- `narratio plan`
|
|
||||||
- `narratio resume`
|
- `narratio resume`
|
||||||
- `narratio run-stage`
|
- `narratio run-stage`
|
||||||
- `narratio restore`
|
- `narratio analyze`
|
||||||
|
- `narratio publish`
|
||||||
|
- `narratio session plan`
|
||||||
|
- `narratio session status`
|
||||||
|
- `narratio session validate`
|
||||||
|
- `narratio session restore`
|
||||||
|
- `narratio session artifacts`
|
||||||
|
- `narratio session locks`
|
||||||
|
- `narratio clean <session_id>`
|
||||||
|
|
||||||
Behavior:
|
Validation behavior:
|
||||||
|
|
||||||
- strict YAML decode is enabled (`KnownFields(true)`): unknown fields fail.
|
- strict YAML decode is enabled (`KnownFields(true)`); unknown fields fail.
|
||||||
- session templates render before session YAML decode.
|
- loaded `session.yml` files must be concrete YAML (no `{{ ... }}` placeholders).
|
||||||
- remote `session.yml` uses the same strict decode and template behavior as local `session.yml`.
|
|
||||||
- defaults are applied for optional pipeline fields.
|
- defaults are applied for optional pipeline fields.
|
||||||
- campaign-level stable input paths fill missing session input paths.
|
- campaign/session identity mismatches fail load.
|
||||||
- session-level stable input paths override campaign-level input paths.
|
|
||||||
- validation enforces required fields, value formats, and cross-field constraints.
|
|
||||||
|
|
||||||
## 2. Config file discovery
|
## File Discovery
|
||||||
|
Pipeline discovery order when `--config` is omitted:
|
||||||
|
|
||||||
These commands use the same config discovery behavior:
|
1. `/usr/local/etc/narratio/pipeline.yml`
|
||||||
|
2. `/etc/narratio/pipeline.yml`
|
||||||
|
|
||||||
- `narratio run`
|
Session discovery order when `--session` is omitted:
|
||||||
- `narratio plan`
|
|
||||||
- `narratio resume`
|
|
||||||
- `narratio run-stage`
|
|
||||||
- `narratio restore`
|
|
||||||
|
|
||||||
Pipeline config lookup:
|
1. `/usr/local/etc/narratio/session.yml`
|
||||||
|
2. `/etc/narratio/session.yml`
|
||||||
|
|
||||||
- if `--config <path>` is provided, that path is used.
|
Campaign discovery when `--campaign-file` is omitted:
|
||||||
- if omitted, Narratio searches in order:
|
|
||||||
1. `/usr/local/etc/narratio/pipeline.yml`
|
|
||||||
2. `/etc/narratio/pipeline.yml`
|
|
||||||
- first existing file wins.
|
|
||||||
|
|
||||||
Campaign config lookup:
|
- if `--campaign <id>` is set: `{pipeline.campaigns.root}/{id}/campaign.yml`
|
||||||
|
- otherwise: `{pipeline.campaigns.root}/{pipeline.campaigns.default_campaign_id}/campaign.yml`
|
||||||
|
|
||||||
- if `--campaign <path>` is provided, that path is used.
|
Remote `session.yml` fallback:
|
||||||
- if omitted, Narratio searches in order:
|
|
||||||
1. `/usr/local/etc/narratio/campaign.yml`
|
|
||||||
2. `/etc/narratio/campaign.yml`
|
|
||||||
- first existing file wins.
|
|
||||||
|
|
||||||
Session config lookup:
|
- if local session discovery fails and storage is configured, Narratio can load:
|
||||||
|
|
||||||
- if `--session <path>` is provided, that path is used.
|
|
||||||
- if `--session` is omitted, Narratio searches locally in order:
|
|
||||||
1. `/usr/local/etc/narratio/session.yml`
|
|
||||||
2. `/etc/narratio/session.yml`
|
|
||||||
- first existing local file wins.
|
|
||||||
- if no local session file is found, `--session-id <value>` is present, storage is configured, and campaign identity is resolved, Narratio loads remote `session.yml` from:
|
|
||||||
- `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`
|
- `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`
|
||||||
- local discovery always runs before remote fallback.
|
|
||||||
- local files in the current working directory are used only when passed explicitly, for example `--config ./pipeline.yml --campaign ./campaign.yml --session ./session.yml`.
|
|
||||||
|
|
||||||
## 3. Session templating
|
## Minimal Working Config
|
||||||
|
`pipeline.yml`
|
||||||
Template behavior for local and remote `session.yml`:
|
|
||||||
|
|
||||||
- supported placeholders:
|
|
||||||
- `{{session_id}}`
|
|
||||||
- `{{ session_id }}`
|
|
||||||
- `{{previous_session_id}}`
|
|
||||||
- `{{ previous_session_id }}`
|
|
||||||
- `--session-id <value>` supplies the placeholder value.
|
|
||||||
- `--previous-session-id <value>` supplies the previous-session placeholder value.
|
|
||||||
- unresolved placeholders fail load.
|
|
||||||
- if rendered `session_id` mismatches `--session-id`, load fails.
|
|
||||||
- if rendered `previous_session_id` mismatches `--previous-session-id`, load fails.
|
|
||||||
|
|
||||||
## 4. Minimal config set
|
|
||||||
|
|
||||||
### `pipeline.yml`
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
campaigns:
|
||||||
|
root: /usr/local/share/narratio/campaigns
|
||||||
|
default_campaign_id: sample-campaign
|
||||||
whisperx:
|
whisperx:
|
||||||
transcribe_url: "https://transcription.example.com/transcribe"
|
transcribe_url: "https://transcription.example.com/transcribe"
|
||||||
```
|
```
|
||||||
|
|
||||||
Why this is sufficient:
|
`campaign.yml`
|
||||||
|
|
||||||
- `whisperx.transcribe_url` is required.
|
|
||||||
- `workspace.root` defaults to `/var/lib/narratio`.
|
|
||||||
- optional sections (`seriatim`, `audita`, `archive`, `scriptorium`, `trim`, `normalize`, etc.) receive defaults or stay inactive.
|
|
||||||
|
|
||||||
### `campaign.yml`
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
campaign: sample-campaign
|
campaign_id: sample-campaign
|
||||||
inputs:
|
inputs:
|
||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Why this is sufficient:
|
`session.yml`
|
||||||
|
|
||||||
- `campaign` supplies the stable campaign identity.
|
|
||||||
- stable input files are required and resolve relative to `campaign.yml` when copied during `prepare`.
|
|
||||||
|
|
||||||
### `session.yml`
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
session_id: "{{ session_id }}"
|
session_id: 2026-05-03
|
||||||
inputs:
|
inputs:
|
||||||
audio_dir: ./audio
|
audio_dir: ./audio
|
||||||
```
|
```
|
||||||
|
|
||||||
Why this is sufficient:
|
## Publish Config
|
||||||
|
Top-level publish settings live at `pipeline.publish`.
|
||||||
- `session_id` is required and can be rendered from `--session-id`.
|
|
||||||
- `campaign` can be omitted because it is supplied by `campaign.yml`.
|
|
||||||
- stable input paths can be omitted because `campaign.yml` supplies defaults.
|
|
||||||
- local `audio_dir` resolves relative to `session.yml`.
|
|
||||||
|
|
||||||
Minimal local-file usage:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio run --config /path/to/pipeline.yml --campaign ./campaign.yml --session ./session.yml --session-id 2026-05-03
|
|
||||||
```
|
|
||||||
|
|
||||||
Previous-session-enabled variant:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
session_id: "{{ session_id }}"
|
publish:
|
||||||
previous_session_id: "{{ previous_session_id }}"
|
|
||||||
inputs:
|
|
||||||
audio_dir: ./audio
|
|
||||||
```
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Production-oriented config set
|
|
||||||
|
|
||||||
### `pipeline.yml`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
workspace:
|
|
||||||
root: /var/lib/narratio/workspace
|
|
||||||
cleanup_after_archive: true
|
|
||||||
|
|
||||||
storage:
|
|
||||||
backend: s3
|
|
||||||
s3:
|
|
||||||
bucket: my-dnd-archive
|
|
||||||
root_prefix: dnd
|
|
||||||
region: us-east-1
|
|
||||||
access_key_id_env: OBJECT_STORAGE_KEY_ID
|
|
||||||
secret_access_key_env: OBJECT_STORAGE_KEY
|
|
||||||
|
|
||||||
spool:
|
|
||||||
root: /var/spool/narratio
|
|
||||||
delete_audio_after_archive: true
|
|
||||||
|
|
||||||
cache:
|
|
||||||
root: /var/cache/narratio
|
|
||||||
s3_audio: true
|
|
||||||
|
|
||||||
archive:
|
|
||||||
enabled: true
|
enabled: true
|
||||||
upload_run: true
|
upload_run: true
|
||||||
promote_artifacts:
|
outputs:
|
||||||
- source: narratio.transcript.trimmed
|
- source: narratio.transcript.final_trimmed
|
||||||
dest: transcripts/trimmed.json
|
dest: transcripts/final.trimmed.json
|
||||||
required: true
|
required: true
|
||||||
- source: narratio.artifact.session_recap
|
- source: narratio.artifact.session_recap
|
||||||
dest: artifacts/session_recap.md
|
dest: artifacts/session_recap.md
|
||||||
@@ -182,81 +96,32 @@ archive:
|
|||||||
locks:
|
locks:
|
||||||
- source: narratio.artifact.session_recap
|
- source: narratio.artifact.session_recap
|
||||||
reason: Final recap was manually edited.
|
reason: Final recap was manually edited.
|
||||||
|
|
||||||
whisperx:
|
|
||||||
transcribe_url: "https://transcription.example.com/transcribe"
|
|
||||||
|
|
||||||
scriptorium:
|
|
||||||
artifacts:
|
|
||||||
session_recap:
|
|
||||||
enabled: true
|
|
||||||
prompt_id: dnd.session_recap
|
|
||||||
output_path: artifacts/session_recap.md
|
|
||||||
inputs:
|
|
||||||
transcript:
|
|
||||||
source: narratio.transcript.trimmed
|
|
||||||
required: true
|
|
||||||
previous_recap:
|
|
||||||
source: narratio.previous_session.artifact.session_recap
|
|
||||||
required: false
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### `campaign.yml`
|
Rules:
|
||||||
|
|
||||||
```yaml
|
- `outputs[].source` is required.
|
||||||
campaign: forsaken
|
- `outputs[].dest` is optional; when omitted, Narratio derives destination from the source.
|
||||||
inputs:
|
- `outputs[].required` defaults to `true`.
|
||||||
speakers_file: /srv/narratio/campaigns/forsaken/speakers.yml
|
- static `publish.locks` and remote `{session_prefix}/locks.yml` are merged; static locks win on duplicates.
|
||||||
autocorrect_file: /srv/narratio/campaigns/forsaken/autocorrect.yml
|
- locks prevent overwrite of top-level published destinations.
|
||||||
glossary_file: /srv/narratio/campaigns/forsaken/glossary.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
### Local `session.yml`
|
Supported publish source families:
|
||||||
|
|
||||||
```yaml
|
- built-ins: `narratio.transcript.base`, `narratio.transcript.polished`, `narratio.transcript.final`, `narratio.transcript.final_trimmed`, `narratio.bounds.session`
|
||||||
session_id: "{{ session_id }}"
|
- configured artifacts: `narratio.artifact.<artifact_key>`
|
||||||
previous_session_id: "{{ previous_session_id }}"
|
|
||||||
date: 2026-05-03
|
|
||||||
title: The Black Cabin
|
|
||||||
inputs:
|
|
||||||
audio_s3:
|
|
||||||
prefix: audio/
|
|
||||||
```
|
|
||||||
|
|
||||||
### S3-first session config
|
## Full Reference
|
||||||
|
|
||||||
For S3-first operation, upload the same `session.yml` content to:
|
|
||||||
|
|
||||||
```text
|
|
||||||
{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Operational notes:
|
|
||||||
|
|
||||||
- archive promotion is explicit and source-based via `archive.promote_artifacts`.
|
|
||||||
- `source` is required; `dest` is optional and derived when omitted.
|
|
||||||
- `archive.locks` skips top-level promotion overwrites for static locked sources while preserving run-local uploads.
|
|
||||||
- operator-created mutable locks are stored at `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/locks.yml` and are merged with static locks.
|
|
||||||
- Narratio does not auto-promote all generated analyze artifacts.
|
|
||||||
- `restore` reads the same config/campaign/session inputs and restore scope is bounded by committed archive current state.
|
|
||||||
- `clean` removes workspace/spool state by default and preserves `pipeline.cache.root` unless `--clear-cache` is passed.
|
|
||||||
|
|
||||||
## 6. Full pipeline reference
|
|
||||||
|
|
||||||
|
### Pipeline
|
||||||
| Path | Type | Required | Default |
|
| Path | Type | Required | Default |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `pipeline.workspace.root` | string | No | `/var/lib/narratio` |
|
| `pipeline.workspace.root` | string | No | `/var/lib/narratio` |
|
||||||
| `pipeline.workspace.cleanup_after_archive` | bool | No | `false` |
|
| `pipeline.workspace.cleanup_after_publish` | bool | No | `false` |
|
||||||
| `pipeline.secrets.env_dir` | string | Conditional | none |
|
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
|
||||||
|
| `pipeline.campaigns.default_campaign_id` | string | No | empty |
|
||||||
|
| `pipeline.secrets.env_dir` | string | No | empty |
|
||||||
| `pipeline.storage.backend` | string | No | empty |
|
| `pipeline.storage.backend` | string | No | empty |
|
||||||
| `pipeline.storage.bucket` | string | No | empty |
|
|
||||||
| `pipeline.storage.prefix` | string | No | empty |
|
|
||||||
| `pipeline.storage.s3.bucket` | string | Conditional | empty |
|
| `pipeline.storage.s3.bucket` | string | Conditional | empty |
|
||||||
| `pipeline.storage.s3.root_prefix` | string | No | `dnd` |
|
| `pipeline.storage.s3.root_prefix` | string | No | `dnd` |
|
||||||
| `pipeline.storage.s3.region` | string | No | empty |
|
| `pipeline.storage.s3.region` | string | No | empty |
|
||||||
@@ -265,18 +130,18 @@ Operational notes:
|
|||||||
| `pipeline.storage.s3.access_key_id_env` | string | No | `OBJECT_STORAGE_KEY_ID` |
|
| `pipeline.storage.s3.access_key_id_env` | string | No | `OBJECT_STORAGE_KEY_ID` |
|
||||||
| `pipeline.storage.s3.secret_access_key_env` | string | No | `OBJECT_STORAGE_KEY` |
|
| `pipeline.storage.s3.secret_access_key_env` | string | No | `OBJECT_STORAGE_KEY` |
|
||||||
| `pipeline.spool.root` | string | No | `/var/spool/narratio` |
|
| `pipeline.spool.root` | string | No | `/var/spool/narratio` |
|
||||||
| `pipeline.spool.delete_audio_after_archive` | bool | No | `false` |
|
| `pipeline.spool.delete_audio_after_publish` | bool | No | `false` |
|
||||||
| `pipeline.cache.root` | string | No | `/var/cache/narratio` |
|
| `pipeline.cache.root` | string | No | `/var/cache/narratio` |
|
||||||
| `pipeline.cache.s3_audio` | bool | No | `true` |
|
| `pipeline.cache.s3_audio` | bool | No | `true` |
|
||||||
| `pipeline.archive.enabled` | bool | No | `true` |
|
| `pipeline.publish.enabled` | bool | No | `true` |
|
||||||
| `pipeline.archive.upload_run` | bool | No | `true` |
|
| `pipeline.publish.upload_run` | bool | No | `true` |
|
||||||
| `pipeline.archive.promote_artifacts[]` | list | No | trimmed transcript rule |
|
| `pipeline.publish.outputs[]` | list | No | one final-trimmed output rule |
|
||||||
| `pipeline.archive.promote_artifacts[].source` | string | Yes (per rule) | none |
|
| `pipeline.publish.outputs[].source` | string | Yes (per rule) | none |
|
||||||
| `pipeline.archive.promote_artifacts[].dest` | string | No | derived from source |
|
| `pipeline.publish.outputs[].dest` | string | No | derived from source |
|
||||||
| `pipeline.archive.promote_artifacts[].required` | bool | No | `true` |
|
| `pipeline.publish.outputs[].required` | bool | No | `true` |
|
||||||
| `pipeline.archive.locks[]` | list | No | empty |
|
| `pipeline.publish.locks[]` | list | No | empty |
|
||||||
| `pipeline.archive.locks[].source` | string | Yes (per lock) | none |
|
| `pipeline.publish.locks[].source` | string | Yes (per lock) | none |
|
||||||
| `pipeline.archive.locks[].reason` | string | No | empty |
|
| `pipeline.publish.locks[].reason` | string | No | empty |
|
||||||
| `pipeline.whisperx.transcribe_url` | string | Yes | none |
|
| `pipeline.whisperx.transcribe_url` | string | Yes | none |
|
||||||
| `pipeline.whisperx.language` | string | No | `en` |
|
| `pipeline.whisperx.language` | string | No | `en` |
|
||||||
| `pipeline.whisperx.timeout` | duration string | No | `30m` |
|
| `pipeline.whisperx.timeout` | duration string | No | `30m` |
|
||||||
@@ -307,7 +172,7 @@ Operational notes:
|
|||||||
| `pipeline.audita.output_schema` | string | No | empty |
|
| `pipeline.audita.output_schema` | string | No | empty |
|
||||||
| `pipeline.audita.work_dir_retention` | string | No | empty |
|
| `pipeline.audita.work_dir_retention` | string | No | empty |
|
||||||
| `pipeline.audita.report` | bool | No | `true` |
|
| `pipeline.audita.report` | bool | No | `true` |
|
||||||
| `pipeline.normalize.output_path` | string | No | `transcripts/normalized.json` |
|
| `pipeline.normalize.output_path` | string | No | `transcripts/final.json` |
|
||||||
| `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` |
|
| `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` |
|
||||||
| `pipeline.normalize.report` | bool | No | `true` |
|
| `pipeline.normalize.report` | bool | No | `true` |
|
||||||
| `pipeline.trim.enabled` | bool | No | `false` |
|
| `pipeline.trim.enabled` | bool | No | `false` |
|
||||||
@@ -325,163 +190,38 @@ Operational notes:
|
|||||||
| `pipeline.scriptorium.timeout` | duration string | No | `10m` |
|
| `pipeline.scriptorium.timeout` | duration string | No | `10m` |
|
||||||
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
|
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
|
||||||
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
||||||
| `pipeline.scriptorium.artifacts.<name>.enabled` | bool | No | `false` |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.depends_on[]` | list[string] | No | empty |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.render_debug` | bool | No | unset |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.prompt_id` | string | Conditional | none |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.profile_id` | string | No | empty |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.output_path` | string | Conditional | none |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.timeout` | duration string | No | empty |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` | string | Conditional | none |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.inputs.<key>.artifact` | string | No | empty |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.inputs.<key>.path` | string | No | empty |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required` | bool | No | `false` |
|
|
||||||
| `pipeline.scriptorium.artifacts.<name>.vars.<key>` | map value | No | empty |
|
|
||||||
| `pipeline.analyzer.binary_path` | string | No | empty |
|
|
||||||
| `pipeline.analyzer.timeout` | duration string | No | empty |
|
|
||||||
| `pipeline.analyzer.artifacts.output_dir` | string | No | empty |
|
|
||||||
| `pipeline.analyzer.artifacts.types[]` | list[string] | No | empty |
|
|
||||||
| `pipeline.notification.backend` | string | No | empty |
|
| `pipeline.notification.backend` | string | No | empty |
|
||||||
| `pipeline.notification.recipient` | string | No | empty |
|
| `pipeline.notification.recipient` | string | No | empty |
|
||||||
| `pipeline.notification.timeout` | duration string | No | empty |
|
| `pipeline.notification.timeout` | duration string | No | `30s` |
|
||||||
|
|
||||||
Scriptorium artifact-key and dependency rules:
|
### Campaign
|
||||||
|
| Path | Type | Required |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `campaign_id` | string | Yes |
|
||||||
|
| `session_template_file` | string | No |
|
||||||
|
| `inputs.speakers_file` | string | Yes |
|
||||||
|
| `inputs.autocorrect_file` | string | Yes |
|
||||||
|
| `inputs.glossary_file` | string | Yes |
|
||||||
|
|
||||||
- artifact keys must match `^[a-z][a-z0-9_]*$`.
|
### Session
|
||||||
- enabled artifacts require `prompt_id` and `output_path`.
|
| Path | Type | Required |
|
||||||
- `output_path` must be relative, traversal-safe, and under `artifacts/`.
|
| --- | --- | --- |
|
||||||
- configured artifact input sources use `narratio.artifact.<name>`.
|
| `session_id` | string | Yes |
|
||||||
- if input source references `narratio.artifact.<name>`, artifact `<name>` must exist and must be listed in `depends_on`.
|
| `previous_session_id` | string | No |
|
||||||
- every `depends_on` entry must be a configured artifact key.
|
| `campaign` | string | No |
|
||||||
- self-dependency is rejected.
|
| `date` | string | No |
|
||||||
- enabled dependency cycles are rejected.
|
| `title` | string | No |
|
||||||
- any artifact referenced by `depends_on` or `narratio.artifact.<name>` source must define `output_path` (even if not enabled).
|
| `inputs.speakers_file` | string | No |
|
||||||
|
| `inputs.autocorrect_file` | string | No |
|
||||||
Allowed `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` values:
|
| `inputs.glossary_file` | string | No |
|
||||||
|
| `inputs.audio_dir` | string | Conditional |
|
||||||
- `narratio.previous_session.artifact.<configured_artifact_key>`
|
| `inputs.audio_files[]` | list[string] | Conditional |
|
||||||
- `narratio.transcript.merged`
|
| `inputs.audio_s3.prefix` | string | Conditional |
|
||||||
- `narratio.transcript.polished`
|
|
||||||
- `narratio.transcript.full`
|
|
||||||
- `narratio.transcript.trimmed`
|
|
||||||
- `narratio.bounds.session`
|
|
||||||
- `narratio.artifact.<configured_artifact_key>`
|
|
||||||
- `previous_session_artifact` (legacy path-based source; uses `inputs.<key>.path`)
|
|
||||||
|
|
||||||
`pipeline.archive.promote_artifacts[].source` values:
|
|
||||||
|
|
||||||
- `narratio.transcript.merged`
|
|
||||||
- `narratio.transcript.polished`
|
|
||||||
- `narratio.transcript.full`
|
|
||||||
- `narratio.transcript.trimmed`
|
|
||||||
- `narratio.bounds.session`
|
|
||||||
- `narratio.artifact.<configured_artifact_key>`
|
|
||||||
|
|
||||||
`pipeline.archive.locks[].source` accepts the same source values as `pipeline.archive.promote_artifacts[].source`.
|
|
||||||
|
|
||||||
Archive promotion destination rules:
|
|
||||||
|
|
||||||
- `dest` must be a clean relative path (not absolute, no traversal).
|
|
||||||
- duplicate `dest` values are rejected.
|
|
||||||
- if `dest` is omitted:
|
|
||||||
- built-in sources derive their canonical destination path;
|
|
||||||
- configured sources derive from `pipeline.scriptorium.artifacts.<name>.output_path`;
|
|
||||||
- derivation failure is a config validation error.
|
|
||||||
|
|
||||||
Archive lock rules:
|
|
||||||
|
|
||||||
- locks are source-based and do not accept `dest`.
|
|
||||||
- duplicate lock sources are rejected.
|
|
||||||
- static `pipeline.archive.locks` win over remote mutable locks for the same source.
|
|
||||||
- locked promotions are recorded as intentional skips in archive metadata.
|
|
||||||
- locked required promotions do not fail archive by default.
|
|
||||||
- ordinary `--force` reruns do not override locks.
|
|
||||||
|
|
||||||
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.
|
|
||||||
- writes use existence checks plus `--force` for updates; they are not compare-and-swap atomic.
|
|
||||||
|
|
||||||
Restore-related implications:
|
|
||||||
|
|
||||||
- restore remote identity requires archive S3 identity to resolve (`pipeline.storage.s3.bucket` and session prefix derivation inputs).
|
|
||||||
- restore scope considers committed current state and durable paths (`manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`, optional `audio/**`).
|
|
||||||
- S3 audio downloads use `pipeline.spool.root` for active downloads and `pipeline.cache.root` for reusable cached audio when `pipeline.cache.s3_audio` is true.
|
|
||||||
- `pipeline.cache.root` is durable local cache state. It is not workspace state and is preserved by default by `narratio clean`.
|
|
||||||
|
|
||||||
## 7. Full campaign reference
|
|
||||||
|
|
||||||
| Path | Type | Required | Default |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `campaign.campaign` | string | Yes | none |
|
|
||||||
| `campaign.inputs.speakers_file` | string | Yes | none |
|
|
||||||
| `campaign.inputs.autocorrect_file` | string | Yes | none |
|
|
||||||
| `campaign.inputs.glossary_file` | string | Yes | none |
|
|
||||||
|
|
||||||
Campaign input paths may be absolute or relative. Relative paths resolve from the directory containing `campaign.yml`.
|
|
||||||
|
|
||||||
## 8. Full session reference
|
|
||||||
|
|
||||||
| Path | Type | Required | Default |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `session.session_id` | string | Yes | none |
|
|
||||||
| `session.previous_session_id` | string | No | empty |
|
|
||||||
| `session.campaign` | string | No | `campaign.campaign` |
|
|
||||||
| `session.date` | string | No | empty |
|
|
||||||
| `session.title` | string | No | empty |
|
|
||||||
| `session.inputs.audio_dir` | string | Conditional | empty |
|
|
||||||
| `session.inputs.audio_files[]` | list[string] | Conditional | empty |
|
|
||||||
| `session.inputs.audio_s3.prefix` | string | Conditional | none |
|
|
||||||
| `session.inputs.speakers_file` | string | No | `campaign.inputs.speakers_file` |
|
|
||||||
| `session.inputs.autocorrect_file` | string | No | `campaign.inputs.autocorrect_file` |
|
|
||||||
| `session.inputs.glossary_file` | string | No | `campaign.inputs.glossary_file` |
|
|
||||||
|
|
||||||
Session input paths may be absolute or relative. Relative audio paths and session-level stable input overrides resolve from the directory containing `session.yml`. If both `campaign.yml` and `session.yml` specify campaign identity, the values must match.
|
|
||||||
|
|
||||||
Audio-source rule:
|
|
||||||
|
|
||||||
- configure exactly one mode:
|
|
||||||
- `audio_dir`, or
|
|
||||||
- `audio_files` (at least one), or
|
|
||||||
- `audio_s3.prefix`
|
|
||||||
- `audio_s3` cannot be combined with local audio fields.
|
|
||||||
|
|
||||||
Previous-session rule:
|
|
||||||
|
|
||||||
- if `session.previous_session_id` is set, it must not equal `session.session_id`.
|
|
||||||
- canonical previous-session sources (`narratio.previous_session.artifact.<name>`) are hydrated during `prepare` from archive current state when required by enabled configured artifacts.
|
|
||||||
|
|
||||||
## 9. Secrets
|
|
||||||
|
|
||||||
Narratio supports filesystem-based secret injection via `pipeline.secrets.env_dir`.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- `env_dir` may be absolute or relative.
|
|
||||||
- relative `env_dir` resolves from current working directory.
|
|
||||||
- files with valid env-var names (`[A-Za-z_][A-Za-z0-9_]*`) are loaded.
|
|
||||||
- values are loaded from file contents with trailing newline trimming.
|
|
||||||
- existing process env vars are preserved.
|
|
||||||
- invalid names and subdirectories are skipped.
|
|
||||||
- missing/unreadable `env_dir` fails command execution.
|
|
||||||
|
|
||||||
Guidance:
|
|
||||||
|
|
||||||
- do not put secret values directly in YAML.
|
|
||||||
- configure env var names in config and provide values via env/secrets files.
|
|
||||||
|
|
||||||
## 10. Examples
|
|
||||||
|
|
||||||
Maintained examples:
|
|
||||||
|
|
||||||
|
## Maintained Examples
|
||||||
- `examples/pipeline.minimal.yml`
|
- `examples/pipeline.minimal.yml`
|
||||||
- `examples/pipeline.production.yml`
|
- `examples/pipeline.production.yml`
|
||||||
- `examples/pipeline.full.annotated.yml`
|
- `examples/pipeline.full.annotated.yml`
|
||||||
- `examples/campaign.yml`
|
- `examples/campaigns/sample-campaign/campaign.yml`
|
||||||
- `examples/session.template.yml`
|
|
||||||
- `examples/session.local-audio.yml`
|
- `examples/session.local-audio.yml`
|
||||||
- `examples/session.s3-audio.yml`
|
- `examples/session.s3-audio.yml`
|
||||||
|
|
||||||
These examples are validated by `internal/config` tests.
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ Remote-storage commands must obtain object storage through the app-level command
|
|||||||
|
|
||||||
1. Implement stage behavior in `internal/stage` with clear input/output boundaries.
|
1. Implement stage behavior in `internal/stage` with clear input/output boundaries.
|
||||||
2. Keep external transport/subprocess details in `internal/adapters`.
|
2. Keep external transport/subprocess details in `internal/adapters`.
|
||||||
3. Preserve manifest and promotion semantics expected by runner and archive logic.
|
3. Preserve manifest and publish-output semantics expected by runner and publish logic.
|
||||||
4. Add/update stage and adapter tests.
|
4. Add/update stage and adapter tests.
|
||||||
5. Update internal component contracts in `docs/internal/`.
|
5. Update internal component contracts in `docs/internal/`.
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ Define Narratio's adapter contract for transcript polishing via Audita CLI subpr
|
|||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
Inputs (`audita.PolishRequest`):
|
Inputs (`audita.PolishRequest`):
|
||||||
- merged transcript path
|
- base transcript path
|
||||||
- glossary path
|
- glossary path
|
||||||
- output processed transcript path
|
- output polished transcript path
|
||||||
- optional report path (required when report enabled)
|
- optional report path (required when report enabled)
|
||||||
- work dir
|
- work dir
|
||||||
- generated config path
|
- generated config path
|
||||||
@@ -15,7 +15,7 @@ Inputs (`audita.PolishRequest`):
|
|||||||
- optional module/model/base URL and concurrency knobs
|
- optional module/model/base URL and concurrency knobs
|
||||||
|
|
||||||
Outputs (`audita.PolishResult`):
|
Outputs (`audita.PolishResult`):
|
||||||
- processed transcript path
|
- polished transcript path
|
||||||
- optional report path
|
- optional report path
|
||||||
- generated config path
|
- generated config path
|
||||||
- stdout/stderr log paths
|
- stdout/stderr log paths
|
||||||
@@ -27,7 +27,7 @@ Owns:
|
|||||||
- Deterministic CLI argument construction for `audita process`
|
- Deterministic CLI argument construction for `audita process`
|
||||||
- Environment bridging for API credentials
|
- Environment bridging for API credentials
|
||||||
- Invocation config emission
|
- Invocation config emission
|
||||||
- Output validation for processed transcript and report
|
- Output validation for polished transcript and report
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
- Upstream/downstream stage orchestration
|
- Upstream/downstream stage orchestration
|
||||||
@@ -52,7 +52,7 @@ Via `pipeline.audita.*` mapped in app/stage wiring:
|
|||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
- Constructor validation fails on invalid binary/timeout/schema/concurrency/URL values.
|
- Constructor validation fails on invalid binary/timeout/schema/concurrency/URL values.
|
||||||
- Run fails on missing required paths, missing required credential env var, subprocess errors, invalid processed JSON shape, or invalid report JSON.
|
- Run fails on missing required paths, missing required credential env var, subprocess errors, invalid polished JSON shape, or invalid report JSON.
|
||||||
- Failures preserve stdout/stderr paths in returned result metadata.
|
- Failures preserve stdout/stderr paths in returned result metadata.
|
||||||
|
|
||||||
## Tests to Inspect Before Changing
|
## Tests to Inspect Before Changing
|
||||||
@@ -61,6 +61,6 @@ Via `pipeline.audita.*` mapped in app/stage wiring:
|
|||||||
- `internal/stage/polish_test.go`
|
- `internal/stage/polish_test.go`
|
||||||
|
|
||||||
## Architectural Invariants
|
## Architectural Invariants
|
||||||
- Processed output must be valid JSON with top-level `segments` array.
|
- Polished output must be valid JSON with top-level `segments` array.
|
||||||
- When report is enabled, report output must be valid JSON.
|
- When report is enabled, report output must be valid JSON.
|
||||||
- If `llm_api_key_env` is configured, credential must be present in environment.
|
- If `llm_api_key_env` is configured, credential must be present in environment.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Define Narratio's adapter contract for merge, normalize, and trim subprocess inv
|
|||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
Inputs:
|
Inputs:
|
||||||
- `MergeRequest`: raw/normalized transcript inputs, output path, optional report, speaker/autocorrect paths, logs/config
|
- `MergeRequest`: raw/per-speaker normalized transcript inputs, base output path, optional report, speaker/autocorrect paths, logs/config
|
||||||
- `NormalizeRequest`: input transcript, output path, schema, optional report, timeout/log/config
|
- `NormalizeRequest`: input transcript, output path, schema, optional report, timeout/log/config
|
||||||
- `TrimRequest`: input transcript, output path, keep selector, timeout/log/config
|
- `TrimRequest`: input transcript, output path, keep selector, timeout/log/config
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ Owns:
|
|||||||
- JSON output validation
|
- JSON output validation
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
- Transcript input selection/promotion logic (stage-owned)
|
- Transcript input selection/materialization logic (stage-owned)
|
||||||
- Bounds computation (scriptorium/trim-stage-owned)
|
- Bounds computation (scriptorium/trim-stage-owned)
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
@@ -44,8 +44,8 @@ Via `pipeline.seriatim.*` mapped in app/stage wiring:
|
|||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
- Constructor fails for invalid binary/timeout/output-schema/coalesce-gap.
|
- Constructor fails for invalid binary/timeout/output-schema/coalesce-gap.
|
||||||
- Merge fails on missing output path/inputs/report path (if enabled), subprocess errors, invalid merged output JSON, invalid report JSON.
|
- Merge fails on missing output path/inputs/report path (if enabled), subprocess errors, invalid merged output JSON, invalid report JSON.
|
||||||
- Normalize fails on missing input/output, invalid schema, subprocess errors, invalid normalized output JSON shape, invalid report JSON.
|
- Normalize fails on missing input/output, invalid schema, subprocess errors, invalid final output JSON shape, invalid report JSON.
|
||||||
- Trim fails on missing input/output/keep selector, subprocess errors, invalid trimmed output JSON shape.
|
- Trim fails on missing input/output/keep selector, subprocess errors, invalid final-trimmed output JSON shape.
|
||||||
|
|
||||||
## Tests to Inspect Before Changing
|
## Tests to Inspect Before Changing
|
||||||
- `internal/adapters/seriatim/subprocess_test.go`
|
- `internal/adapters/seriatim/subprocess_test.go`
|
||||||
@@ -56,5 +56,5 @@ Via `pipeline.seriatim.*` mapped in app/stage wiring:
|
|||||||
|
|
||||||
## Architectural Invariants
|
## Architectural Invariants
|
||||||
- Supported output schemas are limited to `seriatim-minimal`, `seriatim-intermediate`, `seriatim-full`.
|
- Supported output schemas are limited to `seriatim-minimal`, `seriatim-intermediate`, `seriatim-full`.
|
||||||
- Normalize/trim outputs must include `segments` arrays.
|
- Final and final-trimmed outputs must include `segments` arrays.
|
||||||
- Merge/normalize/trim all route through deterministic subprocess invocation.
|
- Merge/normalize/trim all route through deterministic subprocess invocation.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Implementation-accurate contracts for workspace/state, manifests, stages, artifa
|
|||||||
- `storage.md`: remote storage backend contracts and object-store invariants.
|
- `storage.md`: remote storage backend contracts and object-store invariants.
|
||||||
- `manifest.md`: session/run manifest schemas, lifecycle transitions, and persistence semantics.
|
- `manifest.md`: session/run manifest schemas, lifecycle transitions, and persistence semantics.
|
||||||
- `artifacts.md`: built-in artifact registry, runtime artifact catalog, and source-resolution behavior.
|
- `artifacts.md`: built-in artifact registry, runtime artifact catalog, and source-resolution behavior.
|
||||||
- `workspace.md`: local state model, manifests, run-local layout, promotion, and cleanup invariants.
|
- `workspace.md`: local state model, manifests, run-local layout, materialization, and cleanup invariants.
|
||||||
- `command-restore.md`: restore command discovery/planning/execution/reporting contract.
|
- `command-restore.md`: restore command discovery/planning/execution/reporting contract.
|
||||||
- `stage-prepare.md`: input materialization and provenance capture.
|
- `stage-prepare.md`: input materialization and provenance capture.
|
||||||
- `stage-transcribe.md`: WhisperX transcript generation.
|
- `stage-transcribe.md`: WhisperX transcript generation.
|
||||||
@@ -20,7 +20,7 @@ Implementation-accurate contracts for workspace/state, manifests, stages, artifa
|
|||||||
- `stage-normalize.md`: post-polish normalization.
|
- `stage-normalize.md`: post-polish normalization.
|
||||||
- `stage-trim.md`: bounds-driven transcript trimming.
|
- `stage-trim.md`: bounds-driven transcript trimming.
|
||||||
- `stage-analyze.md`: dependency-ordered Scriptorium artifact generation for selected configured artifacts.
|
- `stage-analyze.md`: dependency-ordered Scriptorium artifact generation for selected configured artifacts.
|
||||||
- `stage-archive.md`: archive upload and current-pointer publish contract.
|
- `stage-publish.md`: publish upload and current-pointer commit contract.
|
||||||
|
|
||||||
## External Integration Notes
|
## External Integration Notes
|
||||||
- `../integrations/README.md`: canonical location for external integration contracts (`audita.md`, `seriatim.md`, `scriptorium.md`).
|
- `../integrations/README.md`: canonical location for external integration contracts (`audita.md`, `seriatim.md`, `scriptorium.md`).
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Default wiring and adapter calls consume:
|
|||||||
- `pipeline.seriatim.*`
|
- `pipeline.seriatim.*`
|
||||||
- `pipeline.audita.*`
|
- `pipeline.audita.*`
|
||||||
- `pipeline.scriptorium.*`
|
- `pipeline.scriptorium.*`
|
||||||
- `pipeline.storage.*` and `pipeline.archive.*` (object-store construction/gating)
|
- `pipeline.storage.*` and `pipeline.publish.*` (object-store construction/gating)
|
||||||
- `pipeline.notification.*` (sender boundary exists; placeholder behavior today)
|
- `pipeline.notification.*` (sender boundary exists; placeholder behavior today)
|
||||||
|
|
||||||
## External adapters used
|
## External adapters used
|
||||||
@@ -39,11 +39,10 @@ Runtime env boundary fields (`internal/stage.Env`):
|
|||||||
- `scriptorium.Runner`
|
- `scriptorium.Runner`
|
||||||
- `storage.ObjectStore`
|
- `storage.ObjectStore`
|
||||||
- `notify.Sender`
|
- `notify.Sender`
|
||||||
- `analyzer.Runner`
|
|
||||||
|
|
||||||
Current execution usage:
|
Current execution usage:
|
||||||
- Actively used by implemented stages: `WhisperX`, `Seriatim`, `Audita`, `Scriptorium`, `ObjectStore`, `Notifier`.
|
- Actively used by implemented stages: `WhisperX`, `Seriatim`, `Audita`, `Scriptorium`, `ObjectStore`, `Notifier`.
|
||||||
- Present but not used by implemented stage set: `Analyzer`, legacy `storage.Backend`.
|
- Present but not used by implemented stage set: legacy `storage.Backend`.
|
||||||
|
|
||||||
Default construction in app runner:
|
Default construction in app runner:
|
||||||
- Auto-constructed when not injected: WhisperX HTTP client, Seriatim subprocess runner, Audita subprocess runner, Scriptorium subprocess runner, object store (only when needed), and `notify.NoopSender`.
|
- Auto-constructed when not injected: WhisperX HTTP client, Seriatim subprocess runner, Audita subprocess runner, Scriptorium subprocess runner, object store (only when needed), and `notify.NoopSender`.
|
||||||
@@ -71,7 +70,6 @@ Default construction in app runner:
|
|||||||
- `internal/adapters/scriptorium/subprocess_test.go`
|
- `internal/adapters/scriptorium/subprocess_test.go`
|
||||||
- `internal/adapters/storage/*_test.go`
|
- `internal/adapters/storage/*_test.go`
|
||||||
- `internal/adapters/notify/fake_test.go`
|
- `internal/adapters/notify/fake_test.go`
|
||||||
- `internal/adapters/analyzer/fake_test.go`
|
|
||||||
- `internal/app/runner_test.go`
|
- `internal/app/runner_test.go`
|
||||||
|
|
||||||
## Architectural invariants
|
## Architectural invariants
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ Inputs:
|
|||||||
Outputs:
|
Outputs:
|
||||||
- resolved artifact path + provenance (`ResolvedSessionArtifact`);
|
- resolved artifact path + provenance (`ResolvedSessionArtifact`);
|
||||||
- runtime catalog entries for built-ins and configured artifacts;
|
- runtime catalog entries for built-ins and configured artifacts;
|
||||||
- requirement sets for canonical previous-session inputs.
|
- requirement sets for canonical previous-session inputs;
|
||||||
- canonical S3 session, run, current, session config, session locks, audio, and promoted artifact keys.
|
- canonical S3 session, run, current, session config, session locks, audio, and published output keys.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
Owns:
|
Owns:
|
||||||
@@ -28,15 +28,15 @@ Owns:
|
|||||||
Does not own:
|
Does not own:
|
||||||
- prepare-stage remote hydration;
|
- prepare-stage remote hydration;
|
||||||
- stage success/skip transitions;
|
- stage success/skip transitions;
|
||||||
- archive upload orchestration.
|
- publish upload orchestration.
|
||||||
|
|
||||||
## Built-in IDs
|
## Built-in IDs
|
||||||
| Artifact ID | Canonical file | Producer stage | Output kind |
|
| Artifact ID | Canonical file | Producer stage | Output kind |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `narratio.transcript.merged` | `transcripts/merged.json` | `merge` | `transcript_merged` |
|
| `narratio.transcript.base` | `transcripts/base.json` | `merge` | `transcript_base` |
|
||||||
| `narratio.transcript.polished` | `transcripts/processed.json` | `polish` | `transcript_processed` |
|
| `narratio.transcript.polished` | `transcripts/polished.json` | `polish` | `transcript_polished` |
|
||||||
| `narratio.transcript.full` | `transcripts/normalized.json` | `normalize` | `transcript_normalized` |
|
| `narratio.transcript.final` | `transcripts/final.json` | `normalize` | `transcript_final` |
|
||||||
| `narratio.transcript.trimmed` | `transcripts/trimmed.json` | `trim` | `transcript_trimmed` |
|
| `narratio.transcript.final_trimmed` | `transcripts/final.trimmed.json` | `trim` | `transcript_final_trimmed` |
|
||||||
| `narratio.bounds.session` | `artifacts/session_bounds.json` | `trim` | `session_bounds` |
|
| `narratio.bounds.session` | `artifacts/session_bounds.json` | `trim` | `session_bounds` |
|
||||||
|
|
||||||
## Source families
|
## Source families
|
||||||
@@ -71,7 +71,7 @@ Previous-session canonical provenance values include:
|
|||||||
- Built-ins resolve via manifest producer outputs first, then canonical fallback paths.
|
- Built-ins resolve via manifest producer outputs first, then canonical fallback paths.
|
||||||
- Configured `narratio.artifact.<name>` sources resolve through catalog availability.
|
- Configured `narratio.artifact.<name>` sources resolve through catalog availability.
|
||||||
- Canonical previous-session sources resolve to current-session `previous/` cache candidates derived from configured artifact canonical output paths.
|
- Canonical previous-session sources resolve to current-session `previous/` cache candidates derived from configured artifact canonical output paths.
|
||||||
- Archive-relative configured artifact paths under `artifacts/` are cached without a redundant nested `artifacts/` segment.
|
- Publish-relative configured artifact paths under `artifacts/` are cached without a redundant nested `artifacts/` segment.
|
||||||
- Previous-session canonical resolution prefers manifest-recorded input paths when present, then filesystem fallback under `previous/artifacts/**`.
|
- Previous-session canonical resolution prefers manifest-recorded input paths when present, then filesystem fallback under `previous/artifacts/**`.
|
||||||
|
|
||||||
## Previous-session requirement scanning
|
## Previous-session requirement scanning
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
# Internal: Command Restore
|
# Internal: Command Restore
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Define the implemented `narratio restore` contract: committed remote-state discovery, deterministic plan classification, safe file install semantics, and restore reporting.
|
Define the implemented `narratio session restore` contract: committed remote-state discovery, deterministic plan classification, safe file install semantics, and restore reporting.
|
||||||
|
|
||||||
## Inputs and outputs
|
## Inputs and outputs
|
||||||
Inputs:
|
Inputs:
|
||||||
- CLI flags: `--config`, `--session`, `--session-id`, `--previous-session-id`, `--dry-run`, `--force`, `--include-audio`.
|
- CLI syntax: `narratio session restore <session_id>`.
|
||||||
|
- CLI flags: `--config`, `--campaign`, `--campaign-file`, `--session`, `--previous-session-id`, `--dry-run`, `--force`, `--include-audio`.
|
||||||
- Resolved/validated `pipeline.yml` and `session.yml`.
|
- Resolved/validated `pipeline.yml` and `session.yml`.
|
||||||
- Configured remote object store.
|
- Configured remote object store.
|
||||||
- Remote committed current-state markers (`current/run_id.txt`, `current/manifest.json`).
|
- Remote committed current-state markers (`current/run_id.txt`, `current/manifest.json`).
|
||||||
@@ -26,14 +27,14 @@ Owns:
|
|||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
- Stage execution orchestration (`run`, `resume`, `run-stage`).
|
- Stage execution orchestration (`run`, `resume`, `run-stage`).
|
||||||
- Archive publish behavior (owned by archive stage).
|
- Publish-stage behavior.
|
||||||
- Storage transport implementation details (owned by storage adapters).
|
- Storage transport implementation details (owned by storage adapters).
|
||||||
|
|
||||||
## Config fields used
|
## Config fields used
|
||||||
- Config/session discovery and templating fields consumed by all commands.
|
- Config/session discovery and templating fields consumed by all commands.
|
||||||
- `pipeline.workspace.root` (local restore target root).
|
- `pipeline.workspace.root` (local restore target root).
|
||||||
- `pipeline.storage.*` (remote backend + archive identity derivation).
|
- `pipeline.storage.*` (remote backend + publish identity derivation).
|
||||||
- `pipeline.storage.s3.*` identity components used by archive prefix helpers.
|
- `pipeline.storage.s3.*` identity components used by session-prefix helpers.
|
||||||
- `pipeline.spool.root` for active audio downloads.
|
- `pipeline.spool.root` for active audio downloads.
|
||||||
- `pipeline.cache.root` and `pipeline.cache.s3_audio` for reusable S3 audio cache.
|
- `pipeline.cache.root` and `pipeline.cache.s3_audio` for reusable S3 audio cache.
|
||||||
- `session.session_id`
|
- `session.session_id`
|
||||||
@@ -79,7 +80,7 @@ Restore path scope:
|
|||||||
- Dry-run is read-only and returns plan output only.
|
- Dry-run is read-only and returns plan output only.
|
||||||
|
|
||||||
## Failure behavior
|
## Failure behavior
|
||||||
- Fails when storage backend is unavailable or archive identity cannot be resolved.
|
- Fails when storage backend is unavailable or publish identity cannot be resolved.
|
||||||
- Fails when remote current pointer/manifest is missing or invalid.
|
- Fails when remote current pointer/manifest is missing or invalid.
|
||||||
- Fails when remote manifest identity mismatches requested campaign/session.
|
- Fails when remote manifest identity mismatches requested campaign/session.
|
||||||
- Fails on local conflicts unless `--force` is set.
|
- Fails on local conflicts unless `--force` is set.
|
||||||
@@ -95,7 +96,7 @@ Restore path scope:
|
|||||||
- `internal/artifacts/archive_identity_test.go`
|
- `internal/artifacts/archive_identity_test.go`
|
||||||
|
|
||||||
## Architectural invariants
|
## Architectural invariants
|
||||||
- Restore relies on centralized archive identity/key helpers (`internal/artifacts`) rather than ad hoc key building.
|
- Restore relies on centralized path/key helpers (`internal/artifacts`) rather than ad hoc key building.
|
||||||
- `current/run_id.txt` is the remote commit marker; restore must not infer committed state from incidental files.
|
- `current/run_id.txt` is the remote commit marker; restore must not infer committed state from incidental files.
|
||||||
- Local path mapping is traversal-safe and constrained to session root.
|
- Local path mapping is traversal-safe and constrained to session root.
|
||||||
- Restore scope is deterministic and path-classified:
|
- Restore scope is deterministic and path-classified:
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Manifest identity fields are populated by app/stage orchestration from:
|
|||||||
- `session.session_id`
|
- `session.session_id`
|
||||||
- `session.campaign`
|
- `session.campaign`
|
||||||
- `pipeline.workspace.root`
|
- `pipeline.workspace.root`
|
||||||
- `pipeline.storage.s3.*` (when archive/S3 identity is set)
|
- `pipeline.storage.s3.*` (when publish/S3 identity is set)
|
||||||
|
|
||||||
## External adapters used
|
## External adapters used
|
||||||
- No external service adapters.
|
- No external service adapters.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Stage: analyze
|
# Stage: analyze
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Execute selected configured Scriptorium artifacts in deterministic dependency order and promote successful outputs to canonical session artifact paths.
|
Execute selected configured Scriptorium artifacts in deterministic dependency order and materialize successful outputs to canonical session artifact paths.
|
||||||
|
|
||||||
## Inputs and outputs
|
## Inputs and outputs
|
||||||
Inputs:
|
Inputs:
|
||||||
@@ -12,11 +12,10 @@ Inputs:
|
|||||||
Source types used by analyze:
|
Source types used by analyze:
|
||||||
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`;
|
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`;
|
||||||
- configured artifacts: `narratio.artifact.<artifact_key>`;
|
- configured artifacts: `narratio.artifact.<artifact_key>`;
|
||||||
- canonical previous-session artifacts: `narratio.previous_session.artifact.<artifact_key>`;
|
- canonical previous-session artifacts: `narratio.previous_session.artifact.<artifact_key>`.
|
||||||
- legacy path-based previous-session source: `previous_session_artifact` (uses `inputs.*.path`).
|
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
- promoted configured artifact files at each configured `output_path`;
|
- materialized configured artifact files at each configured `output_path`;
|
||||||
- stage metadata (`generated_artifacts`, `reused_artifacts`, selected/order info).
|
- stage metadata (`generated_artifacts`, `reused_artifacts`, selected/order info).
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -25,12 +24,12 @@ Owns:
|
|||||||
- selected-artifact planning and dependency ordering;
|
- selected-artifact planning and dependency ordering;
|
||||||
- per-input resolution and required/optional handling;
|
- per-input resolution and required/optional handling;
|
||||||
- Scriptorium render/run invocation;
|
- Scriptorium render/run invocation;
|
||||||
- run-local output generation and canonical promotion.
|
- run-local output generation and canonical materialization.
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
- prepare-time previous-session hydration;
|
- prepare-time previous-session hydration;
|
||||||
- object-store access for previous-session sources;
|
- object-store access for previous-session sources;
|
||||||
- archive promotion policy.
|
- publish output rule behavior.
|
||||||
|
|
||||||
## Config fields used
|
## Config fields used
|
||||||
- `session.session_id`
|
- `session.session_id`
|
||||||
@@ -77,5 +76,5 @@ Does not own:
|
|||||||
|
|
||||||
## Architectural invariants
|
## Architectural invariants
|
||||||
- Canonical previous-session behavior is local-cache only during analyze.
|
- Canonical previous-session behavior is local-cache only during analyze.
|
||||||
- Generated outputs are validated and promoted before stage success is recorded.
|
- Generated outputs are validated and materialized before stage success is recorded.
|
||||||
- Resolver/catalog decisions stay deterministic and validation-gated.
|
- Resolver/catalog decisions stay deterministic and validation-gated.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Stage: merge
|
# Stage: merge
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Normalize per-speaker raw transcripts and merge them into one merged transcript via Seriatim.
|
Normalize per-speaker raw transcripts and merge them into the base transcript via Seriatim.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
Inputs:
|
Inputs:
|
||||||
@@ -10,7 +10,7 @@ Inputs:
|
|||||||
- `inputs/autocorrect.yml`
|
- `inputs/autocorrect.yml`
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
- `transcripts/merged.json`
|
- `transcripts/base.json`
|
||||||
- optional `artifacts/seriatim.report.json` (when report enabled)
|
- optional `artifacts/seriatim.report.json` (when report enabled)
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
@@ -19,7 +19,7 @@ Owns:
|
|||||||
- Per-input normalize calls to Seriatim
|
- Per-input normalize calls to Seriatim
|
||||||
- Final merge call to Seriatim
|
- Final merge call to Seriatim
|
||||||
- Run-local log/config/report path wiring
|
- Run-local log/config/report path wiring
|
||||||
- Promotion of merged/report outputs to canonical paths
|
- Materialization of base/report outputs to canonical paths
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
- Transcript polishing or downstream artifact generation
|
- Transcript polishing or downstream artifact generation
|
||||||
@@ -43,7 +43,7 @@ Does not own:
|
|||||||
## State and Manifest Behavior
|
## State and Manifest Behavior
|
||||||
- Reads transcript inputs from transcribe stage outputs in manifest when present; falls back to canonical raw directory.
|
- Reads transcript inputs from transcribe stage outputs in manifest when present; falls back to canonical raw directory.
|
||||||
- Writes run-local outputs/logs/config under `runs/{run_id}/merge/...` when enabled.
|
- Writes run-local outputs/logs/config under `runs/{run_id}/merge/...` when enabled.
|
||||||
- Promotes canonical merged transcript and optional report.
|
- Materializes canonical base transcript and optional report.
|
||||||
- Records normalized-input provenance and adapter metadata in stage metadata.
|
- Records normalized-input provenance and adapter metadata in stage metadata.
|
||||||
|
|
||||||
## Skip and Resume Behavior
|
## Skip and Resume Behavior
|
||||||
@@ -51,7 +51,7 @@ Does not own:
|
|||||||
- Forced rerun of this or upstream stages can stale downstream succeeded stages via runner invalidation.
|
- Forced rerun of this or upstream stages can stale downstream succeeded stages via runner invalidation.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
- Fails on missing/invalid raw transcripts, missing speakers/autocorrect files, normalize failure, merge failure, invalid merged output JSON, or invalid report JSON when enabled.
|
- Fails on missing/invalid raw transcripts, missing speakers/autocorrect files, normalize failure, merge failure, invalid base output JSON, or invalid report JSON when enabled.
|
||||||
|
|
||||||
## Tests to Inspect Before Changing
|
## Tests to Inspect Before Changing
|
||||||
- `internal/stage/merge_test.go`
|
- `internal/stage/merge_test.go`
|
||||||
@@ -59,5 +59,5 @@ Does not own:
|
|||||||
|
|
||||||
## Architectural Invariants
|
## Architectural Invariants
|
||||||
- Merge consumes normalized forms of each raw transcript.
|
- Merge consumes normalized forms of each raw transcript.
|
||||||
- Merged transcript must validate before promotion.
|
- Base transcript must validate before materialization.
|
||||||
- Report output is optional and gated by config.
|
- Report output is optional and gated by config.
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
# Stage: normalize
|
# Stage: normalize
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Normalize the processed transcript into a deterministic intermediate schema for trim and optionally emit a normalize report.
|
Normalize the polished transcript into the full final transcript and optionally emit a normalize report.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
Inputs:
|
Inputs:
|
||||||
- `transcripts/processed.json`
|
- `transcripts/polished.json`
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
- `transcripts/normalized.json` (or configured normalize output path)
|
- `transcripts/final.json` (or configured normalize output path)
|
||||||
- optional `artifacts/seriatim.normalize.report.json`
|
- optional `artifacts/seriatim.normalize.report.json`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
Owns:
|
Owns:
|
||||||
- Processed transcript discovery/validation
|
- Polished transcript discovery/validation
|
||||||
- Normalize request construction and invocation
|
- Normalize request construction and invocation
|
||||||
- Optional normalize report wiring
|
- Optional normalize report wiring
|
||||||
- Promotion of normalized transcript and optional report
|
- Promotion of final transcript and optional report
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
- Bounds detection or segment trimming
|
- Bounds detection or segment trimming
|
||||||
@@ -35,9 +35,9 @@ Does not own:
|
|||||||
- Seriatim adapter (`Normalize`).
|
- Seriatim adapter (`Normalize`).
|
||||||
|
|
||||||
## State and Manifest Behavior
|
## State and Manifest Behavior
|
||||||
- Reads processed transcript from polish outputs in manifest when present; falls back to canonical path.
|
- Reads polished transcript from polish outputs in manifest when present; falls back to canonical path.
|
||||||
- Uses run-local output/report/log/config paths when run layout is enabled.
|
- Uses run-local output/report/log/config paths when run layout is enabled.
|
||||||
- Promotes canonical normalized transcript and optional normalize report.
|
- Promotes canonical final transcript and optional normalize report.
|
||||||
- Records adapter/result metadata including source path selection.
|
- Records adapter/result metadata including source path selection.
|
||||||
|
|
||||||
## Skip and Resume Behavior
|
## Skip and Resume Behavior
|
||||||
@@ -45,12 +45,12 @@ Does not own:
|
|||||||
- Forced reruns can stale downstream succeeded stages.
|
- Forced reruns can stale downstream succeeded stages.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
- Fails on missing/invalid processed transcript, adapter error, invalid normalized output, or invalid report output when report enabled.
|
- Fails on missing/invalid polished transcript, adapter error, invalid final output, or invalid report output when report enabled.
|
||||||
|
|
||||||
## Tests to Inspect Before Changing
|
## Tests to Inspect Before Changing
|
||||||
- `internal/stage/normalize_test.go`
|
- `internal/stage/normalize_test.go`
|
||||||
- `internal/adapters/seriatim/subprocess_test.go`
|
- `internal/adapters/seriatim/subprocess_test.go`
|
||||||
|
|
||||||
## Architectural Invariants
|
## Architectural Invariants
|
||||||
- Normalized output must validate as processed-transcript-compatible JSON (`segments` array required).
|
- Final output must validate as transcript-compatible JSON (`segments` array required).
|
||||||
- Default normalize config is applied when `pipeline.normalize` is unset.
|
- Default normalize config is applied when `pipeline.normalize` is unset.
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
# Stage: polish
|
# Stage: polish
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Polish merged transcript with Audita and produce a processed transcript for downstream normalization/analyze.
|
Polish the base transcript with Audita and produce a polished transcript for downstream normalization/analyze.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
Inputs:
|
Inputs:
|
||||||
- `transcripts/merged.json`
|
- `transcripts/base.json`
|
||||||
- `inputs/glossary.yml`
|
- `inputs/glossary.yml`
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
- `transcripts/processed.json`
|
- `transcripts/polished.json`
|
||||||
- optional `artifacts/audita.report.json` (when report enabled)
|
- optional `artifacts/audita.report.json` (when report enabled)
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
Owns:
|
Owns:
|
||||||
- Merged transcript discovery/validation
|
- Base transcript discovery/validation
|
||||||
- Audita invocation request construction
|
- Audita invocation request construction
|
||||||
- Run-local logs/config/work-dir/report wiring
|
- Run-local logs/config/work-dir/report wiring
|
||||||
- Promotion of processed transcript and optional report
|
- Promotion of polished transcript and optional report
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
- Upstream merge normalization
|
- Upstream merge normalization
|
||||||
@@ -47,9 +47,9 @@ Does not own:
|
|||||||
- Audita adapter (`env.Audita.Run`).
|
- Audita adapter (`env.Audita.Run`).
|
||||||
|
|
||||||
## State and Manifest Behavior
|
## State and Manifest Behavior
|
||||||
- Reads merged transcript from merge manifest outputs when available; falls back to canonical merged path.
|
- Reads base transcript from merge manifest outputs when available; falls back to canonical base path.
|
||||||
- Uses run-local output/report/log/config/scratch paths when run layout is enabled.
|
- Uses run-local output/report/log/config/scratch paths when run layout is enabled.
|
||||||
- Promotes canonical `transcripts/processed.json` and optional report.
|
- Promotes canonical `transcripts/polished.json` and optional report.
|
||||||
- Records adapter invocation metadata, credential presence signal, and output provenance in stage metadata.
|
- Records adapter invocation metadata, credential presence signal, and output provenance in stage metadata.
|
||||||
|
|
||||||
## Skip and Resume Behavior
|
## Skip and Resume Behavior
|
||||||
@@ -57,13 +57,13 @@ Does not own:
|
|||||||
- Forced rerun can stale downstream succeeded stages via runner invalidation.
|
- Forced rerun can stale downstream succeeded stages via runner invalidation.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
- Fails on missing/invalid merged transcript, missing glossary, adapter error, invalid processed output shape (`segments` array required), or invalid report JSON when enabled.
|
- Fails on missing/invalid base transcript, missing glossary, adapter error, invalid polished output shape (`segments` array required), or invalid report JSON when enabled.
|
||||||
|
|
||||||
## Tests to Inspect Before Changing
|
## Tests to Inspect Before Changing
|
||||||
- `internal/stage/polish_test.go`
|
- `internal/stage/polish_test.go`
|
||||||
- `internal/adapters/audita/subprocess_test.go`
|
- `internal/adapters/audita/subprocess_test.go`
|
||||||
|
|
||||||
## Architectural Invariants
|
## Architectural Invariants
|
||||||
- Processed transcript must contain a top-level `segments` array.
|
- Polished transcript must contain a top-level `segments` array.
|
||||||
- Report behavior is strictly config-gated.
|
- Report behavior is strictly config-gated.
|
||||||
- Stage output canonicalization always ends at `transcripts/processed.json`.
|
- Stage output canonicalization always ends at `transcripts/polished.json`.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Inputs:
|
|||||||
- local: `session.inputs.audio_dir` or `session.inputs.audio_files`;
|
- local: `session.inputs.audio_dir` or `session.inputs.audio_files`;
|
||||||
- S3: `session.inputs.audio_s3.prefix`;
|
- S3: `session.inputs.audio_s3.prefix`;
|
||||||
- configured enabled Scriptorium artifact inputs (for previous-session requirement scanning);
|
- configured enabled Scriptorium artifact inputs (for previous-session requirement scanning);
|
||||||
- remote previous-session current archive state when previous hydration is required.
|
- remote previous-session current publish state when previous hydration is required.
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
- `inputs/campaign.yml`;
|
- `inputs/campaign.yml`;
|
||||||
@@ -41,7 +41,7 @@ Owns:
|
|||||||
Does not own:
|
Does not own:
|
||||||
- transcript or artifact generation;
|
- transcript or artifact generation;
|
||||||
- analyze-stage source resolution;
|
- analyze-stage source resolution;
|
||||||
- archive commit behavior.
|
- publish commit behavior.
|
||||||
|
|
||||||
## Config fields used
|
## Config fields used
|
||||||
- `session.session_id`
|
- `session.session_id`
|
||||||
@@ -62,7 +62,7 @@ Does not own:
|
|||||||
- `pipeline.scriptorium.artifacts.<name>.enabled`
|
- `pipeline.scriptorium.artifacts.<name>.enabled`
|
||||||
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source`
|
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source`
|
||||||
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required`
|
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required`
|
||||||
- `campaign.campaign`
|
- `campaign.campaign_id`
|
||||||
- `campaign.inputs.speakers_file`
|
- `campaign.inputs.speakers_file`
|
||||||
- `campaign.inputs.autocorrect_file`
|
- `campaign.inputs.autocorrect_file`
|
||||||
- `campaign.inputs.glossary_file`
|
- `campaign.inputs.glossary_file`
|
||||||
@@ -83,10 +83,10 @@ Does not own:
|
|||||||
- `narratio.previous_session.artifact.<artifact_key>`
|
- `narratio.previous_session.artifact.<artifact_key>`
|
||||||
- If one or more canonical previous-session requirements exist:
|
- If one or more canonical previous-session requirements exist:
|
||||||
- clears managed `previous/` state;
|
- clears managed `previous/` state;
|
||||||
- hydrates required/optional previous artifacts from the configured previous session’s committed archive current state;
|
- hydrates required/optional previous artifacts from the configured previous session’s committed publish current state;
|
||||||
- writes `previous/manifest.json` and hydrated `previous/artifacts/**`;
|
- writes `previous/manifest.json` and hydrated `previous/artifacts/**`;
|
||||||
- stores archive-relative artifact paths such as `artifacts/session_recap.md` as `previous/artifacts/session_recap.md`, not `previous/artifacts/artifacts/session_recap.md`;
|
- stores publish-relative artifact paths such as `artifacts/session_recap.md` as `previous/artifacts/session_recap.md`, not `previous/artifacts/artifacts/session_recap.md`;
|
||||||
- records hydrated previous inputs in `manifest.Inputs` with source `previous_session_archive.current`.
|
- records hydrated previous inputs in `manifest.Inputs` with source `previous_session_publish.current`.
|
||||||
- If no canonical previous-session requirements exist, prepare does not manage `previous/`.
|
- If no canonical previous-session requirements exist, prepare does not manage `previous/`.
|
||||||
- `manifest.Inputs` is sorted deterministically by `(kind, path)`.
|
- `manifest.Inputs` is sorted deterministically by `(kind, path)`.
|
||||||
- S3 audio `manifest.Inputs` retain S3 provenance and include `cache_path`; `spool_path` is present only when the current prepare invocation downloaded the file.
|
- S3 audio `manifest.Inputs` retain S3 provenance and include `cache_path`; `spool_path` is present only when the current prepare invocation downloaded the file.
|
||||||
@@ -95,7 +95,7 @@ Does not own:
|
|||||||
- `previous_session_id` unset:
|
- `previous_session_id` unset:
|
||||||
- if any referenced previous artifact is required: fail;
|
- if any referenced previous artifact is required: fail;
|
||||||
- if all referenced previous artifacts are optional: continue and omit them.
|
- if all referenced previous artifacts are optional: continue and omit them.
|
||||||
- Previous session archive current pointer or manifest missing:
|
- Previous session publish current pointer or manifest missing:
|
||||||
- if any referenced previous artifact is required: fail;
|
- if any referenced previous artifact is required: fail;
|
||||||
- if all referenced previous artifacts are optional: continue and omit missing ones.
|
- if all referenced previous artifacts are optional: continue and omit missing ones.
|
||||||
- Missing required previous artifact object: fail.
|
- Missing required previous artifact object: fail.
|
||||||
@@ -120,5 +120,5 @@ Does not own:
|
|||||||
|
|
||||||
## Architectural invariants
|
## Architectural invariants
|
||||||
- `audio_dir`/`audio_files` and `audio_s3` are mutually exclusive.
|
- `audio_dir`/`audio_files` and `audio_s3` are mutually exclusive.
|
||||||
- Storage keys are computed by callers using archive/path helpers; storage adapter receives explicit keys.
|
- Storage keys are computed by callers using path helpers; storage adapter receives explicit keys.
|
||||||
- `prepare` is the only stage that hydrates canonical previous-session cache state.
|
- `prepare` is the only stage that hydrates canonical previous-session cache state.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Stage: archive
|
# Stage: publish
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Publish durable run/session state to object storage, then atomically advance remote current state.
|
Publish durable run/session state to object storage, then atomically advance remote current state.
|
||||||
@@ -7,37 +7,37 @@ Publish durable run/session state to object storage, then atomically advance rem
|
|||||||
Inputs:
|
Inputs:
|
||||||
- session manifest and prerequisite stage records
|
- session manifest and prerequisite stage records
|
||||||
- run root contents under `runs/{run_id}/`
|
- run root contents under `runs/{run_id}/`
|
||||||
- promotion rules with artifact `source` IDs and archive `dest` paths (`archive.promote_artifacts`)
|
- publish output rules with artifact `source` IDs and publish `dest` paths (`pipeline.publish.outputs`)
|
||||||
- effective source-based promotion locks from static config and remote session lock store
|
- effective source-based publish locks from static config and remote session lock store
|
||||||
- session-level `previous/**` cache files when present
|
- session-level `previous/**` cache files when present
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
- uploaded run files under `{session_prefix}/runs/{run_id}/...`
|
- uploaded run files under `{session_prefix}/runs/{run_id}/...`
|
||||||
- uploaded promoted artifacts under `{session_prefix}/...`
|
- uploaded published outputs under `{session_prefix}/...`
|
||||||
- uploaded session previous-cache files under `{session_prefix}/previous/...` when present
|
- uploaded session previous-cache files under `{session_prefix}/previous/...` when present
|
||||||
- `{session_prefix}/current/manifest.json`
|
- `{session_prefix}/current/manifest.json`
|
||||||
- `{session_prefix}/current/run_id.txt` written last
|
- `{session_prefix}/current/run_id.txt` written last
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
Owns:
|
Owns:
|
||||||
- Archive enable/disable gate behavior
|
- publish enable/disable gate behavior
|
||||||
- Prerequisite stage success enforcement
|
- prerequisite stage success enforcement
|
||||||
- Run file collection and upload (excluding `audio/`)
|
- run file collection and upload (excluding `audio/`)
|
||||||
- Promotion rule resolution and upload
|
- publish output rule resolution and upload
|
||||||
- Promotion lock enforcement
|
- publish lock enforcement
|
||||||
- Session previous-cache file collection/upload
|
- session previous-cache file collection/upload
|
||||||
- Commit pointer publish order
|
- commit pointer publish order
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
- Stage execution before archive
|
- stage execution before publish
|
||||||
- Post-archive local cleanup policy execution (handled by app cleanup logic)
|
- post-publish local cleanup policy execution (handled by app cleanup logic)
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
- `pipeline.archive.enabled`
|
- `pipeline.publish.enabled`
|
||||||
- `pipeline.archive.upload_run`
|
- `pipeline.publish.upload_run`
|
||||||
- `pipeline.archive.promote_artifacts`
|
- `pipeline.publish.outputs`
|
||||||
- `pipeline.archive.locks`
|
- `pipeline.publish.locks`
|
||||||
- `{session_prefix}/locks.yml` loaded by app orchestration before archive execution
|
- `{session_prefix}/locks.yml` loaded by app orchestration before publish execution
|
||||||
- `pipeline.storage.s3.bucket`
|
- `pipeline.storage.s3.bucket`
|
||||||
- `pipeline.storage.s3.root_prefix`
|
- `pipeline.storage.s3.root_prefix`
|
||||||
- `pipeline.workspace.root`
|
- `pipeline.workspace.root`
|
||||||
@@ -51,24 +51,28 @@ Does not own:
|
|||||||
- Requires `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, and `analyze` status `succeeded`.
|
- Requires `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, and `analyze` status `succeeded`.
|
||||||
- Resolves bucket/prefix from manifest identity first, then config fallback.
|
- Resolves bucket/prefix from manifest identity first, then config fallback.
|
||||||
- Uploads session `previous/**` files as durable session state when the local `previous/` directory exists.
|
- Uploads session `previous/**` files as durable session state when the local `previous/` directory exists.
|
||||||
- Skips top-level promotion uploads for effective locked sources; run-local uploads still publish.
|
- Skips top-level published output uploads for effective locked sources; run-local materialized outputs remain unchanged.
|
||||||
- Effective locks are the union of `pipeline.archive.locks` and remote `{session_prefix}/locks.yml`; static pipeline locks win on duplicate sources.
|
- When selected configured artifact keys are supplied, skips publish rules for unselected `narratio.artifact.<key>` sources; built-in transcript and bounds outputs still publish.
|
||||||
|
- Effective locks are the union of `pipeline.publish.locks` and remote `{session_prefix}/locks.yml`; static pipeline locks win on duplicate sources.
|
||||||
- Writes metadata including:
|
- Writes metadata including:
|
||||||
- upload counts/paths
|
- upload counts/paths
|
||||||
- `previous_files_uploaded` and `previous_uploaded_paths`
|
- `previous_files_uploaded` and `previous_uploaded_paths`
|
||||||
- `locked_promotion_count` and `locked_promotions`
|
- `published_files_uploaded` and `published_paths`
|
||||||
|
- `skipped_optional_outputs`
|
||||||
|
- `skipped_unselected_outputs`
|
||||||
|
- `locked_output_count` and `locked_outputs`
|
||||||
- `current_manifest_key`
|
- `current_manifest_key`
|
||||||
- `current_run_id_key`
|
- `current_run_id_key`
|
||||||
- `current_pointer_written`
|
- `current_pointer_written`
|
||||||
- On skipped archive path, returns metadata with `skipped=true` and pointer not written.
|
- On skipped publish path, returns metadata with `skipped=true` and pointer not written.
|
||||||
|
|
||||||
## Skip and Resume Behavior
|
## Skip and Resume Behavior
|
||||||
- Stage may self-skip (metadata skip) when archive disabled or run upload disabled.
|
- Stage may self-skip (metadata skip) when publish disabled or run upload disabled.
|
||||||
- Runner-level skip also applies for previously succeeded stage unless forced.
|
- Runner-level skip also applies for previously succeeded stage unless forced.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
- Fails on missing prerequisite success, missing object store when required, missing run root, missing unlocked required promotion source, upload failures, or pointer write failures.
|
- Fails on missing prerequisite success, missing object store when required, missing run root, missing unlocked required output source, upload failures, or pointer write failures.
|
||||||
- Locked required promotions are intentional skips and do not fail archive.
|
- Locked required outputs are intentional skips and do not fail publish.
|
||||||
- Pointer semantics are fail-safe: `current/run_id.txt` is not written if prior required uploads fail.
|
- Pointer semantics are fail-safe: `current/run_id.txt` is not written if prior required uploads fail.
|
||||||
|
|
||||||
## Tests to Inspect Before Changing
|
## Tests to Inspect Before Changing
|
||||||
@@ -77,8 +81,8 @@ Does not own:
|
|||||||
|
|
||||||
## Architectural Invariants
|
## Architectural Invariants
|
||||||
- Run upload excludes `audio/` subtree.
|
- Run upload excludes `audio/` subtree.
|
||||||
- Session `previous/**` is archiveable durable input/provenance state, not run-local output.
|
- Session `previous/**` is publishable durable input/provenance state, not run-local output.
|
||||||
- Ordinary `--force` does not override archive locks.
|
- Ordinary `--force` does not override publish locks.
|
||||||
- Malformed or unreadable remote lock store fails archive-capable execution before promotion.
|
- Malformed or unreadable remote lock store fails publish-capable execution before output uploads.
|
||||||
- `current/manifest.json` uploads before `current/run_id.txt`.
|
- `current/manifest.json` uploads before `current/run_id.txt`.
|
||||||
- `current/run_id.txt` is the remote publish commit marker.
|
- `current/run_id.txt` is the remote publish commit marker.
|
||||||
@@ -15,7 +15,7 @@ Owns:
|
|||||||
- Discovering prepared audio inputs
|
- Discovering prepared audio inputs
|
||||||
- Deriving speaker ids from audio basenames
|
- Deriving speaker ids from audio basenames
|
||||||
- Parallel WhisperX invocation with bounded concurrency
|
- Parallel WhisperX invocation with bounded concurrency
|
||||||
- Validating produced JSON and promoting run-local outputs
|
- Validating produced JSON and materializing run-local outputs
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
- Transcript merge/polish/normalize/trim/analyze
|
- Transcript merge/polish/normalize/trim/analyze
|
||||||
@@ -36,8 +36,8 @@ Does not own:
|
|||||||
|
|
||||||
## State and Manifest Behavior
|
## State and Manifest Behavior
|
||||||
- Uses run-local output paths under `runs/{run_id}/transcribe/outputs/...` when run layout is enabled.
|
- Uses run-local output paths under `runs/{run_id}/transcribe/outputs/...` when run layout is enabled.
|
||||||
- Validates each generated transcript JSON before promotion.
|
- Validates each generated transcript JSON before materialization.
|
||||||
- Promotes canonical outputs to `transcripts/raw/*.json`.
|
- Materializes canonical outputs to `transcripts/raw/*.json`.
|
||||||
- Records per-file metadata (attempts/status/duration/output path) in stage metadata.
|
- Records per-file metadata (attempts/status/duration/output path) in stage metadata.
|
||||||
|
|
||||||
## Skip and Resume Behavior
|
## Skip and Resume Behavior
|
||||||
@@ -54,5 +54,5 @@ Does not own:
|
|||||||
|
|
||||||
## Architectural Invariants
|
## Architectural Invariants
|
||||||
- Speaker identity is derived from `.flac` basename and must be unique.
|
- Speaker identity is derived from `.flac` basename and must be unique.
|
||||||
- Every successful speaker output must be valid JSON before promotion.
|
- Every successful speaker output must be valid JSON before materialization.
|
||||||
- Canonical raw transcript set is the only supported merge input surface.
|
- Canonical raw transcript set is the only supported merge input surface.
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
# Stage: trim
|
# Stage: trim
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Optionally trim the normalized transcript to session bounds; always produce a durable trimmed transcript.
|
Optionally trim the final transcript to session bounds; always produce a durable final-trimmed transcript.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
Inputs:
|
Inputs:
|
||||||
- `transcripts/normalized.json`
|
- `transcripts/final.json`
|
||||||
|
|
||||||
Outputs:
|
Outputs:
|
||||||
- `transcripts/trimmed.json` (or configured trim output path)
|
- `transcripts/final.trimmed.json` (or configured trim output path)
|
||||||
- when trim enabled: `artifacts/session_bounds.json`
|
- when trim enabled: `artifacts/session_bounds.json`
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
Owns:
|
Owns:
|
||||||
- Trim-enabled switch behavior
|
- Trim-enabled switch behavior
|
||||||
- Bounds generation via Scriptorium artifact run
|
- Bounds generation via Scriptorium artifact run
|
||||||
- Bounds validation against normalized transcript
|
- Bounds validation against final transcript
|
||||||
- Keep-selector derivation and Seriatim trim invocation
|
- Keep-selector derivation and Seriatim trim invocation
|
||||||
- Copy-through behavior when disabled or bounds indicate unchanged transcript
|
- Copy-through behavior when disabled or bounds indicate unchanged transcript
|
||||||
|
|
||||||
@@ -50,19 +50,19 @@ Does not own:
|
|||||||
- `Trim` when bounds indicate trimming is required
|
- `Trim` when bounds indicate trimming is required
|
||||||
|
|
||||||
## State and Manifest Behavior
|
## State and Manifest Behavior
|
||||||
- Reads normalized transcript from normalize manifest outputs when available; falls back to canonical path.
|
- Reads final transcript from normalize manifest outputs when available; falls back to canonical path.
|
||||||
- Uses run-local outputs/logs/reports/config/scratch paths when run layout is enabled.
|
- Uses run-local outputs/logs/reports/config/scratch paths when run layout is enabled.
|
||||||
- Promotes canonical trimmed transcript; promotes session bounds when trim enabled.
|
- Materializes canonical final-trimmed transcript and session bounds when trim is enabled.
|
||||||
- Records bounds diagnostics, trim action, keep selector, and adapter metadata.
|
- Records bounds diagnostics, trim action, keep selector, and adapter metadata.
|
||||||
|
|
||||||
## Skip and Resume Behavior
|
## Skip and Resume Behavior
|
||||||
- Runner-level skip applies when already succeeded and not forced.
|
- Runner-level skip applies when already succeeded and not forced.
|
||||||
- Forced reruns can stale downstream succeeded stages.
|
- Forced reruns can stale downstream succeeded stages.
|
||||||
- When `trim.enabled=false`, stage still succeeds by copying normalized to trimmed output.
|
- When `trim.enabled=false`, stage still succeeds by copying final to final-trimmed output.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
- Fails on missing/invalid normalized transcript.
|
- Fails on missing/invalid final transcript.
|
||||||
- With trim enabled, fails on missing adapters/config, bounds generation/validation errors, invalid bounds JSON, invalid range/segment ids, trim adapter failures, or invalid trimmed output.
|
- With trim enabled, fails on missing adapters/config, bounds generation/validation errors, invalid bounds JSON, invalid range/segment ids, trim adapter failures, or invalid final-trimmed output.
|
||||||
|
|
||||||
## Tests to Inspect Before Changing
|
## Tests to Inspect Before Changing
|
||||||
- `internal/stage/trim_test.go`
|
- `internal/stage/trim_test.go`
|
||||||
@@ -70,6 +70,6 @@ Does not own:
|
|||||||
- `internal/adapters/seriatim/subprocess_test.go`
|
- `internal/adapters/seriatim/subprocess_test.go`
|
||||||
|
|
||||||
## Architectural Invariants
|
## Architectural Invariants
|
||||||
- Trim never falls back to processed transcript; normalized transcript is required input.
|
- Trim never falls back to polished transcript; final transcript is required input.
|
||||||
- `session_bounds` output exists only for enabled trim path.
|
- `session_bounds` output exists only for enabled trim path.
|
||||||
- Render-debug artifacts are diagnostics and not declared stage outputs.
|
- Render-debug artifacts are diagnostics and not declared stage outputs.
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ Does not own:
|
|||||||
## External adapters used
|
## External adapters used
|
||||||
Storage package contracts:
|
Storage package contracts:
|
||||||
- `ObjectStore` (active remote object-store boundary): `List`, `Download`, `Upload`, `Exists`.
|
- `ObjectStore` (active remote object-store boundary): `List`, `Download`, `Upload`, `Exists`.
|
||||||
- `Backend` (archive request boundary): currently implemented with `NoopBackend` only.
|
- `Backend` (legacy compatibility boundary): currently implemented with `NoopBackend` only.
|
||||||
|
|
||||||
Implementations:
|
Implementations:
|
||||||
- `S3Backend`: AWS SDK-backed `ObjectStore` implementation.
|
- `S3Backend`: AWS SDK-backed `ObjectStore` implementation.
|
||||||
- `FakeBackend`: deterministic test `ObjectStore` and archive backend.
|
- `FakeBackend`: deterministic test `ObjectStore` and compatibility backend.
|
||||||
- `NoopBackend`: deterministic no-op archive backend for compatibility wiring.
|
- `NoopBackend`: deterministic no-op compatibility backend for wiring/tests.
|
||||||
|
|
||||||
## State and manifest behavior
|
## State and manifest behavior
|
||||||
- Storage implementations are stateless with respect to manifest/session lifecycle.
|
- Storage implementations are stateless with respect to manifest/session lifecycle.
|
||||||
@@ -67,7 +67,7 @@ Implementations:
|
|||||||
- `internal/adapters/storage/s3_backend_test.go`
|
- `internal/adapters/storage/s3_backend_test.go`
|
||||||
- `internal/adapters/storage/fake_test.go`
|
- `internal/adapters/storage/fake_test.go`
|
||||||
- `internal/adapters/storage/keys_test.go`
|
- `internal/adapters/storage/keys_test.go`
|
||||||
- `internal/adapters/storage/archive.go` + consumers in stage tests (`prepare`, `archive`)
|
- `internal/adapters/storage/archive.go` + consumers in stage tests (`prepare`, `publish`)
|
||||||
|
|
||||||
## Architectural invariants
|
## Architectural invariants
|
||||||
- Callers pass full bucket-relative keys.
|
- Callers pass full bucket-relative keys.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Workspace internals
|
# Workspace internals
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Define the local durable and run-local workspace model used by stages, manifests, resume, and archive.
|
Define the local durable and run-local workspace model used by stages, manifests, resume, and publish.
|
||||||
|
|
||||||
## Inputs and Outputs
|
## Inputs and Outputs
|
||||||
Inputs:
|
Inputs:
|
||||||
@@ -18,20 +18,20 @@ Outputs:
|
|||||||
## Boundaries
|
## Boundaries
|
||||||
Owns:
|
Owns:
|
||||||
- Session-level path layout (`inputs/`, `audio/`, `transcripts/`, `artifacts/`, `reports/`, `logs/`, `config/`, `current/`, `runs/`, `previous/`)
|
- Session-level path layout (`inputs/`, `audio/`, `transcripts/`, `artifacts/`, `reports/`, `logs/`, `config/`, `current/`, `runs/`, `previous/`)
|
||||||
- `previous/manifest.json` and `previous/artifacts/**` are reserved for prepared previous-session state
|
- `previous/manifest.json` and `previous/artifacts/**` are reserved for previous-session cache state materialized by `prepare` or `restore`
|
||||||
- Run-local stage sandbox layout under `runs/{run_id}/{stage}/`
|
- Run-local stage sandbox layout under `runs/{run_id}/{stage}/`
|
||||||
- Session lock acquisition/release (`.lock`)
|
- Session lock acquisition/release (`.lock`)
|
||||||
|
|
||||||
Does not own:
|
Does not own:
|
||||||
- Stage business logic
|
- Stage business logic
|
||||||
- Remote archive semantics (documented in `stage-archive.md`)
|
- Remote publish semantics (documented in `stage-publish.md`)
|
||||||
- CLI argument parsing
|
- CLI argument parsing
|
||||||
|
|
||||||
## Config Fields Used
|
## Config Fields Used
|
||||||
- `pipeline.workspace.root`
|
- `pipeline.workspace.root`
|
||||||
- `pipeline.workspace.cleanup_after_archive`
|
- `pipeline.workspace.cleanup_after_publish`
|
||||||
- `pipeline.spool.root`
|
- `pipeline.spool.root`
|
||||||
- `pipeline.spool.delete_audio_after_archive`
|
- `pipeline.spool.delete_audio_after_publish`
|
||||||
- `pipeline.cache.root`
|
- `pipeline.cache.root`
|
||||||
- `pipeline.cache.s3_audio`
|
- `pipeline.cache.s3_audio`
|
||||||
- `session.campaign`
|
- `session.campaign`
|
||||||
@@ -43,11 +43,12 @@ None directly in this subsystem. Stages may use object storage adapters and then
|
|||||||
## State and Manifest Behavior
|
## State and Manifest Behavior
|
||||||
- Session state is persisted in the session manifest (`manifest.Manifest`).
|
- Session state is persisted in the session manifest (`manifest.Manifest`).
|
||||||
- Invocation history is persisted per run in run manifests under `runs/{run_id}/manifest.json`.
|
- Invocation history is persisted per run in run manifests under `runs/{run_id}/manifest.json`.
|
||||||
- During each run, stage outputs are often written run-local first (`runs/{run_id}/{stage}/outputs/...`) and promoted to canonical session paths after stage success.
|
- During each run, stage outputs are often written run-local first (`runs/{run_id}/{stage}/outputs/...`) and then materialized to canonical session paths after stage success.
|
||||||
- `manifest.Artifacts` entries record `ProducerRunID` for durable outputs.
|
- `manifest.Artifacts` entries record `ProducerRunID` for durable outputs.
|
||||||
- For S3 audio sessions, `prepare` records work/cache paths, S3 provenance, and spool path when the invocation downloaded the object.
|
- For S3 audio sessions, `prepare` records work/cache paths, S3 provenance, and spool path when the invocation downloaded the object.
|
||||||
|
- `previous/**` is reconstructed from configured previous-session requirements; restore uses the previous session's committed current publish state rather than treating current-session stored `previous/**` as authoritative.
|
||||||
- Durable cache state under `pipeline.cache.root` is not workspace state and is preserved by default by `narratio clean`.
|
- Durable cache state under `pipeline.cache.root` is not workspace state and is preserved by default by `narratio clean`.
|
||||||
- `narratio clean --session-id <id>` removes the session work root and session spool root.
|
- `narratio clean <id>` removes the session work root and session spool root.
|
||||||
- `narratio clean --all` removes all local session work under `workspace.root/work` and spool children under `spool.root`.
|
- `narratio clean --all` removes all local session work under `workspace.root/work` and spool children under `spool.root`.
|
||||||
- `narratio clean --clear-cache` is the explicit opt-in for deleting matching S3 audio cache entries.
|
- `narratio clean --clear-cache` is the explicit opt-in for deleting matching S3 audio cache entries.
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ None directly in this subsystem. Stages may use object storage adapters and then
|
|||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
- Failures preserve manifests and run-local files for inspection.
|
- Failures preserve manifests and run-local files for inspection.
|
||||||
- Lock conflicts fail fast via `ErrLockConflict`.
|
- Lock conflicts fail fast via `ErrLockConflict`.
|
||||||
- Cleanup can fail post-archive; failure is recorded in archive stage metadata and returned by the run.
|
- Cleanup can fail post-publish; failure is recorded in publish stage metadata and returned by the run.
|
||||||
|
|
||||||
## Tests to Inspect Before Changing
|
## Tests to Inspect Before Changing
|
||||||
- `internal/artifacts/local_test.go`
|
- `internal/artifacts/local_test.go`
|
||||||
@@ -71,7 +72,7 @@ None directly in this subsystem. Stages may use object storage adapters and then
|
|||||||
## Architectural Invariants
|
## Architectural Invariants
|
||||||
- Session root is campaign-aware: `{workspace.root}/work/{campaign}/{session_id}`.
|
- Session root is campaign-aware: `{workspace.root}/work/{campaign}/{session_id}`.
|
||||||
- Run roots are always nested: `runs/{run_id}` under the session root.
|
- Run roots are always nested: `runs/{run_id}` under the session root.
|
||||||
- Run-local output promotion must end in canonical session paths.
|
- Run-local output materialization must end in canonical session paths.
|
||||||
- `previous/**` is session-durable state and must not be treated as run-local output scratch state.
|
- `previous/**` is session-durable state and must not be treated as run-local output scratch state.
|
||||||
- Automatic post-archive cleanup only targets run-scoped directories and must never delete configured root directories.
|
- Automatic post-publish cleanup only targets run-scoped directories and must never delete configured root directories.
|
||||||
- Manual `clean` may delete session-scoped directories or the `workspace.root/work` directory, but it must preserve configured root directories and reject unsafe targets.
|
- Manual `clean` may delete session-scoped directories or the `workspace.root/work` directory, but it must preserve configured root directories and reject unsafe targets.
|
||||||
|
|||||||
@@ -1,279 +1,170 @@
|
|||||||
# Operations
|
# Operations
|
||||||
|
|
||||||
This guide describes the implemented operator lifecycle for Narratio.
|
This guide covers the implemented operator lifecycle for Narratio.
|
||||||
|
|
||||||
For field-level configuration, see [docs/config.md](./config.md). For full command/flag reference, see [docs/cli.md](./cli.md).
|
For field-level settings, see [docs/config.md](./config.md). For syntax/flags, see [docs/cli.md](./cli.md).
|
||||||
|
|
||||||
## Normal workflow (S3-first path)
|
## Normal Workflow
|
||||||
|
|
||||||
1. Create or upload `session.yml`, or pass a local `session.yml` explicitly.
|
1. Ensure `pipeline.yml`, `campaign.yml`, and `session.yml` are available.
|
||||||
2. Upload session `.flac` files to object storage under the configured session audio prefix.
|
2. Ensure session audio is available (local `audio_dir`/`audio_files` or S3 prefix).
|
||||||
3. Run Narratio:
|
3. Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio run --session-id 2026-04-04
|
narratio run 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Read success output:
|
4. Inspect status:
|
||||||
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
|
|
||||||
- use `manifest=<path>` with `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`.
|
|
||||||
- S3 audio mode requires `session.inputs.audio_s3.prefix` and valid object-store access.
|
|
||||||
|
|
||||||
Initialize a remote session skeleton:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio session init --config /etc/narratio/pipeline.yml --campaign /etc/narratio/campaign.yml --session-id 2026-04-04 --remote
|
narratio session status 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
Remote init writes `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/session.yml`. It fails if the object already exists unless `--force` is passed.
|
## Publish Workflow
|
||||||
|
|
||||||
Validate before running:
|
Publish is the stage that commits remote current state.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio session validate --session-id 2026-04-04
|
narratio publish 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
## Restore workflow
|
Equivalent command:
|
||||||
|
|
||||||
Use restore when local durable session state is missing or stale and archive current state is authoritative.
|
|
||||||
|
|
||||||
Dry-run (no local writes):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio restore --session-id 2026-04-04 --dry-run
|
narratio run-stage publish 2026-04-04 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
Execution:
|
Publish uploads:
|
||||||
|
|
||||||
```bash
|
- run history files under `{session_prefix}/runs/{run_id}/` (excluding `audio/`)
|
||||||
narratio restore --session-id 2026-04-04
|
- configured published outputs from `pipeline.publish.outputs`
|
||||||
```
|
- `previous/**` cache files when present
|
||||||
|
- `current/manifest.json`
|
||||||
Post-restore analyze rerun pattern:
|
- `current/run_id.txt` last
|
||||||
|
|
||||||
```bash
|
|
||||||
narratio analyze --session-id 2026-04-04
|
|
||||||
```
|
|
||||||
|
|
||||||
Restore source-of-truth:
|
|
||||||
- remote commit marker: `current/run_id.txt`
|
|
||||||
- remote current manifest: `current/manifest.json`
|
|
||||||
|
|
||||||
Restore default scope:
|
|
||||||
- includes `manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`
|
|
||||||
- includes `audio/**` only with `--include-audio`
|
|
||||||
- excludes `runs/**`, `logs/**`, `reports/**`, `config/**`, `inputs/**`, and `current/**` (except remote `current/manifest.json` as source)
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
`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.
|
|
||||||
|
|
||||||
## Local filesystem layout and state artifacts
|
|
||||||
|
|
||||||
Session root:
|
|
||||||
- `{workspace.root}/work/{campaign}/{session_id}/`
|
|
||||||
|
|
||||||
Primary state:
|
|
||||||
- `manifest.json`: session-level stage state.
|
|
||||||
- `runs/{run_id}/manifest.json`: invocation-level state.
|
|
||||||
- `.lock`: session lock while a modifying command is active.
|
|
||||||
- `inputs/campaign.yml`, `inputs/session.yml`, and `inputs/pipeline.resolved.yml`: materialized config inputs for the run.
|
|
||||||
|
|
||||||
Canonical session directories:
|
|
||||||
- `inputs/`
|
|
||||||
- `audio/`
|
|
||||||
- `transcripts/`
|
|
||||||
- `artifacts/`
|
|
||||||
- `previous/`
|
|
||||||
- `reports/`
|
|
||||||
- `logs/`
|
|
||||||
- `config/`
|
|
||||||
- `current/`
|
|
||||||
- `runs/`
|
|
||||||
|
|
||||||
Run-local stage directories:
|
|
||||||
- `runs/{run_id}/{stage}/` with stage-local `outputs/`, `logs/`, `reports/`, `config/`, `scratch/`.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- directory creation is idempotent.
|
|
||||||
- stage outputs are generally generated run-local first, then promoted to canonical paths on success.
|
|
||||||
- restore installs downloaded files to canonical session paths and does not recreate historical run sandboxes.
|
|
||||||
|
|
||||||
## Analyze artifact execution lifecycle
|
|
||||||
|
|
||||||
Analyze executes configured artifacts from `pipeline.scriptorium.artifacts`.
|
|
||||||
|
|
||||||
Execution model:
|
|
||||||
- executable set = enabled artifacts, filtered by `--artifacts` when provided.
|
|
||||||
- artifact-to-artifact dependencies are declared via `depends_on`.
|
|
||||||
- selected artifacts run in deterministic dependency order.
|
|
||||||
- after each successful artifact run, output is promoted to configured canonical `output_path`.
|
|
||||||
|
|
||||||
Configured artifact source reuse:
|
|
||||||
- a non-executable configured artifact can satisfy inputs if its configured output file already exists and is valid.
|
|
||||||
- reused configured artifact provenance is `filesystem.disabled_artifact_output`.
|
|
||||||
|
|
||||||
`--artifacts` behavior:
|
|
||||||
- accepted on `run`, `resume`, `run-stage analyze`, and `analyze`.
|
|
||||||
- filters analyze execution only.
|
|
||||||
- does not imply force on `run`, `resume`, or `run-stage`; `narratio analyze` is force-by-design.
|
|
||||||
|
|
||||||
Canonical previous-session input behavior:
|
|
||||||
- canonical sources use `narratio.previous_session.artifact.<artifact_key>`.
|
|
||||||
- these inputs are hydrated by `prepare`, not `analyze`.
|
|
||||||
- if analyze fails due to missing canonical previous cache, rerun:
|
|
||||||
- `narratio run-stage --session-id <id> --force prepare`
|
|
||||||
|
|
||||||
## Remote archive layout and publish contract
|
|
||||||
|
|
||||||
When archive is enabled and run upload is enabled, archive publishes under:
|
|
||||||
|
|
||||||
- session prefix: `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/`
|
|
||||||
- run prefix: `{session_prefix}/runs/{run_id}/`
|
|
||||||
|
|
||||||
Archive uploads:
|
|
||||||
- run record files from run root (excluding `audio/`).
|
|
||||||
- promoted files from explicit `archive.promote_artifacts` rules.
|
|
||||||
- mutable session locks from helper commands live at `{session_prefix}/locks.yml`.
|
|
||||||
|
|
||||||
Publish order:
|
|
||||||
1. upload `current/manifest.json`
|
|
||||||
2. upload `current/run_id.txt` last
|
|
||||||
|
|
||||||
`current/run_id.txt` is the remote commit marker.
|
`current/run_id.txt` is the remote commit marker.
|
||||||
|
|
||||||
Archive promotion is explicit and source-based:
|
## Published Outputs and Locks
|
||||||
- Narratio does not auto-promote all generated analyze artifacts.
|
|
||||||
- each rule resolves `source` through the artifact resolver/catalog model, then uploads to `dest`.
|
|
||||||
- missing required promotion sources fail archive stage.
|
|
||||||
- missing optional promotion sources are skipped.
|
|
||||||
- invalid resolved artifacts fail archive stage.
|
|
||||||
- `archive.locks` skips top-level promotion overwrites for locked sources while run-local uploads still publish.
|
|
||||||
- remote locks from `{session_prefix}/locks.yml` are merged with static `archive.locks`; static locks win on duplicate sources.
|
|
||||||
- locked required promotions are treated as intentional successful skips and are recorded in archive metadata.
|
|
||||||
|
|
||||||
Lock helper behavior:
|
Published output behavior:
|
||||||
- `narratio locks --session-id <id>` lists effective static and remote locks.
|
|
||||||
- `narratio locks add --session-id <id> --reason <text> <source>` writes a remote lock.
|
|
||||||
- `narratio locks add --session-id <id> --force --reason <text> <source>` updates an existing remote lock reason.
|
|
||||||
- `narratio locks remove --session-id <id> <source>` removes only a remote lock.
|
|
||||||
- `locks remove` cannot remove static pipeline locks.
|
|
||||||
- remote lock writes check whether the lock store exists, but are not compare-and-swap atomic.
|
|
||||||
|
|
||||||
## Resume, retry, restore, and safe rerun behavior
|
- outputs are source-based rules in `pipeline.publish.outputs`.
|
||||||
|
- required missing unlocked sources fail publish.
|
||||||
|
- optional missing unlocked sources are skipped.
|
||||||
|
- selected artifacts (`--artifacts`) only filter configured `narratio.artifact.<key>` output rules.
|
||||||
|
- built-in transcript and bounds output rules are not filtered by `--artifacts`.
|
||||||
|
|
||||||
Default skip:
|
Lock behavior:
|
||||||
- `run` and `run-stage` skip already-succeeded stages unless `--force` is set.
|
|
||||||
|
|
||||||
Resume:
|
- static locks: `pipeline.publish.locks`.
|
||||||
- `resume` starts at first non-succeeded stage.
|
- mutable locks: `{session_prefix}/locks.yml`.
|
||||||
- `resume --force` runs full stage order.
|
- effective lock set is static + mutable; static wins on duplicate sources.
|
||||||
|
- locked outputs are intentional skips and do not fail publish.
|
||||||
|
- lock commands mutate only remote mutable locks.
|
||||||
|
|
||||||
Restore conflict policy:
|
## Restore Workflow
|
||||||
- restore classifies local differences as conflicts.
|
|
||||||
- without `--force`, restore fails when conflicts exist.
|
|
||||||
- with `--force`, conflicting local files are overwritten by remote archive files.
|
|
||||||
|
|
||||||
Forced reruns:
|
Use restore when local durable session state is missing/stale and committed remote current state is authoritative.
|
||||||
|
|
||||||
|
Preview:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session restore 2026-04-04 --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session restore 2026-04-04
|
||||||
|
```
|
||||||
|
|
||||||
|
Default restore scope:
|
||||||
|
|
||||||
|
- `manifest.json`
|
||||||
|
- `transcripts/**`
|
||||||
|
- `artifacts/**`
|
||||||
|
- `previous/**` when required by configured previous-session artifact inputs
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- add `--include-audio` to restore `audio/**`.
|
||||||
|
|
||||||
|
Restore reads committed current state only (`current/run_id.txt`, `current/manifest.json`).
|
||||||
|
|
||||||
|
## Workspace and State Layout
|
||||||
|
|
||||||
|
Session root:
|
||||||
|
|
||||||
|
- `{workspace.root}/work/{campaign}/{session_id}/`
|
||||||
|
|
||||||
|
Durable session state:
|
||||||
|
|
||||||
|
- `manifest.json`
|
||||||
|
- `inputs/**`
|
||||||
|
- `audio/**`
|
||||||
|
- `transcripts/**`
|
||||||
|
- `artifacts/**`
|
||||||
|
- `previous/**`
|
||||||
|
- `reports/**`
|
||||||
|
- `logs/**`
|
||||||
|
- `config/**`
|
||||||
|
- `runs/**`
|
||||||
|
|
||||||
|
Run-local stage layout:
|
||||||
|
|
||||||
|
- `runs/{run_id}/{stage}/outputs|logs|reports|config|scratch`
|
||||||
|
|
||||||
|
Stages typically write run-local outputs first, then materialize canonical session outputs on success.
|
||||||
|
|
||||||
|
## Resume and Force Rules
|
||||||
|
|
||||||
|
- `run` and `run-stage` skip succeeded stages unless `--force` is set.
|
||||||
|
- `resume` starts at the first non-succeeded stage.
|
||||||
- force-rerunning an upstream succeeded stage marks downstream succeeded stages as `stale`.
|
- force-rerunning an upstream succeeded stage marks downstream succeeded stages as `stale`.
|
||||||
- ordinary `--force` does not override archive locks.
|
- `--force` does not bypass publish locks.
|
||||||
|
|
||||||
Safe rerun pattern:
|
## Cleanup
|
||||||
1. rerun the changed stage with `--force`.
|
|
||||||
2. run `resume` to rebuild downstream stages.
|
|
||||||
|
|
||||||
## Cleanup behavior
|
Automatic post-publish cleanup is considered only when publish executes successfully and commits current state.
|
||||||
|
|
||||||
Automatic post-archive cleanup is considered only when archive stage executed and succeeded.
|
Config toggles:
|
||||||
|
|
||||||
Automatic cleanup toggles:
|
- `pipeline.spool.delete_audio_after_publish=true`
|
||||||
- `pipeline.spool.delete_audio_after_archive=true` deletes run-scoped spool audio.
|
- `pipeline.workspace.cleanup_after_publish=true`
|
||||||
- `pipeline.workspace.cleanup_after_archive=true` deletes run-scoped local run directory.
|
|
||||||
|
|
||||||
Manual cleanup:
|
Manual cleanup:
|
||||||
- `narratio clean --session-id <id>` deletes `{workspace.root}/work/{campaign}/{session_id}` and `{spool.root}/{campaign}/{session_id}`.
|
|
||||||
- `narratio clean --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.
|
|
||||||
|
|
||||||
The S3 audio cache under `pipeline.cache.root` is durable input cache state, not workspace or spool state. Automatic cleanup and default manual cleanup do not delete it.
|
|
||||||
|
|
||||||
Cleanup eligibility gates:
|
|
||||||
- archive enabled
|
|
||||||
- archive run upload enabled
|
|
||||||
- run record upload completed
|
|
||||||
- current pointer write completed (`current/run_id.txt` written)
|
|
||||||
|
|
||||||
No cleanup for failed/incomplete/unarchived/archive-skipped runs.
|
|
||||||
|
|
||||||
## Failure and recovery playbooks
|
|
||||||
|
|
||||||
After run failure, Narratio keeps:
|
|
||||||
- session manifest
|
|
||||||
- run manifest
|
|
||||||
- run-local artifacts/logs/config/reports
|
|
||||||
|
|
||||||
Failed or incomplete runs remain local-only.
|
|
||||||
|
|
||||||
After restore failure:
|
|
||||||
- already-installed restore files remain in place.
|
|
||||||
- restore does not roll back prior successful installs.
|
|
||||||
- existing local manifest is preserved if restored manifest validation/install fails.
|
|
||||||
|
|
||||||
Recommended recovery:
|
|
||||||
|
|
||||||
1. inspect state:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio status --session-id 2026-04-04
|
narratio clean 2026-04-04
|
||||||
|
narratio clean --all
|
||||||
```
|
```
|
||||||
|
|
||||||
This reports local manifest state, committed remote current state, expected remote transcript/artifact availability, and archive locks.
|
Cache is preserved by default. Use `--clear-cache` to remove matching S3 audio cache entries.
|
||||||
|
|
||||||
2. for one manifest file, run:
|
## Failure and Recovery
|
||||||
|
|
||||||
|
After stage failure, Narratio keeps manifests and run-local files for inspection.
|
||||||
|
|
||||||
|
Standard recovery flow:
|
||||||
|
|
||||||
|
1. inspect status:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio status --manifest <manifest-path>
|
narratio session status 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
3. for restore-specific checks, run:
|
2. if needed, inspect restore plan:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio restore --session-id 2026-04-04 --dry-run
|
narratio session restore 2026-04-04 --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
4. fix root cause (config/input/credentials/storage/service availability).
|
3. fix root cause.
|
||||||
5. continue with `resume`, or targeted `run-stage --force` followed by `resume`.
|
4. continue with `resume`, or rerun a stage with `--force` then `resume`.
|
||||||
|
|
||||||
## Restore report
|
## Operational Caveats
|
||||||
|
|
||||||
Non-dry-run restore writes a durable report at:
|
- local and S3 audio modes are mutually exclusive.
|
||||||
- `reports/restore-latest.json`
|
- publish requires prerequisite stages through `analyze` to be `succeeded`.
|
||||||
|
- restore requires configured object storage and committed current state.
|
||||||
Report content includes:
|
- `session status` and `session artifacts --remote` both report remote published-output availability when storage is configured.
|
||||||
- identity (`campaign`, `session_id`, `run_id`)
|
|
||||||
- mode flags (`dry_run`, `force`, `include_audio`)
|
|
||||||
- plan counts and execution counts
|
|
||||||
- per-action status
|
|
||||||
|
|
||||||
Dry-run does not write restore report files.
|
|
||||||
|
|
||||||
## Operational caveats
|
|
||||||
|
|
||||||
- `status` with no config/session flags still requires explicit `--manifest`.
|
|
||||||
- `status --session-id <id>` uses normal config/session loading, including remote session fallback.
|
|
||||||
- `status --session-id <id>` includes the same promoted remote output availability view as `artifacts list --remote` when storage is configured.
|
|
||||||
- local and S3 audio input modes are mutually exclusive.
|
|
||||||
- archive publish requires upstream stages through `analyze` to be `succeeded`.
|
|
||||||
- required promotion rules can fail when selected analyze artifacts did not generate a required file path.
|
|
||||||
- restore requires configured remote object storage and committed remote current state.
|
|
||||||
|
|||||||
231
docs/roadmap/campaign.md
Normal file
231
docs/roadmap/campaign.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# Roadmap: Campaign Registry
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Narratio currently treats campaign configuration as one selected
|
||||||
|
`campaign.yml` file:
|
||||||
|
|
||||||
|
- command flags use `--campaign <path>`;
|
||||||
|
- default discovery searches fixed system file locations;
|
||||||
|
- `campaign.yml` uses `campaign:` as the identity field.
|
||||||
|
|
||||||
|
That model works for a single campaign, but it is awkward for installations
|
||||||
|
that manage multiple campaigns. Operators need to pass file paths or maintain a
|
||||||
|
single global campaign config, while the newer session-oriented CLI already
|
||||||
|
uses concise positional session IDs and remote session lookup.
|
||||||
|
|
||||||
|
The campaign selection model should become ID-based and pipeline-owned.
|
||||||
|
Pipeline config should describe where campaigns live, commands should select a
|
||||||
|
campaign by ID, and each campaign directory should contain its stable campaign
|
||||||
|
materials.
|
||||||
|
|
||||||
|
## Target Model
|
||||||
|
|
||||||
|
`pipeline.yml` owns the campaign registry:
|
||||||
|
|
||||||
|
campaigns:
|
||||||
|
root: /usr/local/share/narratio/campaigns
|
||||||
|
default_campaign_id: dilfs
|
||||||
|
|
||||||
|
Campaign files live at the conventional path:
|
||||||
|
|
||||||
|
{campaigns.root}/{campaign_id}/campaign.yml
|
||||||
|
|
||||||
|
The first implementation should use only the conventional path. Recursive
|
||||||
|
discovery of every `campaign.yml` under `campaigns.root` is deferred to a
|
||||||
|
future stage.
|
||||||
|
|
||||||
|
Each campaign file uses `campaign_id` as the canonical identity field:
|
||||||
|
|
||||||
|
campaign_id: dilfs
|
||||||
|
session_template_file: ./session.template.yml
|
||||||
|
inputs:
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
|
||||||
|
Campaign-relative files continue to resolve relative to the selected
|
||||||
|
`campaign.yml`, including stable input files and `session_template_file`.
|
||||||
|
|
||||||
|
The public CLI changes from path-based campaign selection to ID-based campaign
|
||||||
|
selection:
|
||||||
|
|
||||||
|
- `--campaign <id>` selects a campaign ID.
|
||||||
|
- `--campaign-file <path>` explicitly loads one campaign file for
|
||||||
|
development, tests, and unusual local workflows.
|
||||||
|
- `--campaign` and `--campaign-file` are mutually exclusive.
|
||||||
|
|
||||||
|
If neither `--campaign` nor `--campaign-file` is passed, Narratio uses
|
||||||
|
`pipeline.campaigns.default_campaign_id`. If no campaign can be selected,
|
||||||
|
commands fail clearly before session loading or stage execution.
|
||||||
|
|
||||||
|
Resolved campaign ID remains the campaign segment used for:
|
||||||
|
|
||||||
|
- workspace paths;
|
||||||
|
- spool paths;
|
||||||
|
- S3 session prefixes;
|
||||||
|
- remote `session.yml` lookup;
|
||||||
|
- archive locks and promoted output keys;
|
||||||
|
- session/campaign mismatch validation;
|
||||||
|
- status, plan, restore, and helper output.
|
||||||
|
|
||||||
|
## Compatibility Policy
|
||||||
|
|
||||||
|
This is a breaking public/config contract change.
|
||||||
|
|
||||||
|
After the cutover:
|
||||||
|
|
||||||
|
- `--campaign` no longer accepts a filesystem path;
|
||||||
|
- default fixed campaign file discovery is removed;
|
||||||
|
- `campaign:` is no longer accepted in `campaign.yml`;
|
||||||
|
- `campaign_id:` is required.
|
||||||
|
|
||||||
|
Keep `--campaign-file` as the only explicit file override. Do not retain hidden
|
||||||
|
aliases for the old `--campaign <path>` behavior.
|
||||||
|
|
||||||
|
## Implementation Stages
|
||||||
|
|
||||||
|
### Stage 1: Add Campaign Registry Selection
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
Add the registry model and switch command loading to resolve campaigns through
|
||||||
|
pipeline config.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Add `pipeline.campaigns.root`.
|
||||||
|
- Add `pipeline.campaigns.default_campaign_id`.
|
||||||
|
- Add `campaign_id` to campaign config and make it the canonical identity.
|
||||||
|
- Resolve pipeline config first, then campaign selection.
|
||||||
|
- Use this selection order:
|
||||||
|
1. explicit `--campaign-file <path>`;
|
||||||
|
2. explicit `--campaign <id>`;
|
||||||
|
3. `pipeline.campaigns.default_campaign_id`;
|
||||||
|
4. fail clearly.
|
||||||
|
- For ID selection, load `{campaigns.root}/{campaign_id}/campaign.yml`.
|
||||||
|
- Validate that the loaded `campaign_id` matches the selected ID.
|
||||||
|
- Reject `--campaign` with `--campaign-file`.
|
||||||
|
- Preserve strict YAML decoding.
|
||||||
|
- Preserve campaign-relative stable input and session template resolution.
|
||||||
|
- Keep storage details behind the existing storage adapter and object-store
|
||||||
|
helper.
|
||||||
|
- Keep remote session lookup and archive key construction based on the
|
||||||
|
resolved campaign ID.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Commands can run with only a pipeline config and the pipeline default
|
||||||
|
campaign ID.
|
||||||
|
- Commands can select another campaign with `--campaign <id>`.
|
||||||
|
- Commands can load a specific file with `--campaign-file <path>`.
|
||||||
|
- Existing session loading, remote session fallback, prepare materialization,
|
||||||
|
restore, archive, locks, clean, analyze, and publish behavior continue to use
|
||||||
|
the same resolved campaign identity.
|
||||||
|
- No generic config registry framework is introduced.
|
||||||
|
|
||||||
|
### Stage 2: Remove Old Single-File Campaign Behavior
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
Remove the old public campaign file model after registry selection is in
|
||||||
|
place.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Remove fixed default campaign config discovery from command loading.
|
||||||
|
- Remove `DefaultCampaignConfigSearchPaths` and related path-only resolution if
|
||||||
|
no current tests or helpers still need them.
|
||||||
|
- Remove support for `campaign:` from `campaign.yml`.
|
||||||
|
- Update validation errors to refer to `campaign_id`.
|
||||||
|
- Update examples to use campaign directories and `campaign_id`.
|
||||||
|
- Update current-behavior docs to document:
|
||||||
|
- `pipeline.campaigns.root`;
|
||||||
|
- `pipeline.campaigns.default_campaign_id`;
|
||||||
|
- `campaign_id`;
|
||||||
|
- `--campaign <id>`;
|
||||||
|
- `--campaign-file <path>`.
|
||||||
|
- Update troubleshooting examples that currently pass `--campaign <path>`.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `campaign.yml` files with `campaign:` fail strict decoding.
|
||||||
|
- `--campaign /path/to/campaign.yml` is treated as a campaign ID and fails
|
||||||
|
unless that ID exists under `campaigns.root`.
|
||||||
|
- `--campaign-file /path/to/campaign.yml` is the supported file override.
|
||||||
|
- User-facing docs no longer describe fixed campaign config discovery.
|
||||||
|
|
||||||
|
## Test Guidance
|
||||||
|
|
||||||
|
Focused tests:
|
||||||
|
|
||||||
|
- `go test ./internal/config -v`
|
||||||
|
- `go test ./internal/app -v`
|
||||||
|
- `go test ./internal/stage -run Prepare -v`
|
||||||
|
|
||||||
|
Full validation:
|
||||||
|
|
||||||
|
- `go test ./...`
|
||||||
|
|
||||||
|
Config tests to add or update:
|
||||||
|
|
||||||
|
- strict decode accepts `pipeline.campaigns.root`;
|
||||||
|
- strict decode accepts `pipeline.campaigns.default_campaign_id`;
|
||||||
|
- strict decode accepts `campaign_id`;
|
||||||
|
- selected campaign ID mismatch fails;
|
||||||
|
- missing campaign root fails when ID selection is needed;
|
||||||
|
- missing default campaign ID fails when no explicit campaign selector is
|
||||||
|
passed;
|
||||||
|
- old `campaign:` fails after Stage 2.
|
||||||
|
|
||||||
|
App tests to add or update:
|
||||||
|
|
||||||
|
- `--campaign <id>` resolves `{campaigns.root}/{id}/campaign.yml`;
|
||||||
|
- omitted `--campaign` uses `pipeline.campaigns.default_campaign_id`;
|
||||||
|
- `--campaign-file` loads an explicit campaign file;
|
||||||
|
- `--campaign` plus `--campaign-file` fails;
|
||||||
|
- remote session fallback uses the resolved campaign ID;
|
||||||
|
- `session init`, `run`, `run-stage`, `resume`, `analyze`, `publish`, `clean`,
|
||||||
|
and `session` subcommands all use the same campaign selection path;
|
||||||
|
- path-based `--campaign` examples and tests are removed after Stage 2.
|
||||||
|
|
||||||
|
## Documentation Guidance
|
||||||
|
|
||||||
|
Update current-behavior docs only after implementation lands:
|
||||||
|
|
||||||
|
- `docs/config.md`
|
||||||
|
- `docs/cli.md`
|
||||||
|
- `docs/operations.md`
|
||||||
|
- `docs/troubleshooting.md`
|
||||||
|
- relevant files under `docs/internal/`
|
||||||
|
- `examples/`
|
||||||
|
|
||||||
|
Planned campaign registry behavior belongs only in this roadmap until the code,
|
||||||
|
tests, examples, and current-behavior docs are updated.
|
||||||
|
|
||||||
|
## Architecture Guardrails
|
||||||
|
|
||||||
|
- Keep Narratio explicit and stage-driven.
|
||||||
|
- Do not introduce a generic configuration registry or workflow framework.
|
||||||
|
- Keep YAML decoding strict.
|
||||||
|
- Keep defaults centralized and testable.
|
||||||
|
- Keep campaign-relative path resolution centralized.
|
||||||
|
- Use centralized S3 and workspace path helpers.
|
||||||
|
- Keep storage details behind `storage.ObjectStore`.
|
||||||
|
- Keep secret-backed object-store construction in `internal/app`.
|
||||||
|
- Preserve manifest-driven resume and restore behavior.
|
||||||
|
- Do not store raw secrets in campaign configs, manifests, logs, generated
|
||||||
|
configs, or archive metadata.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The canonical pipeline schema is grouped under `campaigns`.
|
||||||
|
- The canonical campaign identity field is `campaign_id`.
|
||||||
|
- `--campaign` means campaign ID.
|
||||||
|
- `--campaign-file` is retained as an explicit override.
|
||||||
|
- Recursive discovery is planned but not part of the first implementation.
|
||||||
|
- Existing production configs can be migrated from `campaign:` to
|
||||||
|
`campaign_id:` and from `--campaign <path>` to `--campaign <id>` or
|
||||||
|
`--campaign-file <path>`.
|
||||||
159
docs/roadmap/cleanup.md
Normal file
159
docs/roadmap/cleanup.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# Roadmap: Legacy Config Cleanup
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Narratio's current pipeline config schema still accepts fields that predate the current storage, artifact, and previous-session models:
|
||||||
|
|
||||||
|
- `pipeline.storage.bucket`
|
||||||
|
- `pipeline.storage.prefix`
|
||||||
|
- `pipeline.analyzer.*`
|
||||||
|
- `previous_session_artifact`
|
||||||
|
|
||||||
|
These names make the config reference harder to trust because they suggest supported behavior that operators should no longer use. The modern interface is:
|
||||||
|
|
||||||
|
- `pipeline.storage.s3.*` for remote storage.
|
||||||
|
- Scriptorium configured artifacts under `pipeline.scriptorium.artifacts`.
|
||||||
|
- Canonical artifact source IDs such as `narratio.artifact.<configured_artifact_key>`.
|
||||||
|
- Canonical previous-session artifact sources such as `narratio.previous_session.artifact.<configured_artifact_key>`.
|
||||||
|
|
||||||
|
Strict YAML decoding should reject removed legacy fields once this cleanup lands.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
`pipeline.storage.bucket` and `pipeline.storage.prefix` were inert compatibility fields and have been removed:
|
||||||
|
|
||||||
|
- They are no longer present on `config.StorageConfig`.
|
||||||
|
- Strict decoding rejects them.
|
||||||
|
- Runtime S3 behavior uses `pipeline.storage.s3.bucket` and `pipeline.storage.s3.root_prefix`.
|
||||||
|
- No current code reads the top-level storage bucket or prefix fields.
|
||||||
|
|
||||||
|
`pipeline.analyzer.*` was legacy code surface and has been removed:
|
||||||
|
|
||||||
|
- `config.PipelineConfig` no longer includes analyzer config.
|
||||||
|
- Strict decoding rejects `pipeline.analyzer`.
|
||||||
|
- `stage.Env` no longer exposes an analyzer runner, and `internal/adapters/analyzer` has been deleted.
|
||||||
|
- Modern analyze execution is Scriptorium-backed; the analyzer adapter is not used by current stage execution.
|
||||||
|
|
||||||
|
`previous_session_artifact` was a live legacy behavior and has been removed:
|
||||||
|
|
||||||
|
- Config validation rejects it as an unsupported Scriptorium input source.
|
||||||
|
- The analyze stage no longer has path-based previous-artifact resolution through `inputs.<name>.path`.
|
||||||
|
- Tests cover canonical previous-session sources and the rejection of the legacy source.
|
||||||
|
- The canonical replacement is `narratio.previous_session.artifact.<configured_artifact_key>`, resolved through the previous-session cache/catalog model.
|
||||||
|
|
||||||
|
## Target Model
|
||||||
|
|
||||||
|
The pipeline config schema should expose only current behavior:
|
||||||
|
|
||||||
|
- Remote storage is configured only through `pipeline.storage.s3.*`.
|
||||||
|
- Generated artifacts are configured only through `pipeline.scriptorium.artifacts`.
|
||||||
|
- Scriptorium artifact inputs use canonical source IDs.
|
||||||
|
- Previous-session artifact inputs use `narratio.previous_session.artifact.<configured_artifact_key>`.
|
||||||
|
- Unknown legacy fields fail strict YAML decoding.
|
||||||
|
|
||||||
|
No compatibility aliases should remain unless a future migration requirement explicitly reintroduces them.
|
||||||
|
|
||||||
|
## Cleanup Order
|
||||||
|
|
||||||
|
### Stage 1: Remove Inert Storage Compatibility Fields
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
Remove `pipeline.storage.bucket` and `pipeline.storage.prefix`.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Delete `StorageConfig.Bucket` and `StorageConfig.Prefix`.
|
||||||
|
- Keep `StorageConfig.Backend` and `StorageConfig.S3`.
|
||||||
|
- Confirm all runtime storage paths continue to use `storage.s3.bucket` and `storage.s3.root_prefix`.
|
||||||
|
- Update examples and docs to remove top-level storage `bucket` and `prefix`.
|
||||||
|
- Add or update strict-decode tests proving `pipeline.storage.bucket` and `pipeline.storage.prefix` are rejected.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Existing S3 workflows still pass with `pipeline.storage.s3.bucket`.
|
||||||
|
- Pipeline configs containing top-level `storage.bucket` or `storage.prefix` fail to load.
|
||||||
|
- No docs or examples present those fields as available.
|
||||||
|
|
||||||
|
### Stage 2: Remove Legacy Analyzer Schema and Adapter Surface
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
Remove the unused analyzer configuration and adapter contract.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Delete `PipelineConfig.Analyzer`.
|
||||||
|
- Delete `AnalyzerConfig` and `ArtifactSettings`.
|
||||||
|
- Remove analyzer timeout validation.
|
||||||
|
- Remove `stage.Env.Analyzer`.
|
||||||
|
- Delete `internal/adapters/analyzer` if no remaining code imports it.
|
||||||
|
- Remove `pipeline.analyzer.*` from tests, examples, and docs.
|
||||||
|
- Add or update strict-decode tests proving `pipeline.analyzer` is rejected.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Analyze behavior remains fully Scriptorium-backed.
|
||||||
|
- No runtime code imports `internal/adapters/analyzer`.
|
||||||
|
- Pipeline configs containing `pipeline.analyzer` fail to load.
|
||||||
|
- Contributor and internal adapter docs no longer list the analyzer adapter.
|
||||||
|
|
||||||
|
### Stage 3: Remove Path-Based Previous Session Artifact Source
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
Remove `previous_session_artifact` and require canonical previous-session artifact sources.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Remove `previous_session_artifact` from supported Scriptorium input sources.
|
||||||
|
- Remove analyze-stage special-case handling that resolves `inputs.<name>.path` for previous artifacts.
|
||||||
|
- Keep canonical handling for `narratio.previous_session.artifact.<configured_artifact_key>`.
|
||||||
|
- Rewrite tests that use `previous_session_artifact` to use canonical sources and prepared previous-cache fixtures.
|
||||||
|
- Add validation tests proving `previous_session_artifact` is rejected.
|
||||||
|
- Update docs to remove the legacy path-based source and document only canonical previous-session sources.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `pipeline.scriptorium.artifacts.*.inputs.*.source: previous_session_artifact` fails validation.
|
||||||
|
- Canonical previous-session sources continue to work for required and optional inputs.
|
||||||
|
- Prepare/restore previous-cache behavior remains unchanged.
|
||||||
|
- No docs or examples mention `previous_session_artifact` as supported.
|
||||||
|
|
||||||
|
## Test Guidance
|
||||||
|
|
||||||
|
Run focused tests after each stage:
|
||||||
|
|
||||||
|
- `go test ./internal/config -v`
|
||||||
|
- `go test ./internal/stage -run Analyze -v`
|
||||||
|
- `go test ./internal/app -v`
|
||||||
|
- `go test ./...`
|
||||||
|
|
||||||
|
For Stage 1, focus on config load/strict-decode and S3 workflow regression tests.
|
||||||
|
|
||||||
|
For Stage 2, focus on compile-time removal, config strict-decode tests, and full app/stage tests to catch stale adapter references.
|
||||||
|
|
||||||
|
For Stage 3, focus on Scriptorium config validation, analyze-stage input resolution, previous-cache behavior, and restore/analyze workflows.
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
|
||||||
|
Update current-behavior docs only after the corresponding code removal lands:
|
||||||
|
|
||||||
|
- `docs/config.md`
|
||||||
|
- `docs/cli.md`, only if command behavior text references removed fields.
|
||||||
|
- `docs/operations.md`, only if operator workflow text references removed fields.
|
||||||
|
- `docs/internal/stage-analyze.md`
|
||||||
|
- `docs/internal/adapters.md`
|
||||||
|
- `examples/pipeline.full.annotated.yml`
|
||||||
|
- `examples/pipeline.production.yml`
|
||||||
|
|
||||||
|
Do not preserve removed fields in examples as compatibility notes. The goal is to make strict config behavior and documentation line up.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- This is a hard cleanup; no backward-compatible aliases are retained.
|
||||||
|
- Current production configs can be migrated to `storage.s3.*`, Scriptorium artifacts, and canonical previous-session sources before this lands.
|
||||||
|
- Removing the unused analyzer adapter does not block any active stage behavior.
|
||||||
|
- The cleanup should be implemented in the listed order so inert schema removal is separated from behavior removal.
|
||||||
255
docs/roadmap/cli.md
Normal file
255
docs/roadmap/cli.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
# Roadmap: Session-Oriented CLI Cleanup
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Narratio's public CLI has accumulated too many top-level commands. Several
|
||||||
|
commands are session-scoped operator helpers, but they currently appear as
|
||||||
|
independent top-level verbs:
|
||||||
|
|
||||||
|
- `plan`
|
||||||
|
- `status`
|
||||||
|
- `restore`
|
||||||
|
- `artifacts list`
|
||||||
|
- `locks`
|
||||||
|
- `session validate`
|
||||||
|
- `session init`
|
||||||
|
|
||||||
|
This makes the command surface harder to learn because the CLI does not clearly
|
||||||
|
separate primary workflow actions from session inspection, initialization,
|
||||||
|
restore, and helper operations.
|
||||||
|
|
||||||
|
## Target Model
|
||||||
|
|
||||||
|
Keep primary workflow commands at top level:
|
||||||
|
|
||||||
|
- `run`
|
||||||
|
- `run-stage`
|
||||||
|
- `resume`
|
||||||
|
- `analyze`
|
||||||
|
- `publish`
|
||||||
|
- `clean`
|
||||||
|
- `session`
|
||||||
|
|
||||||
|
Keep `clean` top-level because it can operate on one session or all local
|
||||||
|
sessions and is a workspace maintenance command, not only a session helper.
|
||||||
|
|
||||||
|
Move session-scoped helper commands under `narratio session` and use positional
|
||||||
|
session identifiers:
|
||||||
|
|
||||||
|
- `narratio session init <session_id> [--remote|--output <path>] [--flags]`
|
||||||
|
- `narratio session validate <session_id> [--flags]`
|
||||||
|
- `narratio session status <session_id> [--flags]`
|
||||||
|
- `narratio session plan <session_id> [--flags]`
|
||||||
|
- `narratio session restore <session_id> [--flags]`
|
||||||
|
- `narratio session artifacts <session_id> [--remote] [--flags]`
|
||||||
|
- `narratio session locks <session_id> [--flags]`
|
||||||
|
- `narratio session locks add <session_id> <source> [--reason <text>] [--force] [--flags]`
|
||||||
|
- `narratio session locks remove <session_id> <source> [--flags]`
|
||||||
|
|
||||||
|
Update top-level workflow commands to use positional session identifiers:
|
||||||
|
|
||||||
|
- `narratio run <session_id> [--flags]`
|
||||||
|
- `narratio resume <session_id> [--flags]`
|
||||||
|
- `narratio analyze <session_id> [--flags]`
|
||||||
|
- `narratio publish <session_id> [--flags]`
|
||||||
|
- `narratio run-stage <stage> <session_id> [--flags]`
|
||||||
|
|
||||||
|
The positional session ID replaces `--session-id` as the primary public
|
||||||
|
interface. Existing `--config`, `--campaign`, `--session`, and
|
||||||
|
`--previous-session-id` flags remain available where they are meaningful.
|
||||||
|
|
||||||
|
## Command Mapping
|
||||||
|
|
||||||
|
| Current command | Target command |
|
||||||
|
| --- | --- |
|
||||||
|
| `narratio run --session-id <id>` | `narratio run <id>` |
|
||||||
|
| `narratio resume --session-id <id>` | `narratio resume <id>` |
|
||||||
|
| `narratio analyze --session-id <id>` | `narratio analyze <id>` |
|
||||||
|
| `narratio publish --session-id <id>` | `narratio publish <id>` |
|
||||||
|
| `narratio run-stage [flags] <stage> --session-id <id>` | `narratio run-stage <stage> <id> [flags]` |
|
||||||
|
| `narratio plan --session-id <id>` | `narratio session plan <id>` |
|
||||||
|
| `narratio status --session-id <id>` | `narratio session status <id>` |
|
||||||
|
| `narratio restore --session-id <id>` | `narratio session restore <id>` |
|
||||||
|
| `narratio artifacts list --session-id <id>` | `narratio session artifacts <id>` |
|
||||||
|
| `narratio locks --session-id <id>` | `narratio session locks <id>` |
|
||||||
|
| `narratio locks add --session-id <id> <source>` | `narratio session locks add <id> <source>` |
|
||||||
|
| `narratio locks remove --session-id <id> <source>` | `narratio session locks remove <id> <source>` |
|
||||||
|
| `narratio session validate --session-id <id>` | `narratio session validate <id>` |
|
||||||
|
| `narratio session init --session-id <id>` | `narratio session init <id>` |
|
||||||
|
| `narratio clean --session-id <id>` | `narratio clean <id>` |
|
||||||
|
| `narratio clean --all` | unchanged |
|
||||||
|
|
||||||
|
`clean` remains top-level, but its session-scoped form should also move from
|
||||||
|
`--session-id` to positional `<session_id>` for consistency.
|
||||||
|
|
||||||
|
## Compatibility Policy
|
||||||
|
|
||||||
|
This is a hard public CLI cleanup after the migration step lands.
|
||||||
|
|
||||||
|
During Step 1, old forms may remain as compatibility aliases to keep the
|
||||||
|
implementation reviewable. During Step 2, remove the old forms from command
|
||||||
|
dispatch, tests, docs, and examples:
|
||||||
|
|
||||||
|
- remove top-level `plan`;
|
||||||
|
- remove top-level `status`;
|
||||||
|
- remove top-level `restore`;
|
||||||
|
- remove top-level `artifacts`;
|
||||||
|
- remove top-level `locks`;
|
||||||
|
- remove `--session-id` from the public command syntax for session-aware
|
||||||
|
commands.
|
||||||
|
|
||||||
|
Do not keep long-term deprecated aliases unless a later roadmap explicitly
|
||||||
|
chooses a compatibility window.
|
||||||
|
|
||||||
|
`status --manifest` does not fit the session-oriented command shape. Remove it
|
||||||
|
from the public CLI in this cleanup. If direct manifest inspection is needed
|
||||||
|
later, add a separate diagnostic command in a future roadmap rather than keeping
|
||||||
|
it as a special case in `session status`.
|
||||||
|
|
||||||
|
## Implementation Step 1: Add New Session-Oriented Interface
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
Add the target command forms while preserving current behavior internally.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Add positional session ID parsing helpers in `internal/app`.
|
||||||
|
- Keep the existing `loadCommandConfig` behavior and populate
|
||||||
|
`config.SessionLoadOptions.SessionID` from the positional ID.
|
||||||
|
- Add or update command wrappers:
|
||||||
|
- `Run(ctx, args, out)` parses `run <session_id>`.
|
||||||
|
- `Resume(ctx, args, out)` parses `resume <session_id>`.
|
||||||
|
- `Analyze(ctx, args, out)` parses `analyze <session_id>`.
|
||||||
|
- `Publish(ctx, args, out)` parses `publish <session_id>`.
|
||||||
|
- `RunStage(ctx, args, out)` parses `run-stage <stage> <session_id>`.
|
||||||
|
- `Clean(ctx, args, out)` parses `clean <session_id>` and keeps
|
||||||
|
`clean --all`.
|
||||||
|
- Extend `Session(ctx, args, out)` dispatch to support:
|
||||||
|
- `init <session_id>`
|
||||||
|
- `validate <session_id>`
|
||||||
|
- `status <session_id>`
|
||||||
|
- `plan <session_id>`
|
||||||
|
- `restore <session_id>`
|
||||||
|
- `artifacts <session_id>`
|
||||||
|
- `locks <session_id>`
|
||||||
|
- `locks add <session_id> <source>`
|
||||||
|
- `locks remove <session_id> <source>`
|
||||||
|
- Keep storage access through the existing app-level object-store helper.
|
||||||
|
- Keep AWS SDK details behind storage adapters.
|
||||||
|
- Keep the runner, stages, manifest behavior, archive behavior, restore
|
||||||
|
planning, lock semantics, and artifact catalog behavior unchanged.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- New forms execute the same code paths and produce equivalent results.
|
||||||
|
- Positional session ID mismatch with concrete local or remote `session.yml`
|
||||||
|
fails through existing session identity checks.
|
||||||
|
- Remote session fallback still uses the positional session ID as the lookup
|
||||||
|
value.
|
||||||
|
- Current command tests cover the new forms before old forms are removed.
|
||||||
|
|
||||||
|
## Implementation Step 2: Remove Old Public Forms
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
Remove compatibility aliases and make the session-oriented interface the only
|
||||||
|
documented and supported public CLI.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Remove top-level dispatch for:
|
||||||
|
- `plan`
|
||||||
|
- `status`
|
||||||
|
- `restore`
|
||||||
|
- `artifacts`
|
||||||
|
- `locks`
|
||||||
|
- Remove `--session-id` flags from public session-aware commands.
|
||||||
|
- Keep `--previous-session-id` as an expected previous-session identity flag.
|
||||||
|
- Keep explicit `--session <path>` for loading a local concrete session file,
|
||||||
|
but still require the positional session ID for commands that operate on a
|
||||||
|
session.
|
||||||
|
- Remove `status --manifest`.
|
||||||
|
- Update usage text and invalid-command errors.
|
||||||
|
- Update `docs/cli.md` and `docs/operations.md` to use only the new forms.
|
||||||
|
- Update any roadmap docs that mention old helper command names.
|
||||||
|
- Update tests to expect old top-level helper commands and `--session-id` forms
|
||||||
|
to fail.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Top-level command list is exactly:
|
||||||
|
- `run`
|
||||||
|
- `run-stage`
|
||||||
|
- `resume`
|
||||||
|
- `analyze`
|
||||||
|
- `publish`
|
||||||
|
- `clean`
|
||||||
|
- `session`
|
||||||
|
- All session-oriented commands use `narratio session <subcommand>
|
||||||
|
<session_id> [--flags]`, except nested lock mutation forms, which use
|
||||||
|
`narratio session locks add|remove <session_id> <source> [--flags]`.
|
||||||
|
- `clean <session_id>` and `clean --all` remain top-level.
|
||||||
|
- Current-behavior docs and tests no longer advertise `--session-id`.
|
||||||
|
|
||||||
|
## Test Guidance
|
||||||
|
|
||||||
|
Focused tests:
|
||||||
|
|
||||||
|
- `go test ./internal/app -run TestExecute -v`
|
||||||
|
- `go test ./internal/app -run 'Session|Status|Restore|Clean|Locks|Artifacts|Plan|RunStage|Analyze|Publish' -v`
|
||||||
|
- `go test ./internal/config -v`
|
||||||
|
|
||||||
|
Full validation:
|
||||||
|
|
||||||
|
- `go test ./...`
|
||||||
|
|
||||||
|
Test cases to add or update:
|
||||||
|
|
||||||
|
- `run <session_id>` loads local and remote sessions through the existing
|
||||||
|
config path.
|
||||||
|
- `resume <session_id>`, `analyze <session_id>`, and `publish <session_id>`
|
||||||
|
preserve current behavior.
|
||||||
|
- `run-stage <stage> <session_id>` preserves current run-stage output and
|
||||||
|
force/artifact-selection behavior.
|
||||||
|
- `session plan <session_id>` replaces top-level `plan`.
|
||||||
|
- `session status <session_id>` replaces top-level session status.
|
||||||
|
- `session validate <session_id>` replaces `session validate --session-id`.
|
||||||
|
- `session init <session_id>` writes the same local or remote concrete
|
||||||
|
`session.yml`.
|
||||||
|
- `session restore <session_id>` preserves restore planning/execution.
|
||||||
|
- `session artifacts <session_id> --remote` preserves promoted-output
|
||||||
|
availability reporting.
|
||||||
|
- `session locks <session_id>`, `session locks add <session_id> <source>`, and
|
||||||
|
`session locks remove <session_id> <source>` preserve static/remote lock
|
||||||
|
semantics.
|
||||||
|
- `clean <session_id>` preserves session cleanup behavior, while `clean --all`
|
||||||
|
remains unchanged.
|
||||||
|
- Old top-level helper commands fail after Step 2.
|
||||||
|
- `--session-id` fails after Step 2.
|
||||||
|
- `status --manifest` fails after Step 2.
|
||||||
|
|
||||||
|
## Documentation Guidance
|
||||||
|
|
||||||
|
Update only after implementation lands:
|
||||||
|
|
||||||
|
- `docs/cli.md`
|
||||||
|
- `docs/operations.md`
|
||||||
|
- any internal docs that list command names or examples
|
||||||
|
|
||||||
|
Keep planned behavior only in this roadmap until the command refactor is
|
||||||
|
implemented.
|
||||||
|
|
||||||
|
## Architecture Guardrails
|
||||||
|
|
||||||
|
- Keep Narratio explicit and stage-driven.
|
||||||
|
- Do not introduce a generic workflow or command framework abstraction.
|
||||||
|
- Reuse existing app command helpers where practical.
|
||||||
|
- Keep config loading strict and centralized.
|
||||||
|
- Keep storage details behind `storage.ObjectStore`.
|
||||||
|
- Keep secret-backed object-store construction in `internal/app`.
|
||||||
|
- Preserve manifest-driven resume and restore behavior.
|
||||||
|
- Treat command renaming as a public CLI contract change, not a runtime stage
|
||||||
|
behavior change.
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Roadmap: Operator Helper Commands
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Implemented.
|
|
||||||
|
|
||||||
The operator helper command set is no longer conceptual. Current behavior is documented in:
|
|
||||||
|
|
||||||
- `docs/cli.md`
|
|
||||||
- `docs/operations.md`
|
|
||||||
- `docs/config.md`
|
|
||||||
- `docs/internal/artifacts.md`
|
|
||||||
- `docs/internal/stage-archive.md`
|
|
||||||
|
|
||||||
## Implemented Commands
|
|
||||||
|
|
||||||
- `narratio session validate`
|
|
||||||
- `narratio status --manifest <path>`
|
|
||||||
- `narratio status --session-id <id>`
|
|
||||||
- `narratio session init --output <path>`
|
|
||||||
- `narratio session init --remote`
|
|
||||||
- `narratio artifacts list`
|
|
||||||
- `narratio artifacts list --remote`
|
|
||||||
- `narratio locks`
|
|
||||||
- `narratio locks add <source>`
|
|
||||||
- `narratio locks remove <source>`
|
|
||||||
|
|
||||||
## Implemented Decisions
|
|
||||||
|
|
||||||
- Helper output is text-only. No JSON schema exists yet.
|
|
||||||
- `status` remains a top-level command.
|
|
||||||
- `session validate`, `session init`, and `artifacts list` are nested helper commands.
|
|
||||||
- `locks` is the single top-level command for listing, adding, and removing archive promotion locks.
|
|
||||||
- Remote session initialization requires explicit `--remote`.
|
|
||||||
- Local session initialization requires `--output`.
|
|
||||||
- Remote artifact availability is opt-in with `artifacts list --remote`.
|
|
||||||
- Mutable locks are source-based and stored at `{session_prefix}/locks.yml`.
|
|
||||||
- The remote lock store uses strict YAML with top-level `locks`.
|
|
||||||
- Static `pipeline.archive.locks` and remote locks are merged; static locks win on duplicate sources.
|
|
||||||
- `locks remove` removes only remote locks.
|
|
||||||
- Ordinary execution `--force` does not override locks.
|
|
||||||
- Remote lock writes use existence checks and `--force` for updates; there is no compare-and-swap protection.
|
|
||||||
|
|
||||||
## Remaining Future Enhancements
|
|
||||||
|
|
||||||
These are intentionally not implemented:
|
|
||||||
|
|
||||||
- `--json` output for helper commands.
|
|
||||||
- Optimistic concurrency or ETag compare-and-swap for remote lock mutations.
|
|
||||||
- Rich remote artifact availability across historical run-local objects.
|
|
||||||
- Session-lock acquisition for remote mutation helpers.
|
|
||||||
- Broader campaign helper commands such as `campaign validate` or `campaign publish`.
|
|
||||||
287
docs/roadmap/publish.md
Normal file
287
docs/roadmap/publish.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
# Roadmap: Publish Contract
|
||||||
|
|
||||||
|
Status: Planned
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Narratio currently uses several terms for one operator-facing concept:
|
||||||
|
|
||||||
|
- `archive` is the stage that uploads run state and commits remote current
|
||||||
|
state.
|
||||||
|
- `publish` is the convenience command that force-runs the archive stage.
|
||||||
|
- `promote`, `promoted`, and `promote_artifacts` describe configured top-level
|
||||||
|
remote output writes.
|
||||||
|
|
||||||
|
This mixed vocabulary makes the public contract harder to explain. Operators
|
||||||
|
should not need to distinguish "archive the run", "publish the run", and
|
||||||
|
"promote artifacts" when these are all part of the same publish action.
|
||||||
|
|
||||||
|
The public model should use:
|
||||||
|
|
||||||
|
- `publish` for the stage, command, config section, and action;
|
||||||
|
- `published` for an expected remote output that exists at its top-level
|
||||||
|
current destination;
|
||||||
|
- `publish rules` for the configured source-to-destination output rules;
|
||||||
|
- `locked` for sources whose top-level published destination must not be
|
||||||
|
overwritten;
|
||||||
|
- `run history` for immutable per-run records under `runs/<run_id>/`.
|
||||||
|
|
||||||
|
## Target Model
|
||||||
|
|
||||||
|
The public stage is `publish`.
|
||||||
|
|
||||||
|
The convenience command:
|
||||||
|
|
||||||
|
narratio publish <session_id>
|
||||||
|
|
||||||
|
is equivalent to:
|
||||||
|
|
||||||
|
narratio run-stage publish <session_id> --force
|
||||||
|
|
||||||
|
Pipeline configuration uses `publish`:
|
||||||
|
|
||||||
|
publish:
|
||||||
|
enabled: true
|
||||||
|
upload_run: true
|
||||||
|
outputs:
|
||||||
|
- source: narratio.transcript.final_trimmed
|
||||||
|
- source: narratio.artifact.session_recap
|
||||||
|
locks:
|
||||||
|
- source: narratio.artifact.session_recap
|
||||||
|
reason: Final recap was manually edited.
|
||||||
|
|
||||||
|
Publish output rules are source-based. Each rule writes one artifact source to
|
||||||
|
a top-level remote destination. If `dest` is omitted, Narratio derives the
|
||||||
|
destination from the artifact registry or configured artifact output path.
|
||||||
|
|
||||||
|
The mutable remote lock store remains:
|
||||||
|
|
||||||
|
{session_prefix}/locks.yml
|
||||||
|
|
||||||
|
Remote availability output uses `published`:
|
||||||
|
|
||||||
|
Published:
|
||||||
|
- narratio.transcript.final_trimmed remote=published
|
||||||
|
- narratio.artifact.session_recap locked remote=published
|
||||||
|
|
||||||
|
The remote key layout is otherwise unchanged:
|
||||||
|
|
||||||
|
- immutable run history stays under `{session_prefix}/runs/{run_id}/`;
|
||||||
|
- current state stays under `{session_prefix}/current/manifest.json`;
|
||||||
|
- the final commit marker stays `{session_prefix}/current/run_id.txt`;
|
||||||
|
- `current/run_id.txt` is still written last.
|
||||||
|
|
||||||
|
## Compatibility Policy
|
||||||
|
|
||||||
|
This is a hard cutover.
|
||||||
|
|
||||||
|
After implementation:
|
||||||
|
|
||||||
|
- `pipeline.archive` is rejected by strict YAML decoding.
|
||||||
|
- `pipeline.archive.promote_artifacts` is rejected.
|
||||||
|
- `pipeline.workspace.cleanup_after_archive` is rejected.
|
||||||
|
- `pipeline.spool.delete_audio_after_archive` is rejected.
|
||||||
|
- `narratio run-stage archive <session_id>` is an unknown stage.
|
||||||
|
- manifests that record an `archive` stage are not migrated.
|
||||||
|
- old archive/promotion metadata keys are not read as compatibility fallbacks.
|
||||||
|
|
||||||
|
Existing remote objects are not moved or renamed. Remote layout remains stable;
|
||||||
|
the rename changes configuration, stage names, status output, metadata, helper
|
||||||
|
names, tests, examples, and documentation.
|
||||||
|
|
||||||
|
## Implementation Stages
|
||||||
|
|
||||||
|
### Stage 1: Public Schema and Stage Cutover
|
||||||
|
|
||||||
|
Status: Planned
|
||||||
|
|
||||||
|
Switch the public config and stage contract to publish terminology.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Replace `pipeline.archive` with `pipeline.publish`.
|
||||||
|
- Replace `archive.promote_artifacts` with `publish.outputs`.
|
||||||
|
- Keep output rule fields:
|
||||||
|
- `source`
|
||||||
|
- `dest`
|
||||||
|
- `required`
|
||||||
|
- Replace `pipeline.archive.locks` with `pipeline.publish.locks`.
|
||||||
|
- Rename post-publish cleanup fields:
|
||||||
|
- `pipeline.workspace.cleanup_after_publish`
|
||||||
|
- `pipeline.spool.delete_audio_after_publish`
|
||||||
|
- Rename the registered stage from `archive` to `publish`.
|
||||||
|
- Update stage order so `publish` runs after `analyze` and before `notify`.
|
||||||
|
- Update top-level `narratio publish` to target stage `publish`.
|
||||||
|
- Keep `run-stage --artifacts <names> publish` support.
|
||||||
|
- Reject `run-stage --artifacts <names>` for stages other than `analyze` and
|
||||||
|
`publish`.
|
||||||
|
- Preserve the remote commit ordering and storage adapter boundaries.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- `narratio run-stage publish <session_id>` executes the publish stage.
|
||||||
|
- `narratio publish <session_id>` force-runs the publish stage.
|
||||||
|
- `narratio run-stage archive <session_id>` fails clearly as an unknown stage.
|
||||||
|
- Old archive config fields fail strict decoding.
|
||||||
|
- New publish config fields load, default, and validate.
|
||||||
|
|
||||||
|
### Stage 2: Runtime Terminology and Metadata Cutover
|
||||||
|
|
||||||
|
Status: Planned
|
||||||
|
|
||||||
|
Rename implementation concepts and runtime output to publish terminology.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Rename archive/promotion config and runtime types conceptually to
|
||||||
|
publish/output terms.
|
||||||
|
- Rename the remote key helper intent from promoted artifact to published
|
||||||
|
output while keeping generated keys unchanged.
|
||||||
|
- Change helper output:
|
||||||
|
- `Promoted:` becomes `Published:`
|
||||||
|
- `remote=promoted` becomes `remote=published`
|
||||||
|
- lock output uses `published` / `not-published`
|
||||||
|
- Rename publish-stage metadata, including:
|
||||||
|
- `promoted_paths` to `published_paths`
|
||||||
|
- `promoted_files_uploaded` to `published_files_uploaded`
|
||||||
|
- `skipped_optional_promotions` to `skipped_optional_outputs`
|
||||||
|
- `skipped_unselected_promotions` to `skipped_unselected_outputs`
|
||||||
|
- `locked_promotion_count` to `locked_output_count`
|
||||||
|
- `locked_promotions` to `locked_outputs`
|
||||||
|
- Update previous-cache and restore logic to use the `publish` stage and
|
||||||
|
`published_paths` metadata only.
|
||||||
|
- Keep run-local stage output materialization separate from remote publish
|
||||||
|
terminology. If local helper names are confusing, rename them to
|
||||||
|
materialization-oriented names rather than publish names.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Status and artifact helper output use `Published:` and `remote=published`.
|
||||||
|
- Publish metadata contains only publish/output terminology.
|
||||||
|
- Previous-cache and restore behavior works with publish metadata and does not
|
||||||
|
depend on old archive metadata.
|
||||||
|
- Storage adapters still receive explicit keys and no AWS SDK details leak into
|
||||||
|
app or stage logic.
|
||||||
|
|
||||||
|
### Stage 3: Documentation, Examples, and Final Cleanup
|
||||||
|
|
||||||
|
Status: Planned
|
||||||
|
|
||||||
|
Update implemented-behavior docs and remove stale public terminology after the
|
||||||
|
runtime cutover lands.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Update current-behavior docs:
|
||||||
|
- `docs/config.md`
|
||||||
|
- `docs/cli.md`
|
||||||
|
- `docs/operations.md`
|
||||||
|
- `docs/troubleshooting.md`
|
||||||
|
- `docs/architecture.md`
|
||||||
|
- relevant files under `docs/internal/`
|
||||||
|
- Rename `docs/internal/stage-archive.md` to
|
||||||
|
`docs/internal/stage-publish.md`.
|
||||||
|
- Update internal documentation links and references.
|
||||||
|
- Update examples to use:
|
||||||
|
- `publish.outputs`
|
||||||
|
- `publish.locks`
|
||||||
|
- `cleanup_after_publish`
|
||||||
|
- `delete_audio_after_publish`
|
||||||
|
- Update tests and final searches so old terminology remains only in this
|
||||||
|
roadmap as historical context.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Maintained examples load and validate.
|
||||||
|
- Current-behavior docs describe only implemented publish terminology.
|
||||||
|
- Internal docs describe run history, published outputs, locks, and current
|
||||||
|
commit ordering clearly.
|
||||||
|
- Old user-facing archive/promote wording is removed except where discussing
|
||||||
|
historical behavior in this roadmap.
|
||||||
|
|
||||||
|
## Test Guidance
|
||||||
|
|
||||||
|
Focused tests:
|
||||||
|
|
||||||
|
- `go test ./internal/config -v`
|
||||||
|
- `go test ./internal/app -v`
|
||||||
|
- `go test ./internal/stage -v`
|
||||||
|
- `go test ./internal/artifacts -v`
|
||||||
|
|
||||||
|
Full validation:
|
||||||
|
|
||||||
|
- `go test ./...`
|
||||||
|
|
||||||
|
Config tests to add or update:
|
||||||
|
|
||||||
|
- `publish.outputs` defaults and validates.
|
||||||
|
- `publish.outputs[].dest` derives from the artifact registry when omitted.
|
||||||
|
- `publish.locks` validates with the same source rules as publish outputs.
|
||||||
|
- old `archive` fails strict decode.
|
||||||
|
- old `promote_artifacts` fails strict decode.
|
||||||
|
- old cleanup fields fail strict decode.
|
||||||
|
|
||||||
|
App and stage tests to add or update:
|
||||||
|
|
||||||
|
- stage order uses `publish` before `notify`.
|
||||||
|
- `run-stage publish` succeeds.
|
||||||
|
- `run-stage archive` fails clearly.
|
||||||
|
- `narratio publish` force-runs the `publish` stage.
|
||||||
|
- `--artifacts` is accepted for `run-stage publish`.
|
||||||
|
- `--artifacts` error text names `analyze` and `publish`.
|
||||||
|
- status and artifact list output show `Published:` and `remote=published`.
|
||||||
|
- lock output says `published` or `not-published`.
|
||||||
|
- previous-cache and restore use `publish` stage metadata.
|
||||||
|
|
||||||
|
Final searches:
|
||||||
|
|
||||||
|
- Config/stage names:
|
||||||
|
- `pipeline.archive`
|
||||||
|
- `archive:`
|
||||||
|
- `promote_artifacts`
|
||||||
|
- `cleanup_after_archive`
|
||||||
|
- `delete_audio_after_archive`
|
||||||
|
- User-facing output:
|
||||||
|
- `Promoted:`
|
||||||
|
- `remote=promoted`
|
||||||
|
- `not-promoted`
|
||||||
|
- Runtime symbols and metadata:
|
||||||
|
- `ArchiveConfig`
|
||||||
|
- `ArchivePromotionRule`
|
||||||
|
- `S3PromotedArtifactKey`
|
||||||
|
- `promoted_paths`
|
||||||
|
- `promoted_files_uploaded`
|
||||||
|
- `locked_promotions`
|
||||||
|
|
||||||
|
Expected remaining matches should be limited to this roadmap and narrowly
|
||||||
|
justified historical references until the roadmap is fully retired.
|
||||||
|
|
||||||
|
## Architecture Guardrails
|
||||||
|
|
||||||
|
- Keep Narratio explicit and stage-driven.
|
||||||
|
- Do not introduce a generic workflow or DAG abstraction.
|
||||||
|
- Keep strict YAML decoding.
|
||||||
|
- Keep remote path construction centralized.
|
||||||
|
- Keep storage details behind `storage.ObjectStore`.
|
||||||
|
- Keep AWS SDK types inside storage adapters.
|
||||||
|
- Preserve manifest-driven resume and restore behavior.
|
||||||
|
- Preserve current-state commit ordering with `current/run_id.txt` written
|
||||||
|
last.
|
||||||
|
- Keep raw secrets out of configs, manifests, logs, generated configs, and
|
||||||
|
publish metadata.
|
||||||
|
- Keep planned behavior only in this roadmap until implementation lands.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- This is a breaking public/config/stage contract change.
|
||||||
|
- No compatibility aliases are retained.
|
||||||
|
- No migration logic is needed for in-progress local manifests.
|
||||||
|
- No migration logic is needed for old remote manifests.
|
||||||
|
- Existing remote objects are not moved or renamed.
|
||||||
|
- `publish` means uploading run history, writing configured published outputs,
|
||||||
|
and committing current state.
|
||||||
|
- `run history` is the preferred term for immutable per-run records under
|
||||||
|
`runs/<run_id>/`.
|
||||||
|
- `archive` remains acceptable only as a generic English concept in historical
|
||||||
|
roadmap context, not as a public Narratio command, config field, stage name,
|
||||||
|
or metadata term after implementation.
|
||||||
210
docs/roadmap/transcripts.md
Normal file
210
docs/roadmap/transcripts.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# Roadmap: Transcript Artifact Naming
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Narratio's built-in transcript artifact names and canonical paths currently mix
|
||||||
|
operator-facing artifact meaning with historical stage and tool terminology:
|
||||||
|
|
||||||
|
- `narratio.transcript.merged` maps to `transcripts/merged.json`.
|
||||||
|
- `narratio.transcript.polished` maps to `transcripts/processed.json`.
|
||||||
|
- `narratio.transcript.full` maps to `transcripts/normalized.json`.
|
||||||
|
- `narratio.transcript.trimmed` maps to `transcripts/trimmed.json`.
|
||||||
|
|
||||||
|
This makes the public artifact surface harder to reason about. Operators see
|
||||||
|
`full`, `normalized`, `processed`, `polished`, `merged`, and `trimmed` used in
|
||||||
|
different places for the same transcript lineage.
|
||||||
|
|
||||||
|
The transcript source IDs, canonical paths, and manifest output kinds should
|
||||||
|
use one vocabulary based on each transcript's role in the session artifact
|
||||||
|
model.
|
||||||
|
|
||||||
|
## Target Model
|
||||||
|
|
||||||
|
Built-in transcript artifacts should use these public source IDs, canonical
|
||||||
|
paths, and manifest output kinds:
|
||||||
|
|
||||||
|
| Source ID | Canonical path | Output kind | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `narratio.transcript.base` | `transcripts/base.json` | `transcript_base` | First unified transcript produced by merging per-speaker raw transcripts. |
|
||||||
|
| `narratio.transcript.polished` | `transcripts/polished.json` | `transcript_polished` | Audita-polished transcript. |
|
||||||
|
| `narratio.transcript.final` | `transcripts/final.json` | `transcript_final` | Full final transcript after normalization. |
|
||||||
|
| `narratio.transcript.final_trimmed` | `transcripts/final.trimmed.json` | `transcript_final_trimmed` | Trimmed version of the final transcript. |
|
||||||
|
|
||||||
|
Stage names remain process-oriented and unchanged:
|
||||||
|
|
||||||
|
- `merge`
|
||||||
|
- `polish`
|
||||||
|
- `normalize`
|
||||||
|
- `trim`
|
||||||
|
|
||||||
|
Downstream adapter contracts also remain process-oriented. The rename changes
|
||||||
|
Narratio's artifact model, canonical paths, config examples, archive promotion
|
||||||
|
sources, lock sources, status output, and documentation. It should not rename
|
||||||
|
the stages themselves or move external integration details into stage logic.
|
||||||
|
|
||||||
|
## Compatibility Policy
|
||||||
|
|
||||||
|
This is a hard cutover.
|
||||||
|
|
||||||
|
After implementation, these old source IDs should be rejected:
|
||||||
|
|
||||||
|
- `narratio.transcript.merged`
|
||||||
|
- `narratio.transcript.full`
|
||||||
|
- `narratio.transcript.trimmed`
|
||||||
|
|
||||||
|
These old canonical paths should not be compatibility fallbacks:
|
||||||
|
|
||||||
|
- `transcripts/merged.json`
|
||||||
|
- `transcripts/processed.json`
|
||||||
|
- `transcripts/normalized.json`
|
||||||
|
- `transcripts/trimmed.json`
|
||||||
|
|
||||||
|
Existing remote archives are not migrated automatically. Operators who want
|
||||||
|
new promoted keys for old sessions should republish those sessions after
|
||||||
|
updating configuration.
|
||||||
|
|
||||||
|
## Implementation Stages
|
||||||
|
|
||||||
|
### Stage 1: Centralize Transcript Artifact Naming
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
Consolidate transcript artifact source IDs, canonical paths, and output kinds
|
||||||
|
in the artifact/path layer before changing runtime behavior.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Add or consolidate constants/helpers for built-in transcript source IDs.
|
||||||
|
- Add or consolidate constants/helpers for canonical transcript paths.
|
||||||
|
- Add or consolidate constants/helpers for transcript manifest output kinds.
|
||||||
|
- Keep source ID, path, and output-kind mappings in one registry or one
|
||||||
|
obviously shared artifact model.
|
||||||
|
- Update artifact registry tests to prove the target mapping.
|
||||||
|
- Avoid changing stage output behavior in this stage unless the implementation
|
||||||
|
is simpler and still reviewable.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- There is one clear source of truth for built-in transcript artifact names,
|
||||||
|
paths, and output kinds.
|
||||||
|
- Tests prove the new target mapping in the artifact layer.
|
||||||
|
- No generic workflow abstraction is introduced.
|
||||||
|
|
||||||
|
### Stage 2: Rename Runtime Outputs and Defaults
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
Switch runtime behavior to the new transcript artifact model.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Update `merge` to write and record `transcripts/base.json` with
|
||||||
|
`transcript_base`.
|
||||||
|
- Update `polish` to write and record `transcripts/polished.json` with
|
||||||
|
`transcript_polished`.
|
||||||
|
- Update `normalize` to write and record `transcripts/final.json` with
|
||||||
|
`transcript_final`.
|
||||||
|
- Update `trim` to write and record `transcripts/final.trimmed.json` with
|
||||||
|
`transcript_final_trimmed`.
|
||||||
|
- Update normalize and trim defaults to:
|
||||||
|
- `pipeline.normalize.output_path: transcripts/final.json`
|
||||||
|
- `pipeline.trim.output_path: transcripts/final.trimmed.json`
|
||||||
|
- Update built-in artifact resolution, archive promotion destination
|
||||||
|
derivation, archive locks, status output, artifact catalog output,
|
||||||
|
previous-cache resolution, restore planning, and restore execution to use
|
||||||
|
the new registry values.
|
||||||
|
- Ensure old source IDs fail config validation.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- New runs produce the target canonical transcript files.
|
||||||
|
- Manifest outputs use the target output kinds.
|
||||||
|
- Archive promotion and lock validation accept new source IDs and reject old
|
||||||
|
source IDs.
|
||||||
|
- Status and artifact listing display new source IDs.
|
||||||
|
- Restore uses the new canonical paths and does not restore old transcript
|
||||||
|
paths as canonical outputs.
|
||||||
|
|
||||||
|
### Stage 3: Update Tests, Examples, and Current Documentation
|
||||||
|
|
||||||
|
Status: Implemented
|
||||||
|
|
||||||
|
Update all implemented-behavior references after the runtime cutover lands.
|
||||||
|
|
||||||
|
Implementation requirements:
|
||||||
|
|
||||||
|
- Update examples to use `narratio.transcript.final_trimmed` and
|
||||||
|
`transcripts/final.trimmed.json` where trimmed final transcript is intended.
|
||||||
|
- Update examples that refer to full final transcripts to use
|
||||||
|
`narratio.transcript.final` and `transcripts/final.json`.
|
||||||
|
- Update `docs/config.md`, `docs/internal/artifacts.md`, stage docs,
|
||||||
|
CLI examples, operations examples, archive examples, lock examples, and
|
||||||
|
status/artifact-list examples.
|
||||||
|
- Add strict validation tests proving old source IDs are rejected.
|
||||||
|
- Mark roadmap stages implemented only after code, tests, examples, and
|
||||||
|
current-behavior docs agree.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Maintained examples load and validate.
|
||||||
|
- Current-behavior docs describe only implemented new names.
|
||||||
|
- Old names remain only in this roadmap as historical/planning context until
|
||||||
|
this roadmap is retired or archived.
|
||||||
|
|
||||||
|
## Test Guidance
|
||||||
|
|
||||||
|
Run focused tests while implementing:
|
||||||
|
|
||||||
|
- `go test ./internal/artifacts -v`
|
||||||
|
- `go test ./internal/config -v`
|
||||||
|
- `go test ./internal/stage -v`
|
||||||
|
- `go test ./internal/app -v`
|
||||||
|
|
||||||
|
Run full validation before finishing:
|
||||||
|
|
||||||
|
- `go test ./...`
|
||||||
|
|
||||||
|
Run final searches:
|
||||||
|
|
||||||
|
- Old source IDs:
|
||||||
|
- `narratio.transcript.merged`
|
||||||
|
- `narratio.transcript.full`
|
||||||
|
- `narratio.transcript.trimmed`
|
||||||
|
- Old paths:
|
||||||
|
- `transcripts/merged.json`
|
||||||
|
- `transcripts/processed.json`
|
||||||
|
- `transcripts/normalized.json`
|
||||||
|
- `transcripts/trimmed.json`
|
||||||
|
- Old output kinds:
|
||||||
|
- `transcript_merged`
|
||||||
|
- `transcript_processed`
|
||||||
|
- `transcript_normalized`
|
||||||
|
- `transcript_trimmed`
|
||||||
|
|
||||||
|
Expected remaining matches should be limited to this roadmap's
|
||||||
|
historical/planning references until the roadmap is fully completed.
|
||||||
|
|
||||||
|
## Architecture Guardrails
|
||||||
|
|
||||||
|
- Keep Narratio explicit and stage-driven; do not introduce a generic workflow
|
||||||
|
or DAG abstraction.
|
||||||
|
- Keep path and artifact naming in centralized helpers rather than scattered
|
||||||
|
string concatenation.
|
||||||
|
- Preserve manifest-driven resume behavior.
|
||||||
|
- Keep storage details behind storage adapters.
|
||||||
|
- Do not move Seriatim, Audita, or Scriptorium command details out of their
|
||||||
|
adapter boundaries.
|
||||||
|
- Keep current-behavior documentation in sync only after implementation lands;
|
||||||
|
planned behavior belongs in this roadmap until then.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The cutover is intentionally not backward-compatible.
|
||||||
|
- Existing remote archive objects are not renamed or migrated automatically.
|
||||||
|
- Stage names and downstream adapter request field names remain unchanged.
|
||||||
|
- The term `base` is preferred over `merged` for the first unified transcript.
|
||||||
|
- The term `final` is preferred over `full` or `normalized` for the full final
|
||||||
|
transcript.
|
||||||
|
- The trimmed final path is `transcripts/final.trimmed.json`.
|
||||||
@@ -1,318 +1,168 @@
|
|||||||
# Troubleshooting
|
# Troubleshooting
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Canonical operator troubleshooting guide for recurring implemented Narratio failures.
|
Canonical operator troubleshooting guide for recurring Narratio failures.
|
||||||
|
|
||||||
## Config file discovery failure
|
## Config discovery failure
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- `run`, `plan`, `resume`, `run-stage`, or `restore` fails with config/session not found.
|
- command fails because `pipeline.yml`, `campaign.yml`, or `session.yml` was not found.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- `pipeline.yml`, `campaign.yml`, or `session.yml` is missing from system discovery paths.
|
- missing files in discovery paths.
|
||||||
- a local working-directory config file was not passed explicitly.
|
- missing/incorrect campaign selection.
|
||||||
|
- local file exists but was not passed explicitly.
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls -l /usr/local/etc/narratio/pipeline.yml /etc/narratio/pipeline.yml
|
ls -l /usr/local/etc/narratio/pipeline.yml /etc/narratio/pipeline.yml
|
||||||
ls -l /usr/local/etc/narratio/campaign.yml /etc/narratio/campaign.yml
|
|
||||||
ls -l /usr/local/etc/narratio/session.yml /etc/narratio/session.yml
|
ls -l /usr/local/etc/narratio/session.yml /etc/narratio/session.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
Safe fix:
|
||||||
- pass explicit `--config`, `--campaign`, and `--session`.
|
- pass explicit `--config`, `--campaign` or `--campaign-file`, and `--session`.
|
||||||
- or place files in documented discovery paths.
|
|
||||||
|
|
||||||
Links:
|
## Templated session file rejected
|
||||||
- [docs/config.md](./config.md)
|
|
||||||
- [docs/cli.md](./cli.md)
|
|
||||||
|
|
||||||
## Session template rendering failure
|
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- load fails with unresolved placeholder or `session_id` mismatch.
|
- load fails because `session.yml` must be concrete.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- templated `session.yml` used without `--session-id`.
|
- template placeholders (`{{ ... }}`) still present in loaded session config.
|
||||||
- rendered `session_id` differs from passed `--session-id`.
|
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio plan --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session ./session.yml --session-id 2026-04-04
|
narratio session plan 2026-04-04 --config /path/pipeline.yml --campaign-file /path/campaign.yml --session /path/session.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
Safe fix:
|
||||||
- pass `--session-id` when template placeholders are present.
|
- generate concrete session YAML via `narratio session init`.
|
||||||
- ensure rendered `session_id` matches intended run session id.
|
|
||||||
|
|
||||||
Links:
|
## Strict decode or validation failure
|
||||||
- [docs/config.md](./config.md)
|
|
||||||
|
|
||||||
## Strict YAML decode or validation failure
|
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- config load fails with unknown field or validation error.
|
- unknown field or invalid value error during config load.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- typo/stale field name.
|
- typo, stale field name, or invalid value.
|
||||||
- missing required fields or invalid constraints.
|
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio plan --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04
|
narratio session plan 2026-04-04 --config /path/pipeline.yml --campaign-file /path/campaign.yml --session /path/session.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
Safe fix:
|
||||||
- align fields/values to canonical config reference and examples.
|
- align config with [docs/config.md](./config.md) and maintained examples.
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/config.md](./config.md)
|
|
||||||
- [examples/](../examples/)
|
|
||||||
|
|
||||||
## `--artifacts` selection failure
|
## `--artifacts` selection failure
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- `run`/`resume`/`run-stage` fails with invalid or unknown artifact selection.
|
- command fails on unknown/invalid selected artifact key.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- `--artifacts` contains blank names or unknown artifact keys.
|
- artifact key not defined in `pipeline.scriptorium.artifacts`.
|
||||||
- `pipeline.scriptorium.artifacts` missing while using `--artifacts`.
|
- empty token in `--artifacts` input.
|
||||||
|
|
||||||
Diagnostics:
|
Safe fix:
|
||||||
|
- use only configured artifact keys.
|
||||||
|
|
||||||
```bash
|
## `run-stage --artifacts` unsupported stage
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Safe Fix:
|
|
||||||
- use configured artifact keys only.
|
|
||||||
- ensure `pipeline.scriptorium.artifacts` is defined.
|
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/cli.md](./cli.md)
|
|
||||||
- [docs/config.md](./config.md)
|
|
||||||
|
|
||||||
## `run-stage --artifacts` on non-analyze stage
|
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- `run-stage` fails with `--artifacts is only supported for stage "analyze"`.
|
- `run-stage` rejects `--artifacts` for the selected stage.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- `--artifacts` was used with a non-`analyze` stage.
|
- `--artifacts` used with a stage other than `analyze` or `publish`.
|
||||||
|
|
||||||
Diagnostics:
|
Safe fix:
|
||||||
|
- use `--artifacts` only with `run-stage analyze ...` or `run-stage publish ...`.
|
||||||
|
|
||||||
```bash
|
## Previous-session input unavailable
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Safe Fix:
|
|
||||||
- use `--artifacts` only with `run-stage ... analyze`.
|
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/cli.md](./cli.md)
|
|
||||||
|
|
||||||
## Configured artifact dependency/input validation failure
|
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- config validation fails for `depends_on`, `narratio.artifact.<name>` source, or artifact output path.
|
- analyze fails on required previous-session artifact input.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- `narratio.artifact.<name>` source missing matching `depends_on` key.
|
- `previous/**` cache not hydrated for this session.
|
||||||
- dependency references unknown artifact key.
|
|
||||||
- dependency self-reference or enabled dependency cycle.
|
|
||||||
- artifact output path missing/invalid/outside `artifacts/` root.
|
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio plan --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04
|
narratio session status 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
Safe fix:
|
||||||
- ensure artifact-to-artifact inputs have explicit `depends_on` entries using artifact keys.
|
|
||||||
- ensure referenced artifacts exist and define valid `output_path` values.
|
|
||||||
- keep output paths relative and under `artifacts/`.
|
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/config.md](./config.md)
|
|
||||||
- [docs/internal/stage-analyze.md](./internal/stage-analyze.md)
|
|
||||||
|
|
||||||
## Required configured artifact input unavailable at analyze time
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
- analyze fails because configured input source is unavailable.
|
|
||||||
|
|
||||||
Likely Cause:
|
|
||||||
- required upstream configured artifact was not selected/executed this run.
|
|
||||||
- non-executable dependency output file is missing or invalid on disk.
|
|
||||||
|
|
||||||
Diagnostics:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio status --manifest /path/to/manifest.json
|
narratio run-stage prepare 2026-04-04 --force
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
Or rehydrate from remote current state:
|
||||||
- run analyze with needed artifacts selected.
|
|
||||||
- or ensure dependency output file exists at configured path and is valid.
|
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/operations.md](./operations.md)
|
|
||||||
- [docs/config.md](./config.md)
|
|
||||||
|
|
||||||
## Manifest/status path failure
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
- `status` fails because manifest path is missing, unreadable, or invalid.
|
|
||||||
|
|
||||||
Likely Cause:
|
|
||||||
- wrong manifest path.
|
|
||||||
- manifest removed after cleanup.
|
|
||||||
- `--manifest` omitted.
|
|
||||||
|
|
||||||
Diagnostics:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio status --manifest /path/to/manifest.json
|
narratio session restore 2026-04-04
|
||||||
ls -l /path/to/manifest.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
|
||||||
- use manifest path printed by `run`, `resume`, or `run-stage`.
|
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/cli.md](./cli.md)
|
|
||||||
- [docs/operations.md](./operations.md)
|
|
||||||
|
|
||||||
## Session lock conflict (`.lock`)
|
## Session lock conflict (`.lock`)
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- `run`, `resume`, `run-stage`, or `restore` fails with lock conflict for session workdir.
|
- command fails with lock conflict.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- another Narratio process is running same session.
|
- another process is running for the same session.
|
||||||
- stale lock from interrupted prior run.
|
- stale lock file from interrupted command.
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls -l {workspace.root}/work/{campaign}/{session_id}/.lock
|
ls -l {workspace.root}/work/{campaign}/{session_id}/.lock
|
||||||
cat {workspace.root}/work/{campaign}/{session_id}/.lock
|
|
||||||
ps aux | grep narratio
|
ps aux | grep narratio
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
Safe fix:
|
||||||
- wait for active process to finish.
|
- wait for active process; remove stale lock only if no process is active.
|
||||||
- if no process is active, remove only stale session `.lock` file.
|
|
||||||
|
|
||||||
Links:
|
## Restore current pointer/manifest missing
|
||||||
- [docs/operations.md](./operations.md)
|
|
||||||
- [docs/internal/workspace.md](./internal/workspace.md)
|
|
||||||
|
|
||||||
## Restore remote current pointer or manifest missing
|
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- `restore` fails with remote current pointer or current manifest errors.
|
- restore fails reading remote current state.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- `current/run_id.txt` was never published.
|
- publish commit did not complete.
|
||||||
- `current/manifest.json` is missing for the session prefix.
|
- `current/run_id.txt` or `current/manifest.json` is missing.
|
||||||
- archive commit did not complete.
|
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio restore --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 --dry-run
|
narratio session restore 2026-04-04 --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
Safe fix:
|
||||||
- verify archive stage succeeded for the target session.
|
- republish from a healthy local session state.
|
||||||
- rerun/archive from a healthy source workspace so current pointers are published.
|
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/operations.md](./operations.md)
|
|
||||||
- [docs/internal/stage-archive.md](./internal/stage-archive.md)
|
|
||||||
|
|
||||||
## Restore manifest identity mismatch
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
- `restore` fails because remote manifest session or campaign does not match requested values.
|
|
||||||
|
|
||||||
Likely Cause:
|
|
||||||
- wrong `--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
|
|
||||||
```
|
|
||||||
|
|
||||||
Safe Fix:
|
|
||||||
- use the correct session config and `--session-id`.
|
|
||||||
- verify campaign/session identity in local config before restore.
|
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/config.md](./config.md)
|
|
||||||
- [docs/operations.md](./operations.md)
|
|
||||||
|
|
||||||
## Restore conflict without `--force`
|
## Restore conflict without `--force`
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- `restore` fails with `restore conflict` and conflict counts.
|
- restore reports conflict and exits.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- local durable file differs from remote file for one or more planned restore paths.
|
- local durable file differs from remote restore source.
|
||||||
|
|
||||||
Diagnostics:
|
Safe fix:
|
||||||
|
- inspect with `--dry-run`.
|
||||||
|
- rerun with `--force` only when remote should overwrite local.
|
||||||
|
|
||||||
```bash
|
## Secrets or credentials failure
|
||||||
narratio restore --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 --dry-run
|
|
||||||
```
|
|
||||||
|
|
||||||
Safe Fix:
|
|
||||||
- review planned conflicts.
|
|
||||||
- rerun with `--force` only when remote state should overwrite local state.
|
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/cli.md](./cli.md)
|
|
||||||
- [docs/operations.md](./operations.md)
|
|
||||||
|
|
||||||
## Restore report expectations
|
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- operator expects restore report file but does not find one.
|
- startup fails loading secrets dir, or storage/tool auth fails at runtime.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- restore was executed in `--dry-run` mode.
|
- invalid `pipeline.secrets.env_dir`.
|
||||||
- restore failed before report persistence path (for example lock acquisition failure).
|
- missing credential env vars.
|
||||||
|
|
||||||
Diagnostics:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ls -l {workspace.root}/work/{campaign}/{session_id}/reports/restore-latest.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Safe Fix:
|
|
||||||
- run non-dry-run restore for durable report output.
|
|
||||||
- resolve lock or early preflight failures and retry.
|
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/operations.md](./operations.md)
|
|
||||||
|
|
||||||
## Secrets env-dir or credential-env failure
|
|
||||||
|
|
||||||
Symptom:
|
|
||||||
- startup fails loading secrets directory, or stage fails due to missing credential env vars.
|
|
||||||
|
|
||||||
Likely Cause:
|
|
||||||
- invalid `pipeline.secrets.env_dir` path/permissions.
|
|
||||||
- required credential env var unset/empty.
|
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
@@ -321,60 +171,53 @@ ls -la /path/to/secrets_dir
|
|||||||
env | grep -E 'AUDITA|OBJECT_STORAGE|AWS|SCRIPTORIUM'
|
env | grep -E 'AUDITA|OBJECT_STORAGE|AWS|SCRIPTORIUM'
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
Safe fix:
|
||||||
- fix secrets directory and credential env vars.
|
- fix path/permissions/env vars; keep secret values out of YAML.
|
||||||
- keep secret values out of YAML.
|
|
||||||
|
|
||||||
Links:
|
## S3 audio prepare failure
|
||||||
- [docs/config.md](./config.md)
|
|
||||||
|
|
||||||
## S3-audio prepare failure
|
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- `prepare` fails in S3 mode (listing/downloading/no audio/backend error).
|
- prepare fails in S3 mode (list/download/no files/backend error).
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- wrong `session.inputs.audio_s3.prefix`.
|
- bad `session.inputs.audio_s3.prefix`.
|
||||||
- no `.flac` files at resolved prefix.
|
- no `.flac` objects at prefix.
|
||||||
- invalid/missing object-store credentials or backend config.
|
- bad storage credentials/config.
|
||||||
- mixed local+S3 audio input config.
|
- mixed local+S3 audio config.
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio run-stage --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 prepare
|
narratio run-stage prepare 2026-04-04 --config /path/pipeline.yml --campaign-file /path/campaign.yml --session /path/session.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
Safe fix:
|
||||||
- configure exactly one audio source mode.
|
- configure exactly one audio mode and verify storage access.
|
||||||
- verify `.flac` files and storage access.
|
|
||||||
|
|
||||||
Links:
|
## Publish output or current-pointer failure
|
||||||
- [docs/config.md](./config.md)
|
|
||||||
- [docs/operations.md](./operations.md)
|
|
||||||
|
|
||||||
## Archive promotion/current-pointer failure
|
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
- archive fails on required promotion source missing or pointer write failure.
|
- publish fails on required output source missing, upload error, or commit-marker write failure.
|
||||||
|
|
||||||
Likely Cause:
|
Likely cause:
|
||||||
- required promoted file absent (including analyze outputs not generated for this run).
|
- required source file not produced.
|
||||||
- storage upload failed before `current/run_id.txt` commit marker write.
|
- storage upload failed before `current/run_id.txt` write.
|
||||||
|
|
||||||
Diagnostics:
|
Diagnostics:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio status --manifest /path/to/manifest.json
|
narratio session status 2026-04-04
|
||||||
narratio run-stage --config /path/to/pipeline.yml --campaign /path/to/campaign.yml --session /path/to/session.yml --session-id 2026-04-04 archive
|
narratio run-stage publish 2026-04-04 --config /path/pipeline.yml --campaign-file /path/campaign.yml --session /path/session.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Safe Fix:
|
Safe fix:
|
||||||
- rerun or resume upstream stages to generate required files.
|
- rerun upstream stages to regenerate required outputs.
|
||||||
- adjust promotion `source`/`dest` rules to match artifacts that must exist.
|
- adjust `pipeline.publish.outputs` source/dest rules.
|
||||||
- retry after storage issue is resolved.
|
- retry after storage issue is fixed.
|
||||||
|
|
||||||
|
## Helpful Links
|
||||||
|
|
||||||
Links:
|
|
||||||
- [docs/operations.md](./operations.md)
|
|
||||||
- [docs/config.md](./config.md)
|
- [docs/config.md](./config.md)
|
||||||
- [docs/internal/stage-archive.md](./internal/stage-archive.md)
|
- [docs/cli.md](./cli.md)
|
||||||
|
- [docs/operations.md](./operations.md)
|
||||||
|
- [docs/internal/stage-publish.md](./internal/stage-publish.md)
|
||||||
|
|||||||
1
examples/campaigns/sample-campaign/autocorrect.yml
Normal file
1
examples/campaigns/sample-campaign/autocorrect.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
campaign: sample-campaign
|
campaign_id: sample-campaign
|
||||||
|
session_template_file: ./session.template.yml
|
||||||
inputs:
|
inputs:
|
||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
1
examples/campaigns/sample-campaign/glossary.yml
Normal file
1
examples/campaigns/sample-campaign/glossary.yml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
3
examples/campaigns/sample-campaign/session.template.yml
Normal file
3
examples/campaigns/sample-campaign/session.template.yml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
session_id: "{{ session_id }}"
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
5
examples/campaigns/sample-campaign/speakers.yml
Normal file
5
examples/campaigns/sample-campaign/speakers.yml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
match:
|
||||||
|
- speaker: "Eric Rakestraw"
|
||||||
|
match:
|
||||||
|
- "Eric_Rakestraw"
|
||||||
|
- "Eric"
|
||||||
@@ -4,21 +4,18 @@
|
|||||||
workspace:
|
workspace:
|
||||||
# Optional: defaults to /var/lib/narratio.
|
# Optional: defaults to /var/lib/narratio.
|
||||||
root: /var/lib/narratio/workspace
|
root: /var/lib/narratio/workspace
|
||||||
# Optional: remove run-scoped workdir after successful archive commit.
|
# Optional: remove run-scoped workdir after successful publish commit.
|
||||||
cleanup_after_archive: false
|
cleanup_after_publish: false
|
||||||
|
|
||||||
# Optional: local secret file loader (directory of ENV_VAR_NAME files).
|
# Optional: local secret file loader (directory of ENV_VAR_NAME files).
|
||||||
# secrets:
|
# secrets:
|
||||||
# env_dir: ./secrets
|
# env_dir: ./secrets
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
# Optional storage backend selector; use "s3" for archive + S3 audio workflows.
|
# Optional storage backend selector; use "s3" for publish + S3 audio workflows.
|
||||||
backend: s3
|
backend: s3
|
||||||
# Compatibility fields retained in schema.
|
|
||||||
bucket: ""
|
|
||||||
prefix: ""
|
|
||||||
s3:
|
s3:
|
||||||
# Required when using S3 audio or S3 archive uploads.
|
# Required when using S3 audio or S3 publish uploads.
|
||||||
bucket: my-dnd-archive
|
bucket: my-dnd-archive
|
||||||
# Optional; defaults to "dnd".
|
# Optional; defaults to "dnd".
|
||||||
root_prefix: dnd
|
root_prefix: dnd
|
||||||
@@ -30,20 +27,26 @@ storage:
|
|||||||
access_key_id_env: OBJECT_STORAGE_KEY_ID
|
access_key_id_env: OBJECT_STORAGE_KEY_ID
|
||||||
secret_access_key_env: OBJECT_STORAGE_KEY
|
secret_access_key_env: OBJECT_STORAGE_KEY
|
||||||
|
|
||||||
|
campaigns:
|
||||||
|
# Optional; defaults to /usr/local/share/narratio/campaigns.
|
||||||
|
root: /usr/local/share/narratio/campaigns
|
||||||
|
# Optional command default when --campaign is omitted.
|
||||||
|
default_campaign_id: sample-campaign
|
||||||
|
|
||||||
spool:
|
spool:
|
||||||
# Optional; defaults to /var/spool/narratio.
|
# Optional; defaults to /var/spool/narratio.
|
||||||
root: /var/spool/narratio
|
root: /var/spool/narratio
|
||||||
# Optional cleanup of run-scoped spool audio after successful archive commit.
|
# Optional cleanup of run-scoped spool audio after successful publish commit.
|
||||||
delete_audio_after_archive: false
|
delete_audio_after_publish: false
|
||||||
|
|
||||||
archive:
|
publish:
|
||||||
# Optional booleans; defaults are true.
|
# Optional booleans; defaults are true.
|
||||||
enabled: true
|
enabled: true
|
||||||
upload_run: true
|
upload_run: true
|
||||||
# Optional promotion rules; sources use Narratio artifact source IDs.
|
# Optional publish output rules; sources use Narratio artifact source IDs.
|
||||||
promote_artifacts:
|
outputs:
|
||||||
- source: narratio.transcript.trimmed
|
- source: narratio.transcript.final_trimmed
|
||||||
dest: transcripts/trimmed.json
|
dest: transcripts/final.trimmed.json
|
||||||
required: true
|
required: true
|
||||||
- source: narratio.artifact.session_recap
|
- source: narratio.artifact.session_recap
|
||||||
dest: artifacts/session_recap.md
|
dest: artifacts/session_recap.md
|
||||||
@@ -96,14 +99,14 @@ audita:
|
|||||||
|
|
||||||
normalize:
|
normalize:
|
||||||
# Optional; defaults shown explicitly.
|
# Optional; defaults shown explicitly.
|
||||||
output_path: transcripts/normalized.json
|
output_path: transcripts/final.json
|
||||||
output_schema: seriatim-intermediate
|
output_schema: seriatim-intermediate
|
||||||
report: true
|
report: true
|
||||||
|
|
||||||
trim:
|
trim:
|
||||||
# Keep disabled unless bounds prompt integration is configured.
|
# Keep disabled unless bounds prompt integration is configured.
|
||||||
enabled: false
|
enabled: false
|
||||||
output_path: transcripts/trimmed.json
|
output_path: transcripts/final.trimmed.json
|
||||||
bounds:
|
bounds:
|
||||||
prompt_id: dnd.session_bounds
|
prompt_id: dnd.session_bounds
|
||||||
profile_id: local-fast
|
profile_id: local-fast
|
||||||
@@ -130,7 +133,7 @@ scriptorium:
|
|||||||
timeout: 10m
|
timeout: 10m
|
||||||
inputs:
|
inputs:
|
||||||
transcript:
|
transcript:
|
||||||
source: narratio.transcript.trimmed
|
source: narratio.transcript.final_trimmed
|
||||||
required: true
|
required: true
|
||||||
previous_recap:
|
previous_recap:
|
||||||
source: narratio.previous_session.artifact.session_recap
|
source: narratio.previous_session.artifact.session_recap
|
||||||
@@ -158,21 +161,13 @@ scriptorium:
|
|||||||
source: narratio.artifact.session_recap
|
source: narratio.artifact.session_recap
|
||||||
required: true
|
required: true
|
||||||
transcript:
|
transcript:
|
||||||
source: narratio.transcript.trimmed
|
source: narratio.transcript.final_trimmed
|
||||||
required: true
|
required: true
|
||||||
vars:
|
vars:
|
||||||
session_id: true
|
session_id: true
|
||||||
campaign_name: true
|
campaign_name: true
|
||||||
output_kind: player_handout
|
output_kind: player_handout
|
||||||
|
|
||||||
analyzer:
|
|
||||||
# Optional adapter settings.
|
|
||||||
binary_path: ""
|
|
||||||
timeout: 2m
|
|
||||||
artifacts:
|
|
||||||
output_dir: ""
|
|
||||||
types: []
|
|
||||||
|
|
||||||
notification:
|
notification:
|
||||||
# Optional notification settings.
|
# Optional notification settings.
|
||||||
backend: ""
|
backend: ""
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
|
campaigns:
|
||||||
|
root: /usr/local/share/narratio/campaigns
|
||||||
|
default_campaign_id: sample-campaign
|
||||||
|
|
||||||
whisperx:
|
whisperx:
|
||||||
transcribe_url: "https://transcription.example.com/transcribe"
|
transcribe_url: "https://transcription.example.com/transcribe"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
workspace:
|
workspace:
|
||||||
root: /var/lib/narratio/workspace
|
root: /var/lib/narratio/workspace
|
||||||
cleanup_after_archive: true
|
cleanup_after_publish: true
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
backend: s3
|
backend: s3
|
||||||
@@ -11,16 +11,20 @@ storage:
|
|||||||
access_key_id_env: OBJECT_STORAGE_KEY_ID
|
access_key_id_env: OBJECT_STORAGE_KEY_ID
|
||||||
secret_access_key_env: OBJECT_STORAGE_KEY
|
secret_access_key_env: OBJECT_STORAGE_KEY
|
||||||
|
|
||||||
|
campaigns:
|
||||||
|
root: /usr/local/share/narratio/campaigns
|
||||||
|
default_campaign_id: sample-campaign
|
||||||
|
|
||||||
spool:
|
spool:
|
||||||
root: /var/spool/narratio
|
root: /var/spool/narratio
|
||||||
delete_audio_after_archive: true
|
delete_audio_after_publish: true
|
||||||
|
|
||||||
archive:
|
publish:
|
||||||
enabled: true
|
enabled: true
|
||||||
upload_run: true
|
upload_run: true
|
||||||
promote_artifacts:
|
outputs:
|
||||||
- source: narratio.transcript.trimmed
|
- source: narratio.transcript.final_trimmed
|
||||||
dest: transcripts/trimmed.json
|
dest: transcripts/final.trimmed.json
|
||||||
required: true
|
required: true
|
||||||
- source: narratio.artifact.session_recap
|
- source: narratio.artifact.session_recap
|
||||||
dest: artifacts/session_recap.md
|
dest: artifacts/session_recap.md
|
||||||
@@ -57,7 +61,7 @@ audita:
|
|||||||
report: true
|
report: true
|
||||||
|
|
||||||
normalize:
|
normalize:
|
||||||
output_path: transcripts/normalized.json
|
output_path: transcripts/final.json
|
||||||
output_schema: seriatim-intermediate
|
output_schema: seriatim-intermediate
|
||||||
report: true
|
report: true
|
||||||
|
|
||||||
@@ -78,7 +82,7 @@ scriptorium:
|
|||||||
timeout: 10m
|
timeout: 10m
|
||||||
inputs:
|
inputs:
|
||||||
transcript:
|
transcript:
|
||||||
source: narratio.transcript.trimmed
|
source: narratio.transcript.final_trimmed
|
||||||
required: true
|
required: true
|
||||||
previous_recap:
|
previous_recap:
|
||||||
source: narratio.previous_session.artifact.session_recap
|
source: narratio.previous_session.artifact.session_recap
|
||||||
@@ -102,14 +106,11 @@ scriptorium:
|
|||||||
source: narratio.artifact.session_recap
|
source: narratio.artifact.session_recap
|
||||||
required: true
|
required: true
|
||||||
transcript:
|
transcript:
|
||||||
source: narratio.transcript.trimmed
|
source: narratio.transcript.final_trimmed
|
||||||
required: true
|
required: true
|
||||||
vars:
|
vars:
|
||||||
session_id: true
|
session_id: true
|
||||||
output_kind: player_handout
|
output_kind: player_handout
|
||||||
|
|
||||||
analyzer:
|
|
||||||
timeout: 2m
|
|
||||||
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package analyzer
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// NoopRunner is a deterministic no-op analyzer adapter.
|
|
||||||
type NoopRunner struct{}
|
|
||||||
|
|
||||||
// Run returns the requested output path with placeholder metadata.
|
|
||||||
func (n *NoopRunner) Run(ctx context.Context, req AnalyzeRequest) (AnalyzeResult, error) {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return AnalyzeResult{}, err
|
|
||||||
}
|
|
||||||
return AnalyzeResult{ArtifactPath: req.OutputPath, Metadata: map[string]any{"placeholder": true}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FakeRunner captures analyze requests and returns deterministic responses.
|
|
||||||
type FakeRunner struct {
|
|
||||||
Requests []AnalyzeRequest
|
|
||||||
Err error
|
|
||||||
Result AnalyzeResult
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run records request and returns configured response.
|
|
||||||
func (f *FakeRunner) Run(ctx context.Context, req AnalyzeRequest) (AnalyzeResult, error) {
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return AnalyzeResult{}, err
|
|
||||||
}
|
|
||||||
f.Requests = append(f.Requests, req)
|
|
||||||
if f.Err != nil {
|
|
||||||
return AnalyzeResult{}, f.Err
|
|
||||||
}
|
|
||||||
res := f.Result
|
|
||||||
if res.ArtifactPath == "" {
|
|
||||||
res.ArtifactPath = req.OutputPath
|
|
||||||
}
|
|
||||||
if res.Metadata == nil {
|
|
||||||
res.Metadata = map[string]any{"fake": true}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package analyzer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFakeRunnerCapturesRequestAndReturnsPath(t *testing.T) {
|
|
||||||
fake := &FakeRunner{}
|
|
||||||
req := AnalyzeRequest{ArtifactType: "session-log", OutputPath: "artifacts/session-log.md"}
|
|
||||||
|
|
||||||
res, err := fake.Run(context.Background(), req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run() error = %v", err)
|
|
||||||
}
|
|
||||||
if len(fake.Requests) != 1 || fake.Requests[0].ArtifactType != "session-log" {
|
|
||||||
t.Fatalf("requests = %#v, want captured request", fake.Requests)
|
|
||||||
}
|
|
||||||
if res.ArtifactPath != req.OutputPath {
|
|
||||||
t.Fatalf("artifact path = %q, want %q", res.ArtifactPath, req.OutputPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFakeRunnerError(t *testing.T) {
|
|
||||||
fake := &FakeRunner{Err: errors.New("boom")}
|
|
||||||
_, err := fake.Run(context.Background(), AnalyzeRequest{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// Package analyzer declares the adapter contract for artifact analysis generation.
|
|
||||||
package analyzer
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
// TODO: implement analyzer integration once the analyzer contract is finalized.
|
|
||||||
|
|
||||||
// Runner is the adapter boundary for analyzer invocations.
|
|
||||||
type Runner interface {
|
|
||||||
Run(ctx context.Context, req AnalyzeRequest) (AnalyzeResult, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnalyzeRequest describes one analyzer artifact generation request.
|
|
||||||
type AnalyzeRequest struct {
|
|
||||||
ArtifactType string
|
|
||||||
ProcessedTranscriptPath string
|
|
||||||
ContextReferences []string
|
|
||||||
OutputPath string
|
|
||||||
GeneratedConfigPath string
|
|
||||||
StdoutLogPath string
|
|
||||||
StderrLogPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnalyzeResult describes analyzer output.
|
|
||||||
type AnalyzeResult struct {
|
|
||||||
ArtifactPath string
|
|
||||||
Metadata map[string]any
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@ func TestFakeRunnerCapturesRequestAndReturnsPath(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
req := PolishRequest{
|
req := PolishRequest{
|
||||||
GeneratedConfigPath: filepath.Join(dir, "config", "audita.yml"),
|
GeneratedConfigPath: filepath.Join(dir, "config", "audita.yml"),
|
||||||
OutputProcessedPath: filepath.Join(dir, "transcripts", "processed.json"),
|
OutputProcessedPath: filepath.Join(dir, "transcripts", "polished.json"),
|
||||||
StdoutLogPath: filepath.Join(dir, "logs", "audita.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "logs", "audita.stdout.log"),
|
||||||
StderrLogPath: filepath.Join(dir, "logs", "audita.stderr.log"),
|
StderrLogPath: filepath.Join(dir, "logs", "audita.stderr.log"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
req := PolishRequest{
|
req := PolishRequest{
|
||||||
GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"),
|
GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"),
|
||||||
MergedTranscriptPath: filepath.Join(dir, "merged.json"),
|
MergedTranscriptPath: filepath.Join(dir, "base.json"),
|
||||||
GlossaryPath: filepath.Join(dir, "glossary.yml"),
|
GlossaryPath: filepath.Join(dir, "glossary.yml"),
|
||||||
OutputProcessedPath: filepath.Join(dir, "processed.json"),
|
OutputProcessedPath: filepath.Join(dir, "polished.json"),
|
||||||
ReportPath: filepath.Join(dir, "audita.report.json"),
|
ReportPath: filepath.Join(dir, "audita.report.json"),
|
||||||
WorkDir: filepath.Join(dir, "artifacts", "audita-work"),
|
WorkDir: filepath.Join(dir, "artifacts", "audita-work"),
|
||||||
StdoutLogPath: filepath.Join(dir, "audita.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "audita.stdout.log"),
|
||||||
@@ -571,7 +571,7 @@ func mustAuditaRunner(t *testing.T, cfg SubprocessRunnerConfig) *SubprocessRunne
|
|||||||
func auditaReqForTest(t *testing.T, withReport bool) PolishRequest {
|
func auditaReqForTest(t *testing.T, withReport bool) PolishRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
merged := filepath.Join(dir, "merged.json")
|
merged := filepath.Join(dir, "base.json")
|
||||||
glossary := filepath.Join(dir, "glossary.yml")
|
glossary := filepath.Join(dir, "glossary.yml")
|
||||||
writeAuditaTestFile(t, merged, `{"segments":[]}`)
|
writeAuditaTestFile(t, merged, `{"segments":[]}`)
|
||||||
writeAuditaTestFile(t, glossary, "terms: []\n")
|
writeAuditaTestFile(t, glossary, "terms: []\n")
|
||||||
@@ -579,7 +579,7 @@ func auditaReqForTest(t *testing.T, withReport bool) PolishRequest {
|
|||||||
GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"),
|
GeneratedConfigPath: filepath.Join(dir, "audita.generated.yml"),
|
||||||
MergedTranscriptPath: merged,
|
MergedTranscriptPath: merged,
|
||||||
GlossaryPath: glossary,
|
GlossaryPath: glossary,
|
||||||
OutputProcessedPath: filepath.Join(dir, "processed.json"),
|
OutputProcessedPath: filepath.Join(dir, "polished.json"),
|
||||||
WorkDir: filepath.Join(dir, "artifacts", "audita-work"),
|
WorkDir: filepath.Join(dir, "artifacts", "audita-work"),
|
||||||
StdoutLogPath: filepath.Join(dir, "audita.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "audita.stdout.log"),
|
||||||
StderrLogPath: filepath.Join(dir, "audita.stderr.log"),
|
StderrLogPath: filepath.Join(dir, "audita.stderr.log"),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func TestSubprocessRunnerRunSuccessBuildsDeterministicArgsAndCapturesLogs(t *tes
|
|||||||
ConfigPath: "/etc/scriptorium/config.yml",
|
ConfigPath: "/etc/scriptorium/config.yml",
|
||||||
PromptID: "dnd.session_recap",
|
PromptID: "dnd.session_recap",
|
||||||
ProfileID: "local-quality",
|
ProfileID: "local-quality",
|
||||||
InputPaths: map[string]string{"transcript": filepath.Join(dir, "processed.json"), "other": filepath.Join(dir, "other.md")},
|
InputPaths: map[string]string{"transcript": filepath.Join(dir, "polished.json"), "other": filepath.Join(dir, "other.md")},
|
||||||
Vars: map[string]string{"session_id": "2026-05-03", "campaign_name": "Icewind Dale"},
|
Vars: map[string]string{"session_id": "2026-05-03", "campaign_name": "Icewind Dale"},
|
||||||
OutputPath: filepath.Join(dir, "artifacts", "session_recap.md"),
|
OutputPath: filepath.Join(dir, "artifacts", "session_recap.md"),
|
||||||
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.run.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.run.stdout.log"),
|
||||||
@@ -180,7 +180,7 @@ func TestSubprocessRunnerRenderSuccess(t *testing.T) {
|
|||||||
req := RenderArtifactRequest{
|
req := RenderArtifactRequest{
|
||||||
Binary: wrapper,
|
Binary: wrapper,
|
||||||
PromptID: "dnd.session_recap",
|
PromptID: "dnd.session_recap",
|
||||||
InputPaths: map[string]string{"transcript": filepath.Join(dir, "processed.json")},
|
InputPaths: map[string]string{"transcript": filepath.Join(dir, "polished.json")},
|
||||||
OutputPath: filepath.Join(dir, "artifacts", "session_recap.render.json"),
|
OutputPath: filepath.Join(dir, "artifacts", "session_recap.render.json"),
|
||||||
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.render.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "logs", "scriptorium.render.stdout.log"),
|
||||||
StderrLogPath: filepath.Join(dir, "logs", "scriptorium.render.stderr.log"),
|
StderrLogPath: filepath.Join(dir, "logs", "scriptorium.render.stderr.log"),
|
||||||
@@ -285,7 +285,7 @@ type scriptoriumHelperRecord struct {
|
|||||||
func runReqForTest(t *testing.T, binary string) RunArtifactRequest {
|
func runReqForTest(t *testing.T, binary string) RunArtifactRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
transcriptPath := filepath.Join(dir, "processed.json")
|
transcriptPath := filepath.Join(dir, "polished.json")
|
||||||
writeScriptoriumFile(t, transcriptPath, `{"segments":[]}`)
|
writeScriptoriumFile(t, transcriptPath, `{"segments":[]}`)
|
||||||
return RunArtifactRequest{
|
return RunArtifactRequest{
|
||||||
Binary: binary,
|
Binary: binary,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestFakeRunnerCapturesRequestAndReturnsPath(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
req := MergeRequest{
|
req := MergeRequest{
|
||||||
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.yml"),
|
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.yml"),
|
||||||
OutputMergedTranscriptPath: filepath.Join(dir, "transcripts", "merged.json"),
|
OutputMergedTranscriptPath: filepath.Join(dir, "transcripts", "base.json"),
|
||||||
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.stdout.log"),
|
||||||
StderrLogPath: filepath.Join(dir, "logs", "seriatim.stderr.log"),
|
StderrLogPath: filepath.Join(dir, "logs", "seriatim.stderr.log"),
|
||||||
}
|
}
|
||||||
@@ -57,8 +57,8 @@ func TestFakeRunnerTrimCapturesRequestAndReturnsPath(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
req := TrimRequest{
|
req := TrimRequest{
|
||||||
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.trim.yml"),
|
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.trim.yml"),
|
||||||
InputTranscriptPath: filepath.Join(dir, "transcripts", "processed.json"),
|
InputTranscriptPath: filepath.Join(dir, "transcripts", "polished.json"),
|
||||||
OutputTrimmedPath: filepath.Join(dir, "transcripts", "trimmed.json"),
|
OutputTrimmedPath: filepath.Join(dir, "transcripts", "final.trimmed.json"),
|
||||||
KeepSelector: "1-10",
|
KeepSelector: "1-10",
|
||||||
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.trim.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.trim.stdout.log"),
|
||||||
StderrLogPath: filepath.Join(dir, "logs", "seriatim.trim.stderr.log"),
|
StderrLogPath: filepath.Join(dir, "logs", "seriatim.trim.stderr.log"),
|
||||||
@@ -105,8 +105,8 @@ func TestFakeRunnerNormalizeCapturesRequestAndReturnsPath(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
req := NormalizeRequest{
|
req := NormalizeRequest{
|
||||||
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.normalize.yml"),
|
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.normalize.yml"),
|
||||||
InputTranscriptPath: filepath.Join(dir, "transcripts", "processed.json"),
|
InputTranscriptPath: filepath.Join(dir, "transcripts", "polished.json"),
|
||||||
OutputNormalizedPath: filepath.Join(dir, "transcripts", "normalized.json"),
|
OutputNormalizedPath: filepath.Join(dir, "transcripts", "final.json"),
|
||||||
OutputSchema: "seriatim-intermediate",
|
OutputSchema: "seriatim-intermediate",
|
||||||
ReportPath: filepath.Join(dir, "artifacts", "seriatim.normalize.report.json"),
|
ReportPath: filepath.Join(dir, "artifacts", "seriatim.normalize.report.json"),
|
||||||
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.normalize.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.normalize.stdout.log"),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestSubprocessRunnerSuccessWithReportArgsAndEnv(t *testing.T) {
|
|||||||
req := MergeRequest{
|
req := MergeRequest{
|
||||||
GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"),
|
GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"),
|
||||||
InputTranscriptPaths: []string{filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json")},
|
InputTranscriptPaths: []string{filepath.Join(dir, "a.json"), filepath.Join(dir, "b.json")},
|
||||||
OutputMergedTranscriptPath: filepath.Join(dir, "merged.json"),
|
OutputMergedTranscriptPath: filepath.Join(dir, "base.json"),
|
||||||
ReportPath: filepath.Join(dir, "seriatim.report.json"),
|
ReportPath: filepath.Join(dir, "seriatim.report.json"),
|
||||||
SpeakersPath: filepath.Join(dir, "speakers.yml"),
|
SpeakersPath: filepath.Join(dir, "speakers.yml"),
|
||||||
AutocorrectPath: filepath.Join(dir, "autocorrect.yml"),
|
AutocorrectPath: filepath.Join(dir, "autocorrect.yml"),
|
||||||
@@ -732,7 +732,7 @@ func mergeReqForTest(t *testing.T, withReport bool) MergeRequest {
|
|||||||
req := MergeRequest{
|
req := MergeRequest{
|
||||||
GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"),
|
GeneratedConfigPath: filepath.Join(dir, "seriatim.generated.yml"),
|
||||||
InputTranscriptPaths: []string{in1, in2},
|
InputTranscriptPaths: []string{in1, in2},
|
||||||
OutputMergedTranscriptPath: filepath.Join(dir, "merged.json"),
|
OutputMergedTranscriptPath: filepath.Join(dir, "base.json"),
|
||||||
StdoutLogPath: filepath.Join(dir, "seriatim.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "seriatim.stdout.log"),
|
||||||
StderrLogPath: filepath.Join(dir, "seriatim.stderr.log"),
|
StderrLogPath: filepath.Join(dir, "seriatim.stderr.log"),
|
||||||
}
|
}
|
||||||
@@ -745,11 +745,11 @@ func mergeReqForTest(t *testing.T, withReport bool) MergeRequest {
|
|||||||
func trimReqForTest(t *testing.T) TrimRequest {
|
func trimReqForTest(t *testing.T) TrimRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
input := filepath.Join(dir, "processed.json")
|
input := filepath.Join(dir, "polished.json")
|
||||||
writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
|
writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
|
||||||
return TrimRequest{
|
return TrimRequest{
|
||||||
InputTranscriptPath: input,
|
InputTranscriptPath: input,
|
||||||
OutputTrimmedPath: filepath.Join(dir, "trimmed.json"),
|
OutputTrimmedPath: filepath.Join(dir, "final.trimmed.json"),
|
||||||
KeepSelector: "5-12",
|
KeepSelector: "5-12",
|
||||||
GeneratedConfigPath: filepath.Join(dir, "seriatim.trim.generated.yml"),
|
GeneratedConfigPath: filepath.Join(dir, "seriatim.trim.generated.yml"),
|
||||||
StdoutLogPath: filepath.Join(dir, "seriatim.trim.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "seriatim.trim.stdout.log"),
|
||||||
@@ -760,12 +760,12 @@ func trimReqForTest(t *testing.T) TrimRequest {
|
|||||||
func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest {
|
func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
input := filepath.Join(dir, "processed.json")
|
input := filepath.Join(dir, "polished.json")
|
||||||
writeSeriatimFile(t, input, `{"schema":"audita.processed.v1","segments":[]}`)
|
writeSeriatimFile(t, input, `{"schema":"audita.processed.v1","segments":[]}`)
|
||||||
|
|
||||||
req := NormalizeRequest{
|
req := NormalizeRequest{
|
||||||
InputTranscriptPath: input,
|
InputTranscriptPath: input,
|
||||||
OutputNormalizedPath: filepath.Join(dir, "normalized.json"),
|
OutputNormalizedPath: filepath.Join(dir, "final.json"),
|
||||||
OutputSchema: "seriatim-intermediate",
|
OutputSchema: "seriatim-intermediate",
|
||||||
GeneratedConfigPath: filepath.Join(dir, "seriatim.normalize.generated.yml"),
|
GeneratedConfigPath: filepath.Join(dir, "seriatim.normalize.generated.yml"),
|
||||||
StdoutLogPath: filepath.Join(dir, "seriatim.normalize.stdout.log"),
|
StdoutLogPath: filepath.Join(dir, "seriatim.normalize.stdout.log"),
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (f *artifactSelectionFlag) Normalize() ([]string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSelectedAnalyzeArtifacts(cfg *config.Config, selected []string) error {
|
func validateSelectedArtifacts(cfg *config.Config, selected []string) error {
|
||||||
if len(selected) == 0 {
|
if len(selected) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,25 +14,67 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRunStageArtifactsNonAnalyzeFails(t *testing.T) {
|
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(
|
code := Execute(
|
||||||
[]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap", "polish"},
|
[]string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
)
|
)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), `run-stage: --artifacts is only supported for stage "analyze"`) {
|
if !strings.Contains(stderr.String(), `run-stage: --artifacts is only supported for stages "analyze" and "publish"`) {
|
||||||
t.Fatalf("stderr = %q, want stage-gating error", stderr.String())
|
t.Fatalf("stderr = %q, want stage-gating error", stderr.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteRunStageArchivePropagatesSelectedArtifacts(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
|
||||||
|
var capturedStages []string
|
||||||
|
var capturedArtifacts []string
|
||||||
|
origExecuteStagesFn := executeStagesFn
|
||||||
|
t.Cleanup(func() {
|
||||||
|
executeStagesFn = origExecuteStagesFn
|
||||||
|
})
|
||||||
|
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||||
|
for _, s := range stages {
|
||||||
|
capturedStages = append(capturedStages, s.Name())
|
||||||
|
}
|
||||||
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
|
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"publish"}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute(
|
||||||
|
[]string{
|
||||||
|
"run-stage", "publish", "2026-05-03",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath,
|
||||||
|
"--artifacts", "session_recap",
|
||||||
|
},
|
||||||
|
&stdout,
|
||||||
|
&stderr,
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if len(capturedStages) != 1 || capturedStages[0] != "publish" {
|
||||||
|
t.Fatalf("captured stages = %#v, want [publish]", capturedStages)
|
||||||
|
}
|
||||||
|
if strings.Join(capturedArtifacts, ",") != "session_recap" {
|
||||||
|
t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
|
func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
@@ -40,7 +82,7 @@ func TestExecuteUnknownArtifactsFailValidation(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(
|
code := Execute(
|
||||||
[]string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
[]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
)
|
)
|
||||||
@@ -67,7 +109,7 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
|
|||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := RunStage(
|
err := RunStage(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
[]string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap", "analyze"},
|
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap,session_recap"},
|
||||||
&out,
|
&out,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -85,7 +127,7 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} {
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||||
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||||
@@ -95,7 +137,7 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
|||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Resume(
|
err := Resume(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
[]string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||||
&out,
|
&out,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,7 +172,7 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(
|
code := Execute(
|
||||||
[]string{"analyze", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath},
|
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
)
|
)
|
||||||
@@ -167,8 +209,9 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
code := Execute(
|
code := Execute(
|
||||||
[]string{
|
[]string{
|
||||||
"analyze",
|
"analyze",
|
||||||
|
"2026-05-03",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--artifacts", "player_handout,session_recap",
|
"--artifacts", "player_handout,session_recap",
|
||||||
},
|
},
|
||||||
@@ -190,7 +233,7 @@ func TestExecuteAnalyzeUnknownArtifactFailsValidation(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(
|
code := Execute(
|
||||||
[]string{"analyze", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
[]string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
)
|
)
|
||||||
@@ -208,7 +251,7 @@ func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
|
|||||||
args []string
|
args []string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "positional", args: []string{"analyze", "extra"}, want: "analyze: unexpected positional arguments"},
|
{name: "extra positional", args: []string{"analyze", "2026-05-03", "extra"}, want: "analyze: unexpected positional arguments"},
|
||||||
{name: "force flag", args: []string{"analyze", "--force"}, want: "analyze: invalid flags: flag provided but not defined: -force"},
|
{name: "force flag", args: []string{"analyze", "--force"}, want: "analyze: invalid flags: flag provided but not defined: -force"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -229,7 +272,7 @@ func TestExecuteAnalyzeRejectsPositionalArgsAndForceFlag(t *testing.T) {
|
|||||||
func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"analyze"}, &stdout, &stderr)
|
code := Execute([]string{"analyze", "2026-05-03"}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -238,7 +281,109 @@ func TestExecuteAnalyzeMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteUsageIncludesAnalyze(t *testing.T) {
|
func TestExecutePublishForceRunsArchive(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
|
||||||
|
var capturedStages []string
|
||||||
|
var capturedForce bool
|
||||||
|
var capturedArtifacts []string
|
||||||
|
origExecuteStagesFn := executeStagesFn
|
||||||
|
t.Cleanup(func() {
|
||||||
|
executeStagesFn = origExecuteStagesFn
|
||||||
|
})
|
||||||
|
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||||
|
for _, s := range stages {
|
||||||
|
capturedStages = append(capturedStages, s.Name())
|
||||||
|
}
|
||||||
|
capturedForce = opts.Force
|
||||||
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
|
return &RunSummary{
|
||||||
|
ManifestPath: filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json"),
|
||||||
|
Executed: []string{"publish"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute(
|
||||||
|
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||||
|
&stdout,
|
||||||
|
&stderr,
|
||||||
|
)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if len(capturedStages) != 1 || capturedStages[0] != "publish" {
|
||||||
|
t.Fatalf("captured stages = %#v, want [publish]", capturedStages)
|
||||||
|
}
|
||||||
|
if !capturedForce {
|
||||||
|
t.Fatal("captured force = false, want true")
|
||||||
|
}
|
||||||
|
if strings.Join(capturedArtifacts, ",") != "session_recap" {
|
||||||
|
t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "narratio publish: executed=1 skipped=0 force=true; manifest=") {
|
||||||
|
t.Fatalf("stdout = %q, want publish summary", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutePublishRejectsUnsupportedArgsAndFlags(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "extra positional", args: []string{"publish", "2026-05-03", "extra"}, want: "publish: unexpected positional arguments"},
|
||||||
|
{name: "force flag", args: []string{"publish", "--force"}, want: "publish: invalid flags: flag provided but not defined: -force"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute(tc.args, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), tc.want) {
|
||||||
|
t.Fatalf("stderr = %q, want %q", stderr.String(), tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutePublishUnknownArtifactFailsValidation(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute(
|
||||||
|
[]string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "unknown_artifact"},
|
||||||
|
&stdout,
|
||||||
|
&stderr,
|
||||||
|
)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), `publish: --artifacts includes unknown artifact "unknown_artifact"`) {
|
||||||
|
t.Fatalf("stderr = %q, want unknown-artifact validation error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutePublishMissingConfigUsesRunStageLoadingPath(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"publish", "2026-05-03"}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "publish: no pipeline config path provided and no default pipeline config found; searched:") {
|
||||||
|
t.Fatalf("stderr = %q, want pipeline discovery error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteUsageIncludesAnalyzeAndPublish(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(nil, &stdout, &stderr)
|
code := Execute(nil, &stdout, &stderr)
|
||||||
@@ -248,6 +393,9 @@ func TestExecuteUsageIncludesAnalyze(t *testing.T) {
|
|||||||
if !strings.Contains(stderr.String(), "analyze") {
|
if !strings.Contains(stderr.String(), "analyze") {
|
||||||
t.Fatalf("stderr = %q, want usage to include analyze", stderr.String())
|
t.Fatalf("stderr = %q, want usage to include analyze", stderr.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "publish") {
|
||||||
|
t.Fatalf("stderr = %q, want usage to include publish", stderr.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string, string) {
|
func writeValidConfigFilesWithScriptoriumArtifacts(t *testing.T, workspaceRoot string) (string, string, string) {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func TestArtifactSelectionFlagNormalize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateSelectedAnalyzeArtifacts(t *testing.T) {
|
func TestValidateSelectedArtifacts(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
@@ -114,7 +114,7 @@ func TestValidateSelectedAnalyzeArtifacts(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
err := validateSelectedAnalyzeArtifacts(tt.cfg, tt.selected)
|
err := validateSelectedArtifacts(tt.cfg, tt.selected)
|
||||||
if tt.wantErr != "" {
|
if tt.wantErr != "" {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("error = nil, want %q", tt.wantErr)
|
t.Fatalf("error = nil, want %q", tt.wantErr)
|
||||||
|
|||||||
@@ -1,49 +1,44 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func resolveCampaignConfigPath(flagValue string) (string, error) {
|
func resolveCampaignConfigPath(pipelineCfg *config.PipelineConfig, campaignIDFlag, campaignFileFlag string) (string, error) {
|
||||||
return resolveCampaignConfigPathWithCandidates(flagValue, config.DefaultCampaignConfigSearchPaths)
|
campaignID := strings.TrimSpace(campaignIDFlag)
|
||||||
|
campaignFile := strings.TrimSpace(campaignFileFlag)
|
||||||
|
if campaignID != "" && campaignFile != "" {
|
||||||
|
return "", fmt.Errorf("--campaign and --campaign-file are mutually exclusive")
|
||||||
|
}
|
||||||
|
if campaignFile != "" {
|
||||||
|
return filepath.Clean(campaignFile), nil
|
||||||
|
}
|
||||||
|
if campaignID == "" && pipelineCfg != nil {
|
||||||
|
campaignID = strings.TrimSpace(pipelineCfg.Campaigns.DefaultCampaignID)
|
||||||
|
}
|
||||||
|
if campaignID == "" {
|
||||||
|
return "", fmt.Errorf("no campaign selected; pass --campaign <id> or set pipeline.campaigns.default_campaign_id")
|
||||||
|
}
|
||||||
|
if err := validateCampaignIDToken(campaignID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if pipelineCfg == nil || strings.TrimSpace(pipelineCfg.Campaigns.Root) == "" {
|
||||||
|
return "", fmt.Errorf("pipeline.campaigns.root is required to select campaign %q", campaignID)
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(pipelineCfg.Campaigns.Root, campaignID, "campaign.yml")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveCampaignConfigPathWithCandidates(flagValue string, candidates []string) (string, error) {
|
func validateCampaignIDToken(campaignID string) error {
|
||||||
if explicit := strings.TrimSpace(flagValue); explicit != "" {
|
if filepath.IsAbs(campaignID) ||
|
||||||
return explicit, nil
|
strings.Contains(campaignID, "/") ||
|
||||||
|
strings.Contains(campaignID, `\`) ||
|
||||||
|
campaignID == "." ||
|
||||||
|
campaignID == ".." {
|
||||||
|
return fmt.Errorf("campaign id %q must be a single path segment", campaignID)
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
ordered := make([]string, 0, len(candidates))
|
|
||||||
for _, raw := range candidates {
|
|
||||||
path := strings.TrimSpace(raw)
|
|
||||||
if path == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ordered = append(ordered, path)
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err == nil {
|
|
||||||
if info.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return filepath.Clean(path), nil
|
|
||||||
}
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("check default campaign config %q: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ordered) == 0 {
|
|
||||||
return "", fmt.Errorf("no campaign config path provided and no default locations configured")
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"no campaign config path provided and no default campaign config found; searched: %s; pass --campaign to use an explicit path",
|
|
||||||
strings.Join(ordered, ", "),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,84 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveCampaignConfigPathExplicitWins(t *testing.T) {
|
func TestResolveCampaignConfigPathCampaignFileWins(t *testing.T) {
|
||||||
explicit := filepath.Join(t.TempDir(), "custom-campaign.yml")
|
explicit := filepath.Join(t.TempDir(), "custom-campaign.yml")
|
||||||
got, err := resolveCampaignConfigPathWithCandidates(explicit, []string{filepath.Join(t.TempDir(), "campaign.yml")})
|
got, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "", explicit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err)
|
t.Fatalf("resolveCampaignConfigPath() error = %v", err)
|
||||||
}
|
}
|
||||||
if got != explicit {
|
if got != explicit {
|
||||||
t.Fatalf("path = %q, want explicit path %q", got, explicit)
|
t.Fatalf("path = %q, want explicit path %q", got, explicit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCampaignConfigPathUsesFirstExistingDefault(t *testing.T) {
|
func TestResolveCampaignConfigPathUsesSelectedCampaignID(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
missing := filepath.Join(dir, "missing.yml")
|
pipelineCfg := &config.PipelineConfig{}
|
||||||
found := filepath.Join(dir, "campaign.yml")
|
pipelineCfg.Campaigns.Root = dir
|
||||||
if err := os.WriteFile(found, []byte("campaign: sample-campaign\n"), 0o644); err != nil {
|
|
||||||
t.Fatalf("write campaign.yml: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := resolveCampaignConfigPathWithCandidates("", []string{missing, found})
|
got, err := resolveCampaignConfigPath(pipelineCfg, "icewind", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("resolveCampaignConfigPathWithCandidates() error = %v", err)
|
t.Fatalf("resolveCampaignConfigPath() error = %v", err)
|
||||||
}
|
}
|
||||||
if got != filepath.Clean(found) {
|
want := filepath.Join(dir, "icewind", "campaign.yml")
|
||||||
t.Fatalf("path = %q, want %q", got, filepath.Clean(found))
|
if got != filepath.Clean(want) {
|
||||||
|
t.Fatalf("path = %q, want %q", got, filepath.Clean(want))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCampaignConfigPathErrorIncludesSearchedPaths(t *testing.T) {
|
func TestResolveCampaignConfigPathUsesDefaultCampaignID(t *testing.T) {
|
||||||
_, err := resolveCampaignConfigPathWithCandidates("", []string{"/usr/local/etc/narratio/campaign.yml", "/etc/narratio/campaign.yml"})
|
dir := t.TempDir()
|
||||||
|
pipelineCfg := &config.PipelineConfig{}
|
||||||
|
pipelineCfg.Campaigns.Root = dir
|
||||||
|
pipelineCfg.Campaigns.DefaultCampaignID = "dilfs"
|
||||||
|
|
||||||
|
got, err := resolveCampaignConfigPath(pipelineCfg, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveCampaignConfigPath() error = %v", err)
|
||||||
|
}
|
||||||
|
want := filepath.Join(dir, "dilfs", "campaign.yml")
|
||||||
|
if got != filepath.Clean(want) {
|
||||||
|
t.Fatalf("path = %q, want %q", got, filepath.Clean(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCampaignConfigPathRejectsCampaignIDAndFile(t *testing.T) {
|
||||||
|
_, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "dilfs", filepath.Join(t.TempDir(), "campaign.yml"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "searched") {
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
||||||
t.Fatalf("error = %q, want searched paths", err.Error())
|
t.Fatalf("error = %q, want mutual exclusion", err.Error())
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "pass --campaign") {
|
}
|
||||||
t.Fatalf("error = %q, want explicit-campaign guidance", err.Error())
|
|
||||||
|
func TestResolveCampaignConfigPathRequiresCampaignSelection(t *testing.T) {
|
||||||
|
_, err := resolveCampaignConfigPath(&config.PipelineConfig{}, "", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no campaign selected") {
|
||||||
|
t.Fatalf("error = %q, want missing selection guidance", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveCampaignConfigPathRejectsPathLikeCampaignID(t *testing.T) {
|
||||||
|
pipelineCfg := &config.PipelineConfig{}
|
||||||
|
pipelineCfg.Campaigns.Root = t.TempDir()
|
||||||
|
|
||||||
|
_, err := resolveCampaignConfigPath(pipelineCfg, "../icewind", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "single path segment") {
|
||||||
|
t.Fatalf("error = %q, want path segment guidance", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
// Clean removes local workspace/spool state while preserving durable cache
|
// Clean removes local workspace/spool state while preserving durable cache
|
||||||
// state unless cache cleanup is explicitly requested.
|
// state unless cache cleanup is explicitly requested.
|
||||||
func Clean(ctx context.Context, args []string, out io.Writer) error {
|
func Clean(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("clean", flag.ContinueOnError)
|
fs := flag.NewFlagSet("clean", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var flags commonConfigFlags
|
var flags commonConfigFlags
|
||||||
@@ -29,8 +30,17 @@ func Clean(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("clean: invalid flags: %w", err)
|
return fmt.Errorf("clean: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("clean: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("clean", fs, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("clean: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("clean", positionalSessionID, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if all {
|
if all {
|
||||||
return cleanAllLocal(flags, dryRun, clearCache, out)
|
return cleanAllLocal(flags, dryRun, clearCache, out)
|
||||||
@@ -40,9 +50,9 @@ func Clean(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error {
|
func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error {
|
||||||
if strings.TrimSpace(flags.sessionID) == "" {
|
if strings.TrimSpace(flags.sessionID) == "" {
|
||||||
return fmt.Errorf("clean: --session-id is required unless --all is set")
|
return fmt.Errorf("clean: session_id is required unless --all is set")
|
||||||
}
|
}
|
||||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions())
|
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("clean: %w", err)
|
return fmt.Errorf("clean: %w", err)
|
||||||
}
|
}
|
||||||
@@ -82,10 +92,11 @@ func cleanSession(ctx context.Context, flags commonConfigFlags, dryRun, clearCac
|
|||||||
|
|
||||||
func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error {
|
func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writer) error {
|
||||||
if strings.TrimSpace(flags.campaignPath) != "" ||
|
if strings.TrimSpace(flags.campaignPath) != "" ||
|
||||||
|
strings.TrimSpace(flags.campaignFilePath) != "" ||
|
||||||
strings.TrimSpace(flags.sessionPath) != "" ||
|
strings.TrimSpace(flags.sessionPath) != "" ||
|
||||||
strings.TrimSpace(flags.sessionID) != "" ||
|
strings.TrimSpace(flags.sessionID) != "" ||
|
||||||
strings.TrimSpace(flags.previousSessionID) != "" {
|
strings.TrimSpace(flags.previousSessionID) != "" {
|
||||||
return fmt.Errorf("clean: --all cannot be combined with --campaign, --session, --session-id, or --previous-session-id")
|
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
|
||||||
}
|
}
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func TestExecuteCleanSessionDeletesWorkAndSpoolButPreservesCache(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
|
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,7 @@ func TestExecuteCleanSessionDryRunDeletesNothing(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--dry-run"}, &stdout, &stderr)
|
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ func TestExecuteCleanMissingSessionPathsSucceeds(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
|
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ inputs:
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--clear-cache"}, &stdout, &stderr)
|
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ func TestExecuteCleanLocalAudioClearCacheIsNoop(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "--clear-cache"}, &stdout, &stderr)
|
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--clear-cache"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -184,7 +184,7 @@ func TestExecuteCleanAllRejectsSessionScopedFlags(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"clean", "--config", pipelinePath, "--campaign", campaignPath, "--all"}, &stdout, &stderr)
|
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--all"}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -200,7 +200,7 @@ func TestCleanRequiresSessionID(t *testing.T) {
|
|||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "--session-id is required unless --all is set") {
|
if !strings.Contains(stderr.String(), "session_id is required unless --all is set") {
|
||||||
t.Fatalf("stderr = %q, want missing session-id", stderr.String())
|
t.Fatalf("stderr = %q, want missing session-id", stderr.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage", "analyze", "restore", "session", "artifacts", "locks", "clean"}
|
var supportedCommands = []string{"run", "run-stage", "resume", "analyze", "publish", "clean", "session"}
|
||||||
|
|
||||||
// Execute dispatches CLI commands and returns a process exit code.
|
// Execute dispatches CLI commands and returns a process exit code.
|
||||||
func Execute(args []string, stdout, stderr io.Writer) int {
|
func Execute(args []string, stdout, stderr io.Writer) int {
|
||||||
@@ -24,24 +24,16 @@ func Execute(args []string, stdout, stderr io.Writer) int {
|
|||||||
switch cmd {
|
switch cmd {
|
||||||
case "run":
|
case "run":
|
||||||
err = Run(ctx, cmdArgs, stdout)
|
err = Run(ctx, cmdArgs, stdout)
|
||||||
case "plan":
|
|
||||||
err = Plan(ctx, cmdArgs, stdout)
|
|
||||||
case "status":
|
|
||||||
err = Status(ctx, cmdArgs, stdout)
|
|
||||||
case "resume":
|
case "resume":
|
||||||
err = Resume(ctx, cmdArgs, stdout)
|
err = Resume(ctx, cmdArgs, stdout)
|
||||||
case "run-stage":
|
case "run-stage":
|
||||||
err = RunStage(ctx, cmdArgs, stdout)
|
err = RunStage(ctx, cmdArgs, stdout)
|
||||||
case "analyze":
|
case "analyze":
|
||||||
err = Analyze(ctx, cmdArgs, stdout)
|
err = Analyze(ctx, cmdArgs, stdout)
|
||||||
case "restore":
|
case "publish":
|
||||||
err = Restore(ctx, cmdArgs, stdout)
|
err = Publish(ctx, cmdArgs, stdout)
|
||||||
case "session":
|
case "session":
|
||||||
err = Session(ctx, cmdArgs, stdout)
|
err = Session(ctx, cmdArgs, stdout)
|
||||||
case "artifacts":
|
|
||||||
err = Artifacts(ctx, cmdArgs, stdout)
|
|
||||||
case "locks":
|
|
||||||
err = Locks(ctx, cmdArgs, stdout)
|
|
||||||
case "clean":
|
case "clean":
|
||||||
err = Clean(ctx, cmdArgs, stdout)
|
err = Clean(ctx, cmdArgs, stdout)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -25,18 +25,17 @@ func TestExecuteValidCommands(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
||||||
manifestPath := writeManifestPathForExecute(t)
|
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
args []string
|
args []string
|
||||||
wantOut string
|
wantOut string
|
||||||
}{
|
}{
|
||||||
{name: "run", args: []string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
|
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
|
||||||
{name: "plan", args: []string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\narchive: skip\nnotify: skip"},
|
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
|
||||||
{name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"},
|
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
||||||
{name: "resume", args: []string{"resume", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
|
{name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
|
||||||
{name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -64,13 +63,13 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
|||||||
args []string
|
args []string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "run missing flags", args: []string{"run"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
{name: "run missing session", args: []string{"run"}, want: "run: session_id is required"},
|
||||||
{name: "plan missing flags", args: []string{"plan"}, want: "plan: no pipeline config path provided and no default pipeline config found; searched:"},
|
{name: "plan old top-level removed", args: []string{"plan"}, want: `unknown command: "plan"`},
|
||||||
{name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"},
|
{name: "status old top-level removed", args: []string{"status"}, want: `unknown command: "status"`},
|
||||||
{name: "resume missing flags", args: []string{"resume"}, want: "resume: no pipeline config path provided and no default pipeline config found; searched:"},
|
{name: "resume missing session", args: []string{"resume"}, want: "resume: session_id is required"},
|
||||||
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected exactly one stage name"},
|
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected stage name and session_id"},
|
||||||
{name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: no pipeline config path provided and no default pipeline config found; searched:"},
|
{name: "run-stage missing session", args: []string{"run-stage", "polish"}, want: "run-stage: expected stage name and session_id"},
|
||||||
{name: "run missing config uses defaults", args: []string{"run", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
{name: "run missing config uses defaults", args: []string{"run", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -99,7 +98,7 @@ func TestExecuteRunStageUnknownFails(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "unknown"}, &stdout, &stderr)
|
code := Execute([]string{"run-stage", "unknown", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -108,16 +107,32 @@ func TestExecuteRunStageUnknownFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) {
|
func TestExecuteRunStageArchiveAliasFails(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe")
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe")
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1}]}`)
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "normalize"}, &stdout, &stderr)
|
code := Execute([]string{"run-stage", "archive", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), `unknown stage "archive"`) {
|
||||||
|
t.Fatalf("stderr = %q, want unknown archive stage error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRunStageNormalizeIsAccepted(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, "https://example.com/transcribe")
|
||||||
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1}]}`)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := Execute([]string{"run-stage", "normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -141,14 +156,14 @@ func TestExecuteRunStageTranscribeUsesConfiguredWhisperXServer(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "prepare"}, &stdout, &stderr)
|
code := Execute([]string{"run-stage", "prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("prepare exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
|
|
||||||
code = Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "transcribe"}, &stdout, &stderr)
|
code = Execute([]string{"run-stage", "transcribe", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("transcribe exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -203,8 +218,6 @@ seriatim:
|
|||||||
audita:
|
audita:
|
||||||
binary: ` + auditaBinary + `
|
binary: ` + auditaBinary + `
|
||||||
llm_api_key_env: OPENROUTER_API_KEY
|
llm_api_key_env: OPENROUTER_API_KEY
|
||||||
analyzer:
|
|
||||||
timeout: 20m
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
`
|
`
|
||||||
@@ -235,12 +248,12 @@ inputs:
|
|||||||
})
|
})
|
||||||
|
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", sessionID)
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", sessionID)
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"schema":"seriatim-intermediate","segments":[]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"schema":"seriatim-intermediate","segments":[]}`)
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "[]\n")
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &stdout, &stderr)
|
code := Execute([]string{"run-stage", "polish", sessionID, "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -268,8 +281,6 @@ seriatim:
|
|||||||
binary: seriatim
|
binary: seriatim
|
||||||
audita:
|
audita:
|
||||||
binary: audita
|
binary: audita
|
||||||
analyzer:
|
|
||||||
timeout: 20m
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
`
|
`
|
||||||
@@ -290,7 +301,7 @@ inputs:
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"run", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -309,17 +320,15 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
|
|||||||
|
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
||||||
originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
||||||
originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
|
|
||||||
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
|
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
|
||||||
config.DefaultCampaignConfigSearchPaths = []string{campaignPath}
|
|
||||||
defer func() {
|
defer func() {
|
||||||
config.DefaultPipelineConfigSearchPaths = originalDefaults
|
config.DefaultPipelineConfigSearchPaths = originalDefaults
|
||||||
config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults
|
|
||||||
}()
|
}()
|
||||||
|
_ = campaignPath
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"run", "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"run", "2026-05-03", "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -328,30 +337,83 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteMissingCampaignConfigReportsSearchedPaths(t *testing.T) {
|
func TestExecuteMissingCampaignConfigReportsRegistryPath(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
missingCampaignPath := filepath.Join(t.TempDir(), "campaign.yml")
|
if err := os.Remove(campaignPath); err != nil {
|
||||||
originalCampaignDefaults := append([]string(nil), config.DefaultCampaignConfigSearchPaths...)
|
t.Fatalf("remove campaign config: %v", err)
|
||||||
config.DefaultCampaignConfigSearchPaths = []string{missingCampaignPath}
|
}
|
||||||
defer func() {
|
|
||||||
config.DefaultCampaignConfigSearchPaths = originalCampaignDefaults
|
|
||||||
}()
|
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"run", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"run", "2026-05-03", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
if stdout.Len() != 0 {
|
if stdout.Len() != 0 {
|
||||||
t.Fatalf("stdout = %q, want empty", stdout.String())
|
t.Fatalf("stdout = %q, want empty", stdout.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "no campaign config path provided and no default campaign config found; searched:") {
|
if !strings.Contains(stderr.String(), "load campaign config") {
|
||||||
t.Fatalf("stderr = %q, want campaign discovery failure", stderr.String())
|
t.Fatalf("stderr = %q, want campaign discovery failure", stderr.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "pass --campaign") {
|
if !strings.Contains(stderr.String(), filepath.ToSlash(filepath.Join("campaigns", "sample-campaign", "campaign.yml"))) {
|
||||||
t.Fatalf("stderr = %q, want explicit campaign guidance", stderr.String())
|
t.Fatalf("stderr = %q, want campaign registry path", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteUsesPipelineDefaultCampaignID(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, _, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "Campaign: sample-campaign") {
|
||||||
|
t.Fatalf("stdout = %q, want default campaign", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteCampaignIDSelectsRegistryCampaign(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
campaignRoot := filepath.Dir(filepath.Dir(campaignPath))
|
||||||
|
otherDir := filepath.Join(campaignRoot, "icewind")
|
||||||
|
mustWriteTestFile(t, filepath.Join(otherDir, "campaign.yml"), `campaign_id: icewind
|
||||||
|
inputs:
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`)
|
||||||
|
mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", "icewind", "--session", sessionPath}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "Campaign: icewind") {
|
||||||
|
t.Fatalf("stdout = %q, want selected campaign", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRejectsCampaignIDAndCampaignFile(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "mutually exclusive") {
|
||||||
|
t.Fatalf("stderr = %q, want mutually exclusive error", stderr.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +458,9 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...
|
|||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
campaignPath := filepath.Join(dir, "campaign.yml")
|
campaignRoot := filepath.Join(dir, "campaigns")
|
||||||
|
campaignDir := filepath.Join(campaignRoot, "sample-campaign")
|
||||||
|
campaignPath := filepath.Join(campaignDir, "campaign.yml")
|
||||||
sessionPath := filepath.Join(dir, "session.yml")
|
sessionPath := filepath.Join(dir, "session.yml")
|
||||||
url := "https://example.com/transcribe"
|
url := "https://example.com/transcribe"
|
||||||
if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" {
|
if len(transcribeURL) > 0 && strings.TrimSpace(transcribeURL[0]) != "" {
|
||||||
@@ -410,6 +474,9 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...
|
|||||||
|
|
||||||
pipelineYAML := `workspace:
|
pipelineYAML := `workspace:
|
||||||
root: ` + workspaceRoot + `
|
root: ` + workspaceRoot + `
|
||||||
|
campaigns:
|
||||||
|
root: ` + campaignRoot + `
|
||||||
|
default_campaign_id: sample-campaign
|
||||||
cache:
|
cache:
|
||||||
root: ` + filepath.Join(workspaceRoot, "cache") + `
|
root: ` + filepath.Join(workspaceRoot, "cache") + `
|
||||||
spool:
|
spool:
|
||||||
@@ -418,7 +485,7 @@ storage:
|
|||||||
backend: s3
|
backend: s3
|
||||||
s3:
|
s3:
|
||||||
bucket: test-bucket
|
bucket: test-bucket
|
||||||
archive:
|
publish:
|
||||||
enabled: true
|
enabled: true
|
||||||
upload_run: false
|
upload_run: false
|
||||||
whisperx:
|
whisperx:
|
||||||
@@ -435,10 +502,6 @@ seriatim:
|
|||||||
report: true
|
report: true
|
||||||
audita:
|
audita:
|
||||||
binary: ` + auditaBinary + `
|
binary: ` + auditaBinary + `
|
||||||
analyzer:
|
|
||||||
timeout: 20m
|
|
||||||
artifacts:
|
|
||||||
output_dir: artifacts
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
`
|
`
|
||||||
@@ -447,7 +510,7 @@ notification:
|
|||||||
inputs:
|
inputs:
|
||||||
audio_dir: ./audio
|
audio_dir: ./audio
|
||||||
`
|
`
|
||||||
campaignYAML := `campaign: sample-campaign
|
campaignYAML := `campaign_id: sample-campaign
|
||||||
inputs:
|
inputs:
|
||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
@@ -457,6 +520,9 @@ inputs:
|
|||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline config: %v", err)
|
t.Fatalf("write pipeline config: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(campaignDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("create campaign dir: %v", err)
|
||||||
|
}
|
||||||
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write campaign config: %v", err)
|
t.Fatalf("write campaign config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -464,9 +530,9 @@ inputs:
|
|||||||
t.Fatalf("write session config: %v", err)
|
t.Fatalf("write session config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
|
mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n")
|
||||||
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
|
||||||
|
|
||||||
return pipelinePath, campaignPath, sessionPath
|
return pipelinePath, campaignPath, sessionPath
|
||||||
@@ -475,7 +541,7 @@ inputs:
|
|||||||
func writeAppTestCampaignConfig(t *testing.T, dir string) string {
|
func writeAppTestCampaignConfig(t *testing.T, dir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
campaignPath := filepath.Join(dir, "campaign.yml")
|
campaignPath := filepath.Join(dir, "campaign.yml")
|
||||||
campaignYAML := `campaign: sample-campaign
|
campaignYAML := `campaign_id: sample-campaign
|
||||||
inputs:
|
inputs:
|
||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
|||||||
@@ -12,18 +12,21 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) {
|
type pipelineCampaignConfig struct {
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
PipelinePath string
|
||||||
if err != nil {
|
CampaignPath string
|
||||||
return nil, err
|
Pipeline *config.PipelineConfig
|
||||||
}
|
Campaign *config.CampaignConfig
|
||||||
resolvedCampaignPath, err := resolveCampaignConfigPath(campaignFlag)
|
}
|
||||||
|
|
||||||
|
func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaignFileFlag, sessionFlag string, sessionOpts config.SessionLoadOptions) (*config.Config, error) {
|
||||||
|
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" {
|
if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" {
|
||||||
return config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, explicitSession, sessionOpts)
|
return config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, explicitSession, sessionOpts)
|
||||||
}
|
}
|
||||||
|
|
||||||
discoveredSession, err := discoverSessionConfigPathWithCandidates(config.DefaultSessionConfigSearchPaths)
|
discoveredSession, err := discoverSessionConfigPathWithCandidates(config.DefaultSessionConfigSearchPaths)
|
||||||
@@ -31,30 +34,21 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if discoveredSession.Path != "" {
|
if discoveredSession.Path != "" {
|
||||||
return config.LoadWithSessionOptions(resolvedPipelinePath, resolvedCampaignPath, discoveredSession.Path, sessionOpts)
|
return config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, discoveredSession.Path, sessionOpts)
|
||||||
}
|
|
||||||
|
|
||||||
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
campaignCfg, err := config.LoadCampaign(resolvedCampaignPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionID := strings.TrimSpace(sessionOpts.SessionID)
|
sessionID := strings.TrimSpace(sessionOpts.SessionID)
|
||||||
if sessionID == "" {
|
if sessionID == "" {
|
||||||
return nil, missingSessionConfigError(discoveredSession.Searched, "remote session loading requires --session-id")
|
return nil, missingSessionConfigError(discoveredSession.Searched, "remote session loading requires a session_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionPrefix := artifacts.S3SessionPrefix(pipelineCfg.Storage.S3.RootPrefix, campaignCfg.Campaign, sessionID)
|
sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID)
|
||||||
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
|
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
|
||||||
partialCfg := &config.Config{
|
partialCfg := &config.Config{
|
||||||
Pipeline: pipelineCfg,
|
Pipeline: base.Pipeline,
|
||||||
Campaign: campaignCfg,
|
Campaign: base.Campaign,
|
||||||
PipelinePath: resolvedPipelinePath,
|
PipelinePath: base.PipelinePath,
|
||||||
CampaignPath: resolvedCampaignPath,
|
CampaignPath: base.CampaignPath,
|
||||||
}
|
}
|
||||||
store, err := newCommandObjectStore(ctx, partialCfg, nil)
|
store, err := newCommandObjectStore(ctx, partialCfg, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -73,22 +67,22 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read downloaded remote session %q: %w", sessionTempPath, err)
|
return nil, fmt.Errorf("read downloaded remote session %q: %w", sessionTempPath, err)
|
||||||
}
|
}
|
||||||
sessionCfg, err := config.LoadSessionBytesWithOptions("s3://"+s3BucketName(pipelineCfg)+"/"+remoteKey, sessionBytes, sessionOpts)
|
sessionCfg, err := config.LoadSessionBytesWithOptions("s3://"+s3BucketName(base.Pipeline)+"/"+remoteKey, sessionBytes, sessionOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return config.Resolve(
|
return config.Resolve(
|
||||||
resolvedPipelinePath,
|
base.PipelinePath,
|
||||||
pipelineCfg,
|
base.Pipeline,
|
||||||
resolvedCampaignPath,
|
base.CampaignPath,
|
||||||
campaignCfg,
|
base.Campaign,
|
||||||
sessionTempPath,
|
sessionTempPath,
|
||||||
sessionCfg,
|
sessionCfg,
|
||||||
config.SessionSource{
|
config.SessionSource{
|
||||||
Source: "session_config.s3",
|
Source: "session_config.s3",
|
||||||
LocalPath: sessionTempPath,
|
LocalPath: sessionTempPath,
|
||||||
S3Bucket: s3BucketName(pipelineCfg),
|
S3Bucket: s3BucketName(base.Pipeline),
|
||||||
S3Key: remoteKey,
|
S3Key: remoteKey,
|
||||||
S3Size: sessionInfo.Size,
|
S3Size: sessionInfo.Size,
|
||||||
S3ETag: sessionInfo.ETag,
|
S3ETag: sessionInfo.ETag,
|
||||||
@@ -97,6 +91,36 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, sessionF
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) {
|
||||||
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolvedCampaignPath, err := resolveCampaignConfigPath(pipelineCfg, campaignFlag, campaignFileFlag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
campaignCfg, err := config.LoadCampaign(resolvedCampaignPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if selectedID := strings.TrimSpace(campaignFlag); selectedID != "" && strings.TrimSpace(campaignFileFlag) == "" {
|
||||||
|
if got := config.CampaignID(campaignCfg); got != selectedID {
|
||||||
|
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &pipelineCampaignConfig{
|
||||||
|
PipelinePath: resolvedPipelinePath,
|
||||||
|
CampaignPath: resolvedCampaignPath,
|
||||||
|
Pipeline: pipelineCfg,
|
||||||
|
Campaign: campaignCfg,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) {
|
func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) {
|
||||||
objects, err := store.List(ctx, sessionPrefix)
|
objects, err := store.List(ctx, sessionPrefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
type commonConfigFlags struct {
|
type commonConfigFlags struct {
|
||||||
pipelinePath string
|
pipelinePath string
|
||||||
campaignPath string
|
campaignPath string
|
||||||
|
campaignFilePath string
|
||||||
sessionPath string
|
sessionPath string
|
||||||
sessionID string
|
sessionID string
|
||||||
previousSessionID string
|
previousSessionID string
|
||||||
@@ -42,10 +43,10 @@ func (e findingError) Error() string {
|
|||||||
|
|
||||||
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
||||||
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&flags.campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
|
||||||
|
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||||
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
|
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
|
||||||
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier for session.yml templates")
|
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||||
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
||||||
@@ -58,18 +59,42 @@ func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
|||||||
// Session dispatches session helper subcommands.
|
// Session dispatches session helper subcommands.
|
||||||
func Session(ctx context.Context, args []string, out io.Writer) error {
|
func Session(ctx context.Context, args []string, out io.Writer) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("session: expected subcommand: validate|init")
|
return fmt.Errorf("session: expected subcommand: init|validate|status|plan|restore|artifacts|locks")
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "validate":
|
|
||||||
return SessionValidate(ctx, args[1:], out)
|
|
||||||
case "init":
|
case "init":
|
||||||
return SessionInit(ctx, args[1:], out)
|
return SessionInit(ctx, args[1:], out)
|
||||||
|
case "validate":
|
||||||
|
return SessionValidate(ctx, args[1:], out)
|
||||||
|
case "status":
|
||||||
|
return Status(ctx, args[1:], out)
|
||||||
|
case "plan":
|
||||||
|
return Plan(ctx, args[1:], out)
|
||||||
|
case "restore":
|
||||||
|
return Restore(ctx, args[1:], out)
|
||||||
|
case "artifacts":
|
||||||
|
return ArtifactsList(ctx, args[1:], out)
|
||||||
|
case "locks":
|
||||||
|
return SessionLocks(ctx, args[1:], out)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("session: unknown subcommand %q", args[0])
|
return fmt.Errorf("session: unknown subcommand %q", args[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionLocks dispatches session-oriented archive lock list and mutation
|
||||||
|
// helpers while preserving the existing lock implementations.
|
||||||
|
func SessionLocks(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
if len(args) > 0 && !isCLIFlagToken(args[0]) {
|
||||||
|
switch args[0] {
|
||||||
|
case "add":
|
||||||
|
return LocksAdd(ctx, args[1:], out)
|
||||||
|
case "remove":
|
||||||
|
return LocksRemove(ctx, args[1:], out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LocksList(ctx, args, out)
|
||||||
|
}
|
||||||
|
|
||||||
// Artifacts dispatches artifact helper subcommands.
|
// Artifacts dispatches artifact helper subcommands.
|
||||||
func Artifacts(ctx context.Context, args []string, out io.Writer) error {
|
func Artifacts(ctx context.Context, args []string, out io.Writer) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
@@ -85,6 +110,7 @@ func Artifacts(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
// SessionValidate performs a read-only session preflight.
|
// SessionValidate performs a read-only session preflight.
|
||||||
func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
|
func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("session validate", flag.ContinueOnError)
|
fs := flag.NewFlagSet("session validate", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var flags commonConfigFlags
|
var flags commonConfigFlags
|
||||||
@@ -92,12 +118,24 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("session validate: invalid flags: %w", err)
|
return fmt.Errorf("session validate: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("session validate: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("session validate", fs, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("session validate: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("session validate", positionalSessionID, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(flags.sessionID) == "" {
|
||||||
|
return fmt.Errorf("session validate: session_id is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
findings := []finding{}
|
findings := []finding{}
|
||||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions())
|
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
findings = append(findings, errorFinding("config", err.Error()))
|
findings = append(findings, errorFinding("config", err.Error()))
|
||||||
return renderFindings(out, "", "", findings)
|
return renderFindings(out, "", "", findings)
|
||||||
@@ -152,27 +190,32 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
return renderFindings(out, cfg.Session.Campaign, cfg.Session.SessionID, findings)
|
return renderFindings(out, cfg.Session.Campaign, cfg.Session.SessionID, findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status reports either a requested manifest or effective local/remote session state.
|
// Status reports effective local/remote session state.
|
||||||
func Status(ctx context.Context, args []string, out io.Writer) error {
|
func Status(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var manifestPath string
|
|
||||||
var flags commonConfigFlags
|
var flags commonConfigFlags
|
||||||
fs.StringVar(&manifestPath, "manifest", "", "path to manifest.json")
|
|
||||||
addCommonConfigFlags(fs, &flags)
|
addCommonConfigFlags(fs, &flags)
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("status: invalid flags: %w", err)
|
return fmt.Errorf("status: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("status: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("status", fs, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("status: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("status", positionalSessionID, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(manifestPath) != "" {
|
if strings.TrimSpace(flags.sessionID) == "" {
|
||||||
return statusManifest(ctx, manifestPath, out)
|
return fmt.Errorf("status: session_id is required")
|
||||||
}
|
}
|
||||||
if flags.pipelinePath == "" && flags.campaignPath == "" && flags.sessionPath == "" && flags.sessionID == "" && flags.previousSessionID == "" {
|
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
return fmt.Errorf("status: --manifest is required")
|
|
||||||
}
|
|
||||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("status: %w", err)
|
return fmt.Errorf("status: %w", err)
|
||||||
}
|
}
|
||||||
@@ -196,13 +239,13 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
store, storeErr := objectStoreIfConfigured(ctx, cfg)
|
store, storeErr := objectStoreIfConfigured(ctx, cfg)
|
||||||
if storeErr != nil {
|
if storeErr != nil {
|
||||||
fmt.Fprintf(out, "Remote archive: unavailable: %v\n", storeErr)
|
fmt.Fprintf(out, "Remote publish: unavailable: %v\n", storeErr)
|
||||||
} else if store != nil {
|
} else if store != nil {
|
||||||
current, err := discoverRemoteCurrentStateFn(ctx, cfg, store)
|
current, err := discoverRemoteCurrentStateFn(ctx, cfg, store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(out, "Remote archive: missing or unavailable: %v\n", err)
|
fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(out, "Remote archive: current run %s\n", current.RunID)
|
fmt.Fprintf(out, "Remote publish: current run %s\n", current.RunID)
|
||||||
fmt.Fprintf(out, "Remote manifest: %s\n", current.CurrentManifestKey)
|
fmt.Fprintf(out, "Remote manifest: %s\n", current.CurrentManifestKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,49 +261,34 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
All: staticArchiveLocks(cfg),
|
All: staticArchiveLocks(cfg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
promotedRemoteState := map[string]string{}
|
publishedRemoteState := map[string]string{}
|
||||||
if store != nil {
|
if store != nil {
|
||||||
promotedRemoteState = remotePromotionAvailability(ctx, cfg, store, catalog)
|
publishedRemoteState = remotePublishedOutputAvailability(ctx, cfg, store, catalog)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(out, "Remote outputs:")
|
fmt.Fprintln(out, "Remote outputs:")
|
||||||
writeArtifactList(out, cfg, catalog, catalogLocks, promotedRemoteState)
|
writeArtifactList(out, cfg, catalog, catalogLocks, publishedRemoteState)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(out, "Archive locks: error: %v\n", err)
|
fmt.Fprintf(out, "Publish locks: error: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
writeLocks(out, cfg, locks)
|
writeLocks(out, cfg, locks)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(out, "Next actions:")
|
fmt.Fprintln(out, "Next actions:")
|
||||||
fmt.Fprintf(out, "- narratio session validate --session-id %s\n", cfg.Session.SessionID)
|
fmt.Fprintf(out, "- narratio session validate %s\n", cfg.Session.SessionID)
|
||||||
fmt.Fprintf(out, "- narratio restore --session-id %s --dry-run\n", cfg.Session.SessionID)
|
fmt.Fprintf(out, "- narratio session restore %s --dry-run\n", cfg.Session.SessionID)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func statusManifest(ctx context.Context, manifestPath string, out io.Writer) error {
|
|
||||||
store := &manifest.LocalStore{}
|
|
||||||
m, err := store.Load(ctx, manifestPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("status: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := fmt.Fprintf(out, "session_id: %s\n", m.SessionID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := fmt.Fprintf(out, "updated_at: %s\n", m.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
writeStageStatuses(out, m)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionInit creates a local or remote session.yml skeleton.
|
// SessionInit creates a local or remote session.yml skeleton.
|
||||||
func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
|
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var pipelinePath, campaignPath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
||||||
var remote, force bool
|
var remote, force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml")
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||||
fs.StringVar(&sessionID, "session-id", "", "session identifier")
|
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier")
|
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier")
|
||||||
fs.StringVar(&date, "date", "", "session date")
|
fs.StringVar(&date, "date", "", "session date")
|
||||||
fs.StringVar(&title, "title", "", "session title")
|
fs.StringVar(&title, "title", "", "session title")
|
||||||
@@ -272,11 +300,20 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("session init: invalid flags: %w", err)
|
return fmt.Errorf("session init: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("session init: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("session init", fs, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("session init: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("session init", positionalSessionID, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(pipelinePath) == "" || strings.TrimSpace(campaignPath) == "" || strings.TrimSpace(sessionID) == "" {
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
return fmt.Errorf("session init: --config, --campaign, and --session-id are required")
|
return fmt.Errorf("session init: session_id is required")
|
||||||
}
|
}
|
||||||
if (strings.TrimSpace(output) == "") == !remote {
|
if (strings.TrimSpace(output) == "") == !remote {
|
||||||
return fmt.Errorf("session init: specify exactly one target: --output <path> or --remote")
|
return fmt.Errorf("session init: specify exactly one target: --output <path> or --remote")
|
||||||
@@ -285,24 +322,23 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
|
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedPipeline, err := resolvePipelineConfigPath(pipelinePath)
|
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("session init: %w", err)
|
|
||||||
}
|
|
||||||
resolvedCampaign, err := resolveCampaignConfigPath(campaignPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("session init: %w", err)
|
|
||||||
}
|
|
||||||
pipelineCfg, err := config.LoadPipeline(resolvedPipeline)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("session init: %w", err)
|
|
||||||
}
|
|
||||||
campaignCfg, err := config.LoadCampaign(resolvedCampaign)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
return fmt.Errorf("session init: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := buildSessionYAML(campaignCfg.Campaign, sessionID, previousSessionID, date, title, audioS3Prefix, audioDir)
|
input := sessionInitInput{
|
||||||
|
Campaign: config.CampaignID(base.Campaign),
|
||||||
|
CampaignPath: base.CampaignPath,
|
||||||
|
TemplateFile: base.Campaign.SessionTemplateFile,
|
||||||
|
SessionID: sessionID,
|
||||||
|
PreviousSessionID: previousSessionID,
|
||||||
|
Date: date,
|
||||||
|
Title: title,
|
||||||
|
AudioS3Prefix: audioS3Prefix,
|
||||||
|
AudioDir: audioDir,
|
||||||
|
}
|
||||||
|
data, err := buildSessionInitYAML(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
return fmt.Errorf("session init: %w", err)
|
||||||
}
|
}
|
||||||
@@ -317,7 +353,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
return fmt.Errorf("session init: %w", err)
|
||||||
}
|
}
|
||||||
cfg, err := config.Resolve(resolvedPipeline, pipelineCfg, resolvedCampaign, campaignCfg, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
|
cfg, err := config.Resolve(base.PipelinePath, base.Pipeline, base.CampaignPath, base.Campaign, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
return fmt.Errorf("session init: %w", err)
|
||||||
}
|
}
|
||||||
@@ -337,7 +373,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
return fmt.Errorf("session init: %w", err)
|
||||||
}
|
}
|
||||||
sessionPrefix := artifacts.S3SessionPrefix(pipelineCfg.Storage.S3.RootPrefix, campaignCfg.Campaign, sessionID)
|
sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID)
|
||||||
key := artifacts.S3SessionConfigKey(sessionPrefix)
|
key := artifacts.S3SessionConfigKey(sessionPrefix)
|
||||||
exists, err := store.Exists(ctx, key)
|
exists, err := store.Exists(ctx, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -362,12 +398,13 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if _, err := store.Upload(ctx, tmpPath, key, storage.UploadOptions{ContentType: "application/x-yaml; charset=utf-8"}); err != nil {
|
if _, err := store.Upload(ctx, tmpPath, key, storage.UploadOptions{ContentType: "application/x-yaml; charset=utf-8"}); err != nil {
|
||||||
return fmt.Errorf("session init: upload remote session %q: %w", key, err)
|
return fmt.Errorf("session init: upload remote session %q: %w", key, err)
|
||||||
}
|
}
|
||||||
_, err = fmt.Fprintf(out, "narratio session init: wrote s3://%s/%s\n", s3BucketName(pipelineCfg), key)
|
_, err = fmt.Fprintf(out, "narratio session init: wrote s3://%s/%s\n", s3BucketName(base.Pipeline), key)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactsList lists effective artifact sources.
|
// ArtifactsList lists effective artifact sources.
|
||||||
func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
|
func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("artifacts list", flag.ContinueOnError)
|
fs := flag.NewFlagSet("artifacts list", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var flags commonConfigFlags
|
var flags commonConfigFlags
|
||||||
@@ -377,8 +414,20 @@ func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("artifacts list: invalid flags: %w", err)
|
return fmt.Errorf("artifacts list: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("artifacts list: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("artifacts list", fs, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("artifacts list: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("artifacts list", positionalSessionID, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(flags.sessionID) == "" {
|
||||||
|
return fmt.Errorf("artifacts list: session_id is required")
|
||||||
}
|
}
|
||||||
cfg, store, locks, _, err := loadHelperContext(ctx, flags, remote)
|
cfg, store, locks, _, err := loadHelperContext(ctx, flags, remote)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -388,11 +437,11 @@ func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("artifacts list: %w", err)
|
return fmt.Errorf("artifacts list: %w", err)
|
||||||
}
|
}
|
||||||
promotedRemoteState := map[string]string{}
|
publishedRemoteState := map[string]string{}
|
||||||
if remote && store != nil {
|
if remote && store != nil {
|
||||||
promotedRemoteState = remotePromotionAvailability(ctx, cfg, store, catalog)
|
publishedRemoteState = remotePublishedOutputAvailability(ctx, cfg, store, catalog)
|
||||||
}
|
}
|
||||||
writeArtifactList(out, cfg, catalog, locks, promotedRemoteState)
|
writeArtifactList(out, cfg, catalog, locks, publishedRemoteState)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +462,7 @@ func Locks(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
// LocksList lists effective archive locks.
|
// LocksList lists effective archive locks.
|
||||||
func LocksList(ctx context.Context, args []string, out io.Writer) error {
|
func LocksList(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("locks", flag.ContinueOnError)
|
fs := flag.NewFlagSet("locks", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var flags commonConfigFlags
|
var flags commonConfigFlags
|
||||||
@@ -420,11 +470,20 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("locks: invalid flags: %w", err)
|
return fmt.Errorf("locks: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("locks: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("locks", fs, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("locks: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("locks", positionalSessionID, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(flags.sessionID) == "" {
|
if strings.TrimSpace(flags.sessionID) == "" {
|
||||||
return fmt.Errorf("locks: --session-id is required")
|
return fmt.Errorf("locks: session_id is required")
|
||||||
}
|
}
|
||||||
cfg, _, locks, _, err := loadHelperContext(ctx, flags, true)
|
cfg, _, locks, _, err := loadHelperContext(ctx, flags, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -436,6 +495,13 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
// LocksAdd adds or updates one remote lock.
|
// LocksAdd adds or updates one remote lock.
|
||||||
func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
|
func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
var positionalSessionID string
|
||||||
|
var source string
|
||||||
|
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
|
||||||
|
positionalSessionID = strings.TrimSpace(args[0])
|
||||||
|
source = strings.TrimSpace(args[1])
|
||||||
|
args = append([]string(nil), args[2:]...)
|
||||||
|
}
|
||||||
fs := flag.NewFlagSet("locks add", flag.ContinueOnError)
|
fs := flag.NewFlagSet("locks add", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var flags commonConfigFlags
|
var flags commonConfigFlags
|
||||||
@@ -447,18 +513,26 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("locks add: invalid flags: %w", err)
|
return fmt.Errorf("locks add: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 1 {
|
if source == "" {
|
||||||
return fmt.Errorf("locks add: expected exactly one source id")
|
if fs.NArg() != 2 {
|
||||||
|
return fmt.Errorf("locks add: expected session_id and source id")
|
||||||
|
}
|
||||||
|
positionalSessionID = strings.TrimSpace(fs.Arg(0))
|
||||||
|
source = strings.TrimSpace(fs.Arg(1))
|
||||||
|
} else if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("locks add: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("locks add", positionalSessionID, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(flags.sessionID) == "" {
|
if strings.TrimSpace(flags.sessionID) == "" {
|
||||||
return fmt.Errorf("locks add: --session-id is required")
|
return fmt.Errorf("locks add: session_id is required")
|
||||||
}
|
}
|
||||||
source := strings.TrimSpace(fs.Arg(0))
|
|
||||||
cfg, store, locks, _, err := loadHelperContext(ctx, flags, true)
|
cfg, store, locks, _, err := loadHelperContext(ctx, flags, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("locks add: %w", err)
|
return fmt.Errorf("locks add: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := config.ValidateArchiveLockRules([]config.ArchiveLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, "locks add"); err != nil {
|
if _, err := config.ValidatePublishLockRules([]config.PublishLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, "locks add"); err != nil {
|
||||||
return fmt.Errorf("locks add: %w", err)
|
return fmt.Errorf("locks add: %w", err)
|
||||||
}
|
}
|
||||||
if _, ok := lockSourceSet(locks.Static)[source]; ok {
|
if _, ok := lockSourceSet(locks.Static)[source]; ok {
|
||||||
@@ -468,20 +542,27 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if _, exists := remoteSet[source]; exists && !force {
|
if _, exists := remoteSet[source]; exists && !force {
|
||||||
return fmt.Errorf("locks add: remote lock for %q already exists; pass --force to update", source)
|
return fmt.Errorf("locks add: remote lock for %q already exists; pass --force to update", source)
|
||||||
}
|
}
|
||||||
remoteSet[source] = config.ArchiveLockRule{Source: source, Reason: strings.TrimSpace(reason)}
|
remoteSet[source] = config.PublishLockRule{Source: source, Reason: strings.TrimSpace(reason)}
|
||||||
remoteLocks := lockMapValues(remoteSet)
|
remoteLocks := lockMapValues(remoteSet)
|
||||||
if _, err := config.ValidateArchiveLockRules(remoteLocks, cfg.Pipeline.Scriptorium, "locks"); err != nil {
|
if _, err := config.ValidatePublishLockRules(remoteLocks, cfg.Pipeline.Scriptorium, "locks"); err != nil {
|
||||||
return fmt.Errorf("locks add: %w", err)
|
return fmt.Errorf("locks add: %w", err)
|
||||||
}
|
}
|
||||||
if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.ArchiveLockStore{Locks: remoteLocks}); err != nil {
|
if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.PublishLockStore{Locks: remoteLocks}); err != nil {
|
||||||
return fmt.Errorf("locks add: %w", err)
|
return fmt.Errorf("locks add: %w", err)
|
||||||
}
|
}
|
||||||
_, err = fmt.Fprintf(out, "narratio locks add: locked %s\n", source)
|
_, err = fmt.Fprintf(out, "narratio session locks add: locked %s\n", source)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// LocksRemove removes one remote lock.
|
// LocksRemove removes one remote lock.
|
||||||
func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
|
func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
var positionalSessionID string
|
||||||
|
var source string
|
||||||
|
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
|
||||||
|
positionalSessionID = strings.TrimSpace(args[0])
|
||||||
|
source = strings.TrimSpace(args[1])
|
||||||
|
args = append([]string(nil), args[2:]...)
|
||||||
|
}
|
||||||
fs := flag.NewFlagSet("locks remove", flag.ContinueOnError)
|
fs := flag.NewFlagSet("locks remove", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var flags commonConfigFlags
|
var flags commonConfigFlags
|
||||||
@@ -489,18 +570,26 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("locks remove: invalid flags: %w", err)
|
return fmt.Errorf("locks remove: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 1 {
|
if source == "" {
|
||||||
return fmt.Errorf("locks remove: expected exactly one source id")
|
if fs.NArg() != 2 {
|
||||||
|
return fmt.Errorf("locks remove: expected session_id and source id")
|
||||||
|
}
|
||||||
|
positionalSessionID = strings.TrimSpace(fs.Arg(0))
|
||||||
|
source = strings.TrimSpace(fs.Arg(1))
|
||||||
|
} else if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("locks remove: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("locks remove", positionalSessionID, &flags.sessionID); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(flags.sessionID) == "" {
|
if strings.TrimSpace(flags.sessionID) == "" {
|
||||||
return fmt.Errorf("locks remove: --session-id is required")
|
return fmt.Errorf("locks remove: session_id is required")
|
||||||
}
|
}
|
||||||
source := strings.TrimSpace(fs.Arg(0))
|
|
||||||
cfg, store, locks, _, err := loadHelperContext(ctx, flags, true)
|
cfg, store, locks, _, err := loadHelperContext(ctx, flags, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("locks remove: %w", err)
|
return fmt.Errorf("locks remove: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := config.ValidateArchiveLockRules([]config.ArchiveLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, "locks remove"); err != nil {
|
if _, err := config.ValidatePublishLockRules([]config.PublishLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, "locks remove"); err != nil {
|
||||||
return fmt.Errorf("locks remove: %w", err)
|
return fmt.Errorf("locks remove: %w", err)
|
||||||
}
|
}
|
||||||
remoteSet := lockSourceSet(locks.Remote)
|
remoteSet := lockSourceSet(locks.Remote)
|
||||||
@@ -512,15 +601,15 @@ func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
}
|
}
|
||||||
delete(remoteSet, source)
|
delete(remoteSet, source)
|
||||||
remoteLocks := lockMapValues(remoteSet)
|
remoteLocks := lockMapValues(remoteSet)
|
||||||
if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.ArchiveLockStore{Locks: remoteLocks}); err != nil {
|
if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.PublishLockStore{Locks: remoteLocks}); err != nil {
|
||||||
return fmt.Errorf("locks remove: %w", err)
|
return fmt.Errorf("locks remove: %w", err)
|
||||||
}
|
}
|
||||||
_, err = fmt.Fprintf(out, "narratio locks remove: unlocked %s\n", source)
|
_, err = fmt.Fprintf(out, "narratio session locks remove: unlocked %s\n", source)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore bool) (*config.Config, storage.ObjectStore, *effectiveLocks, *manifest.Manifest, error) {
|
func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore bool) (*config.Config, storage.ObjectStore, *effectiveLocks, *manifest.Manifest, error) {
|
||||||
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.sessionPath, flags.sessionOptions())
|
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, err
|
return nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -600,6 +689,104 @@ func buildSessionYAML(campaign, sessionID, previousSessionID, date, title, audio
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type sessionInitInput struct {
|
||||||
|
Campaign string
|
||||||
|
CampaignPath string
|
||||||
|
TemplateFile string
|
||||||
|
SessionID string
|
||||||
|
PreviousSessionID string
|
||||||
|
Date string
|
||||||
|
Title string
|
||||||
|
AudioS3Prefix string
|
||||||
|
AudioDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSessionInitYAML(in sessionInitInput) ([]byte, error) {
|
||||||
|
if strings.TrimSpace(in.TemplateFile) == "" {
|
||||||
|
return buildSessionYAML(in.Campaign, in.SessionID, in.PreviousSessionID, in.Date, in.Title, in.AudioS3Prefix, in.AudioDir)
|
||||||
|
}
|
||||||
|
templatePath := resolveSessionInitTemplatePath(in.CampaignPath, in.TemplateFile)
|
||||||
|
templateBytes, err := os.ReadFile(templatePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read session template %q: %w", templatePath, err)
|
||||||
|
}
|
||||||
|
rendered, err := renderSessionInitTemplate(string(templateBytes), in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("render session template %q: %w", templatePath, err)
|
||||||
|
}
|
||||||
|
return []byte(rendered), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSessionInitTemplatePath(campaignPath, templateFile string) string {
|
||||||
|
templateFile = strings.TrimSpace(templateFile)
|
||||||
|
if filepath.IsAbs(templateFile) {
|
||||||
|
return filepath.Clean(templateFile)
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(filepath.Dir(campaignPath), templateFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionInitTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
|
||||||
|
|
||||||
|
func renderSessionInitTemplate(content string, in sessionInitInput) (string, error) {
|
||||||
|
values := map[string]string{
|
||||||
|
"session_id": strings.TrimSpace(in.SessionID),
|
||||||
|
"previous_session_id": strings.TrimSpace(in.PreviousSessionID),
|
||||||
|
"date": strings.TrimSpace(in.Date),
|
||||||
|
"title": strings.TrimSpace(in.Title),
|
||||||
|
"audio_s3_prefix": strings.TrimSpace(in.AudioS3Prefix),
|
||||||
|
"audio_dir": strings.TrimSpace(in.AudioDir),
|
||||||
|
}
|
||||||
|
used := map[string]struct{}{}
|
||||||
|
unknown := map[string]struct{}{}
|
||||||
|
missing := map[string]struct{}{}
|
||||||
|
rendered := sessionInitTemplatePattern.ReplaceAllStringFunc(content, func(match string) string {
|
||||||
|
parts := sessionInitTemplatePattern.FindStringSubmatch(match)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
name := parts[1]
|
||||||
|
value, ok := values[name]
|
||||||
|
if !ok {
|
||||||
|
unknown[name] = struct{}{}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
used[name] = struct{}{}
|
||||||
|
if value == "" {
|
||||||
|
missing[name] = struct{}{}
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
if len(unknown) > 0 {
|
||||||
|
return "", fmt.Errorf("unsupported template variable(s): %s", sortedStringSet(unknown))
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return "", fmt.Errorf("missing required template variable value(s): %s", sortedStringSet(missing))
|
||||||
|
}
|
||||||
|
unused := map[string]struct{}{}
|
||||||
|
for _, name := range []string{"previous_session_id", "date", "title", "audio_s3_prefix", "audio_dir"} {
|
||||||
|
if values[name] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := used[name]; !ok {
|
||||||
|
unused[name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(unused) > 0 {
|
||||||
|
return "", fmt.Errorf("unused template variable value(s): %s", sortedStringSet(unused))
|
||||||
|
}
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedStringSet(set map[string]struct{}) string {
|
||||||
|
items := make([]string, 0, len(set))
|
||||||
|
for item := range set {
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
sort.Strings(items)
|
||||||
|
return strings.Join(items, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
func renderFindings(out io.Writer, campaign, sessionID string, findings []finding) error {
|
func renderFindings(out io.Writer, campaign, sessionID string, findings []finding) error {
|
||||||
if campaign != "" || sessionID != "" {
|
if campaign != "" || sessionID != "" {
|
||||||
fmt.Fprintf(out, "Campaign: %s\n", campaign)
|
fmt.Fprintf(out, "Campaign: %s\n", campaign)
|
||||||
@@ -793,14 +980,14 @@ func buildHelperArtifactCatalog(cfg *config.Config) (*artifacts.ArtifactCatalog,
|
|||||||
return catalog, nil
|
return catalog, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.ArtifactCatalog, locks *effectiveLocks, promotedRemoteState map[string]string) {
|
func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.ArtifactCatalog, locks *effectiveLocks, publishedRemoteState map[string]string) {
|
||||||
lockSet := lockSourceSet(locks.All)
|
lockSet := lockSourceSet(locks.All)
|
||||||
fmt.Fprintln(out, "Built-in:")
|
fmt.Fprintln(out, "Built-in:")
|
||||||
for _, id := range []string{
|
for _, id := range []string{
|
||||||
artifacts.ArtifactTranscriptMerged,
|
artifacts.ArtifactTranscriptBase,
|
||||||
artifacts.ArtifactTranscriptPolished,
|
artifacts.ArtifactTranscriptPolished,
|
||||||
artifacts.ArtifactTranscriptFull,
|
artifacts.ArtifactTranscriptFinal,
|
||||||
artifacts.ArtifactTranscriptTrimmed,
|
artifacts.ArtifactTranscriptFinalTrimmed,
|
||||||
artifacts.ArtifactBoundsSession,
|
artifacts.ArtifactBoundsSession,
|
||||||
} {
|
} {
|
||||||
writeArtifactLine(out, id, lockSet)
|
writeArtifactLine(out, id, lockSet)
|
||||||
@@ -813,13 +1000,13 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
|
|||||||
for _, req := range artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)) {
|
for _, req := range artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)) {
|
||||||
fmt.Fprintf(out, "- narratio.previous_session.artifact.%s required=%t\n", req.Name, req.Required)
|
fmt.Fprintf(out, "- narratio.previous_session.artifact.%s required=%t\n", req.Name, req.Required)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(out, "Promoted:")
|
fmt.Fprintln(out, "Published:")
|
||||||
for _, rule := range cfg.Pipeline.Archive.PromoteArtifacts {
|
for _, rule := range cfg.Pipeline.Publish.Outputs {
|
||||||
writePromotedArtifactLine(out, rule, catalog, lockSet, promotedRemoteState)
|
writePublishedOutputLine(out, rule, catalog, lockSet, publishedRemoteState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeArtifactLine(out io.Writer, source string, lockSet map[string]config.ArchiveLockRule) {
|
func writeArtifactLine(out io.Writer, source string, lockSet map[string]config.PublishLockRule) {
|
||||||
parts := []string{source}
|
parts := []string{source}
|
||||||
if _, ok := lockSet[source]; ok {
|
if _, ok := lockSet[source]; ok {
|
||||||
parts = append(parts, "locked")
|
parts = append(parts, "locked")
|
||||||
@@ -827,13 +1014,13 @@ func writeArtifactLine(out io.Writer, source string, lockSet map[string]config.A
|
|||||||
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func writePromotedArtifactLine(out io.Writer, rule config.ArchivePromotionRule, catalog *artifacts.ArtifactCatalog, lockSet map[string]config.ArchiveLockRule, remoteState map[string]string) {
|
func writePublishedOutputLine(out io.Writer, rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog, lockSet map[string]config.PublishLockRule, remoteState map[string]string) {
|
||||||
source := strings.TrimSpace(rule.Source)
|
source := strings.TrimSpace(rule.Source)
|
||||||
parts := []string{source}
|
parts := []string{source}
|
||||||
if _, ok := lockSet[source]; ok {
|
if _, ok := lockSet[source]; ok {
|
||||||
parts = append(parts, "locked")
|
parts = append(parts, "locked")
|
||||||
}
|
}
|
||||||
dest, showDest, err := helperPromotionDest(rule, catalog)
|
dest, showDest, err := helperPublishedOutputDest(rule, catalog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
parts = append(parts, "remote=error")
|
parts = append(parts, "remote=error")
|
||||||
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
||||||
@@ -842,35 +1029,35 @@ func writePromotedArtifactLine(out io.Writer, rule config.ArchivePromotionRule,
|
|||||||
if showDest {
|
if showDest {
|
||||||
parts = append(parts, "dest="+dest)
|
parts = append(parts, "dest="+dest)
|
||||||
}
|
}
|
||||||
if state := remoteState[promotionRemoteStateKey(source, dest)]; state != "" {
|
if state := remoteState[publishedOutputRemoteStateKey(source, dest)]; state != "" {
|
||||||
parts = append(parts, state)
|
parts = append(parts, state)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func remotePromotionAvailability(ctx context.Context, cfg *config.Config, store storage.ObjectStore, catalog *artifacts.ArtifactCatalog) map[string]string {
|
func remotePublishedOutputAvailability(ctx context.Context, cfg *config.Config, store storage.ObjectStore, catalog *artifacts.ArtifactCatalog) map[string]string {
|
||||||
out := map[string]string{}
|
out := map[string]string{}
|
||||||
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
for _, rule := range cfg.Pipeline.Archive.PromoteArtifacts {
|
for _, rule := range cfg.Pipeline.Publish.Outputs {
|
||||||
source := strings.TrimSpace(rule.Source)
|
source := strings.TrimSpace(rule.Source)
|
||||||
dest, _, err := helperPromotionDest(rule, catalog)
|
dest, _, err := helperPublishedOutputDest(rule, catalog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out[promotionRemoteStateKey(source, "")] = "remote=error"
|
out[publishedOutputRemoteStateKey(source, "")] = "remote=error"
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := artifacts.S3PromotedArtifactKey(sessionPrefix, dest)
|
key := artifacts.S3PublishedOutputKey(sessionPrefix, dest)
|
||||||
if exists, err := store.Exists(ctx, key); err == nil && exists {
|
if exists, err := store.Exists(ctx, key); err == nil && exists {
|
||||||
out[promotionRemoteStateKey(source, dest)] = "remote=promoted"
|
out[publishedOutputRemoteStateKey(source, dest)] = "remote=published"
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
out[promotionRemoteStateKey(source, dest)] = "remote=error"
|
out[publishedOutputRemoteStateKey(source, dest)] = "remote=error"
|
||||||
} else {
|
} else {
|
||||||
out[promotionRemoteStateKey(source, dest)] = "remote=missing"
|
out[publishedOutputRemoteStateKey(source, dest)] = "remote=missing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func helperPromotionDest(rule config.ArchivePromotionRule, catalog *artifacts.ArtifactCatalog) (string, bool, error) {
|
func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog) (string, bool, error) {
|
||||||
source := strings.TrimSpace(rule.Source)
|
source := strings.TrimSpace(rule.Source)
|
||||||
dest := strings.TrimSpace(rule.Dest)
|
dest := strings.TrimSpace(rule.Dest)
|
||||||
if dest == "" {
|
if dest == "" {
|
||||||
@@ -907,20 +1094,20 @@ func normalizeHelperArchiveRelativePath(rel string) (string, error) {
|
|||||||
return cleaned, nil
|
return cleaned, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func promotionRemoteStateKey(source, dest string) string {
|
func publishedOutputRemoteStateKey(source, dest string) string {
|
||||||
return strings.TrimSpace(source) + "\x00" + strings.TrimSpace(dest)
|
return strings.TrimSpace(source) + "\x00" + strings.TrimSpace(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeLocks(out io.Writer, cfg *config.Config, locks *effectiveLocks) {
|
func writeLocks(out io.Writer, cfg *config.Config, locks *effectiveLocks) {
|
||||||
if locks == nil || len(locks.All) == 0 {
|
if locks == nil || len(locks.All) == 0 {
|
||||||
fmt.Fprintln(out, "Archive locks: none")
|
fmt.Fprintln(out, "Publish locks: none")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintln(out, "Archive locks:")
|
fmt.Fprintln(out, "Publish locks:")
|
||||||
promoted := map[string]config.ArchivePromotionRule{}
|
published := map[string]config.PublishOutputRule{}
|
||||||
if cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Archive != nil {
|
if cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Publish != nil {
|
||||||
for _, rule := range cfg.Pipeline.Archive.PromoteArtifacts {
|
for _, rule := range cfg.Pipeline.Publish.Outputs {
|
||||||
promoted[strings.TrimSpace(rule.Source)] = rule
|
published[strings.TrimSpace(rule.Source)] = rule
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
staticSet := lockSourceSet(locks.Static)
|
staticSet := lockSourceSet(locks.Static)
|
||||||
@@ -929,9 +1116,9 @@ func writeLocks(out io.Writer, cfg *config.Config, locks *effectiveLocks) {
|
|||||||
if _, ok := staticSet[lock.Source]; ok {
|
if _, ok := staticSet[lock.Source]; ok {
|
||||||
origin = "pipeline"
|
origin = "pipeline"
|
||||||
}
|
}
|
||||||
promo := "not-promoted"
|
promo := "not-published"
|
||||||
if _, ok := promoted[lock.Source]; ok {
|
if _, ok := published[lock.Source]; ok {
|
||||||
promo = "promoted"
|
promo = "published"
|
||||||
}
|
}
|
||||||
reason := strings.TrimSpace(lock.Reason)
|
reason := strings.TrimSpace(lock.Reason)
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
@@ -941,13 +1128,13 @@ func writeLocks(out io.Writer, cfg *config.Config, locks *effectiveLocks) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func lockMapValues(in map[string]config.ArchiveLockRule) []config.ArchiveLockRule {
|
func lockMapValues(in map[string]config.PublishLockRule) []config.PublishLockRule {
|
||||||
keys := make([]string, 0, len(in))
|
keys := make([]string, 0, len(in))
|
||||||
for key := range in {
|
for key := range in {
|
||||||
keys = append(keys, key)
|
keys = append(keys, key)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
out := make([]config.ArchiveLockRule, 0, len(keys))
|
out := make([]config.PublishLockRule, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
item := in[key]
|
item := in[key]
|
||||||
item.Source = key
|
item.Source = key
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{
|
code := Execute([]string{
|
||||||
"session", "init",
|
"session", "init", "2026-06-07",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session-id", "2026-06-07",
|
|
||||||
"--title", "The Black Cabin",
|
"--title", "The Black Cabin",
|
||||||
"--remote",
|
"--remote",
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
@@ -48,6 +47,363 @@ func TestExecuteSessionInitRemoteWritesCanonicalSessionConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitRemoteUsesDefaultConfigDiscovery(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
var storeInitCalls int
|
||||||
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "init", "2026-06-07",
|
||||||
|
"--remote",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
key := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07"))
|
||||||
|
if _, ok := fake.Objects[key]; !ok {
|
||||||
|
t.Fatalf("remote session key %q not uploaded; objects=%v", key, fake.Objects)
|
||||||
|
}
|
||||||
|
if storeInitCalls != 1 {
|
||||||
|
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitLocalUsesDefaultConfigDiscovery(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "session.yml")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "init", "2026-06-07",
|
||||||
|
"--output", outputPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read generated session: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `session_id: "2026-06-07"`) || !strings.Contains(string(data), "prefix: audio/") {
|
||||||
|
t.Fatalf("generated session = %q", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitExplicitConfigWinsOverDefaults(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
defaultPipeline, defaultCampaign, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
withDefaultPipelineCampaignConfigs(t, defaultPipeline, defaultCampaign)
|
||||||
|
|
||||||
|
explicitDir := t.TempDir()
|
||||||
|
explicitCampaign := filepath.Join(explicitDir, "campaign.yml")
|
||||||
|
if err := os.WriteFile(explicitCampaign, []byte(`campaign_id: explicit-campaign
|
||||||
|
inputs:
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`), 0o644); err != nil {
|
||||||
|
t.Fatalf("write explicit campaign: %v", err)
|
||||||
|
}
|
||||||
|
mustWriteTestFile(t, filepath.Join(explicitDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n")
|
||||||
|
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
var storeInitCalls int
|
||||||
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "init", "2026-06-07",
|
||||||
|
"--config", defaultPipeline,
|
||||||
|
"--campaign-file", explicitCampaign,
|
||||||
|
"--remote",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
explicitKey := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "explicit-campaign", "2026-06-07"))
|
||||||
|
if _, ok := fake.Objects[explicitKey]; !ok {
|
||||||
|
t.Fatalf("explicit campaign remote key %q not uploaded; objects=%v", explicitKey, fake.Objects)
|
||||||
|
}
|
||||||
|
defaultKey := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07"))
|
||||||
|
if _, ok := fake.Objects[defaultKey]; ok {
|
||||||
|
t.Fatalf("default campaign key %q uploaded despite explicit campaign override", defaultKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitRequiresSessionID(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"session", "init", "--remote"}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "session init: session_id is required") {
|
||||||
|
t.Fatalf("stderr = %q, want session-id required error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitMissingDefaultConfigReportsSearchedPaths(t *testing.T) {
|
||||||
|
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
||||||
|
config.DefaultPipelineConfigSearchPaths = []string{filepath.Join(t.TempDir(), "missing-pipeline.yml")}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
|
||||||
|
})
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "session init: no pipeline config path provided and no default pipeline config found; searched:") {
|
||||||
|
t.Fatalf("stderr = %q, want default pipeline searched-path error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitRemoteLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
withDefaultPipelineCampaignConfigs(t, pipelinePath, campaignPath)
|
||||||
|
accessKeyEnv := "NARRATIO_TEST_SESSION_INIT_OBJECT_KEY_ID"
|
||||||
|
secretKeyEnv := "NARRATIO_TEST_SESSION_INIT_OBJECT_SECRET"
|
||||||
|
restoreEnvAfterTest(t, accessKeyEnv, secretKeyEnv)
|
||||||
|
secretsDir := t.TempDir()
|
||||||
|
mustWriteTestFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-key-id\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret\n")
|
||||||
|
addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv)
|
||||||
|
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
origStoreFn := newObjectStoreFromConfigFn
|
||||||
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
||||||
|
if os.Getenv(accessKeyEnv) != "test-key-id" || os.Getenv(secretKeyEnv) != "test-secret" {
|
||||||
|
return nil, fmt.Errorf("secrets were not loaded before object store init")
|
||||||
|
}
|
||||||
|
return fake, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
newObjectStoreFromConfigFn = origStoreFn
|
||||||
|
})
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"session", "init", "2026-06-07", "--remote"}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitLocalRendersCampaignTemplate(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
|
||||||
|
previous_session_id: "{{ previous_session_id }}"
|
||||||
|
date: "{{ date }}"
|
||||||
|
title: "{{ title }}"
|
||||||
|
inputs:
|
||||||
|
audio_s3:
|
||||||
|
prefix: "{{ audio_s3_prefix }}"
|
||||||
|
`)
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "session.yml")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "init", "2026-06-07",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--previous-session-id", "2026-05-31",
|
||||||
|
"--date", "2026-06-07",
|
||||||
|
"--title", "The Black Cabin",
|
||||||
|
"--audio-s3-prefix", "audio/",
|
||||||
|
"--output", outputPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read generated session: %v", err)
|
||||||
|
}
|
||||||
|
got := string(data)
|
||||||
|
for _, want := range []string{
|
||||||
|
`session_id: "2026-06-07"`,
|
||||||
|
`previous_session_id: "2026-05-31"`,
|
||||||
|
`date: "2026-06-07"`,
|
||||||
|
`title: "The Black Cabin"`,
|
||||||
|
`prefix: "audio/"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("generated session = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "{{") {
|
||||||
|
t.Fatalf("generated session still contains template placeholder: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitRemoteRendersCampaignTemplate(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
|
||||||
|
inputs:
|
||||||
|
audio_s3:
|
||||||
|
prefix: audio/
|
||||||
|
`)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
var storeInitCalls int
|
||||||
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "init", "2026-06-07",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--remote",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
key := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07"))
|
||||||
|
obj, ok := fake.Objects[key]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("remote session key %q not uploaded; objects=%v", key, fake.Objects)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(obj.Data), "{{") || !strings.Contains(string(obj.Data), `session_id: "2026-06-07"`) {
|
||||||
|
t.Fatalf("remote session data = %q, want rendered concrete session", string(obj.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitTemplatePathIsCampaignRelative(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
templateDir := filepath.Join(filepath.Dir(campaignPath), "templates")
|
||||||
|
if err := os.MkdirAll(templateDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir template dir: %v", err)
|
||||||
|
}
|
||||||
|
templatePath := filepath.Join(templateDir, "session.template.yml")
|
||||||
|
if err := os.WriteFile(templatePath, []byte(`session_id: "{{ session_id }}"
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
`), 0o644); err != nil {
|
||||||
|
t.Fatalf("write session template: %v", err)
|
||||||
|
}
|
||||||
|
addSessionTemplateToCampaign(t, campaignPath, "./templates/session.template.yml")
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "session.yml")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "init", "2026-06-07",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--output", outputPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read generated session: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `session_id: "2026-06-07"`) {
|
||||||
|
t.Fatalf("generated session = %q, want campaign-relative template output", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitTemplateMissingVariableFails(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
|
||||||
|
date: "{{ date }}"
|
||||||
|
inputs:
|
||||||
|
audio_s3:
|
||||||
|
prefix: audio/
|
||||||
|
`)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "init", "2026-06-07",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--remote",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "missing required template variable value(s): date") {
|
||||||
|
t.Fatalf("stderr = %q, want missing date variable", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitTemplateUnusedFlagFails(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
|
||||||
|
inputs:
|
||||||
|
audio_s3:
|
||||||
|
prefix: audio/
|
||||||
|
`)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "init", "2026-06-07",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--title", "Unused Title",
|
||||||
|
"--remote",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "unused template variable value(s): title") {
|
||||||
|
t.Fatalf("stderr = %q, want unused title variable", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitTemplateStrictDecodeFailure(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
|
||||||
|
unknown: true
|
||||||
|
inputs:
|
||||||
|
audio_s3:
|
||||||
|
prefix: audio/
|
||||||
|
`)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "init", "2026-06-07",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--remote",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "strict decode failed") {
|
||||||
|
t.Fatalf("stderr = %q, want strict decode error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteSessionValidateLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
|
func TestExecuteSessionValidateLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
@@ -67,7 +423,7 @@ inputs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
audioKey := artifacts.S3PromotedArtifactKey(artifacts.S3AudioPrefix(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "audio/"), "alice.flac")
|
audioKey := artifacts.S3PublishedOutputKey(artifacts.S3AudioPrefix(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "audio/"), "alice.flac")
|
||||||
fake.SeedObject(storage.FakeObject{Key: audioKey, Data: []byte("audio")})
|
fake.SeedObject(storage.FakeObject{Key: audioKey, Data: []byte("audio")})
|
||||||
origStoreFn := newObjectStoreFromConfigFn
|
origStoreFn := newObjectStoreFromConfigFn
|
||||||
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
||||||
@@ -82,7 +438,7 @@ inputs:
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"session", "validate", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||||
}
|
}
|
||||||
@@ -101,13 +457,11 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{
|
code := Execute([]string{
|
||||||
"locks", "add",
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
"--reason", "manual edit",
|
"--reason", "manual edit",
|
||||||
"narratio.transcript.trimmed",
|
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
@@ -117,42 +471,39 @@ func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("remote locks key %q not uploaded", key)
|
t.Fatalf("remote locks key %q not uploaded", key)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(obj.Data), "source: narratio.transcript.trimmed") || !strings.Contains(string(obj.Data), "reason: manual edit") {
|
if !strings.Contains(string(obj.Data), "source: narratio.transcript.final_trimmed") || !strings.Contains(string(obj.Data), "reason: manual edit") {
|
||||||
t.Fatalf("lock store data = %q", string(obj.Data))
|
t.Fatalf("lock store data = %q", string(obj.Data))
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
code = Execute([]string{
|
code = Execute([]string{
|
||||||
"locks",
|
"session", "locks", "2026-05-03",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("locks list exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("locks list exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "- narratio.transcript.trimmed origin=remote") {
|
if !strings.Contains(stdout.String(), "- narratio.transcript.final_trimmed origin=remote") {
|
||||||
t.Fatalf("stdout = %q, want remote lock", stdout.String())
|
t.Fatalf("stdout = %q, want remote lock", stdout.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
code = Execute([]string{
|
code = Execute([]string{
|
||||||
"locks", "remove",
|
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
"narratio.transcript.trimmed",
|
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
store, err := config.LoadArchiveLockStoreBytes("locks.yml", fake.Objects[key].Data, nil)
|
store, err := config.LoadPublishLockStoreBytes("locks.yml", fake.Objects[key].Data, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadArchiveLockStoreBytes() error = %v", err)
|
t.Fatalf("LoadPublishLockStoreBytes() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(store.Locks) != 0 {
|
if len(store.Locks) != 0 {
|
||||||
t.Fatalf("locks after remove = %#v, want empty", store.Locks)
|
t.Fatalf("locks after remove = %#v, want empty", store.Locks)
|
||||||
@@ -172,13 +523,11 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{
|
code := Execute([]string{
|
||||||
"locks", "add",
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
"--reason", "first",
|
"--reason", "first",
|
||||||
"narratio.transcript.trimmed",
|
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("initial locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("initial locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
@@ -187,13 +536,11 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
|
|||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
code = Execute([]string{
|
code = Execute([]string{
|
||||||
"locks", "add",
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
"--reason", "second",
|
"--reason", "second",
|
||||||
"narratio.transcript.trimmed",
|
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("duplicate locks add exit code = 0, want non-zero")
|
t.Fatal("duplicate locks add exit code = 0, want non-zero")
|
||||||
@@ -205,14 +552,12 @@ func TestExecuteLocksAddDuplicateRequiresForce(t *testing.T) {
|
|||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
code = Execute([]string{
|
code = Execute([]string{
|
||||||
"locks", "add",
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
"--reason", "second",
|
"--reason", "second",
|
||||||
"--force",
|
"--force",
|
||||||
"narratio.transcript.trimmed",
|
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("forced locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("forced locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
@@ -229,9 +574,9 @@ func TestExecuteLocksRequireSessionID(t *testing.T) {
|
|||||||
args []string
|
args []string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"list", []string{"locks"}, "locks: --session-id is required"},
|
{"list", []string{"session", "locks"}, "locks: session_id is required"},
|
||||||
{"add", []string{"locks", "add", "narratio.transcript.trimmed"}, "locks add: --session-id is required"},
|
{"add", []string{"session", "locks", "add", "narratio.transcript.final_trimmed"}, "locks add: expected session_id and source id"},
|
||||||
{"remove", []string{"locks", "remove", "narratio.transcript.trimmed"}, "locks remove: --session-id is required"},
|
{"remove", []string{"session", "locks", "remove", "narratio.transcript.final_trimmed"}, "locks remove: expected session_id and source id"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -251,7 +596,7 @@ func TestExecuteLocksRequireSessionID(t *testing.T) {
|
|||||||
func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
|
func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
addStaticArchiveLockToPipelineConfig(t, pipelinePath, "narratio.transcript.trimmed")
|
addStaticArchiveLockToPipelineConfig(t, pipelinePath, "narratio.transcript.final_trimmed")
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
var storeInitCalls int
|
var storeInitCalls int
|
||||||
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||||
@@ -259,12 +604,10 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{
|
code := Execute([]string{
|
||||||
"locks", "add",
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
"narratio.transcript.trimmed",
|
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("locks add static lock exit code = 0, want non-zero")
|
t.Fatal("locks add static lock exit code = 0, want non-zero")
|
||||||
@@ -276,12 +619,10 @@ func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
|
|||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
code = Execute([]string{
|
code = Execute([]string{
|
||||||
"locks", "remove",
|
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
"narratio.transcript.trimmed",
|
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("locks remove static lock exit code = 0, want non-zero")
|
t.Fatal("locks remove static lock exit code = 0, want non-zero")
|
||||||
@@ -297,7 +638,7 @@ func TestExecuteTopLevelLockAndUnlockAreRemoved(t *testing.T) {
|
|||||||
t.Run(cmd, func(t *testing.T) {
|
t.Run(cmd, func(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{cmd, "narratio.transcript.trimmed"}, &stdout, &stderr)
|
code := Execute([]string{cmd, "narratio.transcript.final_trimmed"}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -308,19 +649,53 @@ func TestExecuteTopLevelLockAndUnlockAreRemoved(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func withDefaultPipelineCampaignConfigs(t *testing.T, pipelinePath, campaignPath string) {
|
||||||
|
t.Helper()
|
||||||
|
origPipelineDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
||||||
|
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
config.DefaultPipelineConfigSearchPaths = origPipelineDefaults
|
||||||
|
})
|
||||||
|
_ = campaignPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSessionInitTemplate(t *testing.T, campaignPath, templateYAML string) {
|
||||||
|
t.Helper()
|
||||||
|
templatePath := filepath.Join(filepath.Dir(campaignPath), "session.template.yml")
|
||||||
|
if err := os.WriteFile(templatePath, []byte(templateYAML), 0o644); err != nil {
|
||||||
|
t.Fatalf("write session template: %v", err)
|
||||||
|
}
|
||||||
|
addSessionTemplateToCampaign(t, campaignPath, "./session.template.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSessionTemplateToCampaign(t *testing.T, campaignPath, templateFile string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(campaignPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read campaign config: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), "session_template_file:") {
|
||||||
|
t.Fatalf("campaign config already has session_template_file: %q", string(data))
|
||||||
|
}
|
||||||
|
updated := "session_template_file: " + templateFile + "\n" + string(data)
|
||||||
|
if err := os.WriteFile(campaignPath, []byte(updated), 0o644); err != nil {
|
||||||
|
t.Fatalf("write campaign config: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
|
func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
addArchivePromotionsToPipeline(t, pipelinePath, `
|
addArchivePromotionsToPipeline(t, pipelinePath, `
|
||||||
promote_artifacts:
|
outputs:
|
||||||
- source: narratio.transcript.trimmed
|
- source: narratio.transcript.final_trimmed
|
||||||
dest: transcripts/trimmed.json
|
dest: transcripts/final.trimmed.json
|
||||||
required: true
|
required: true
|
||||||
`)
|
`)
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
trimmedKey := artifacts.S3PromotedArtifactKey(
|
trimmedKey := artifacts.S3PublishedOutputKey(
|
||||||
artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"),
|
artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"),
|
||||||
"transcripts/trimmed.json",
|
"transcripts/final.trimmed.json",
|
||||||
)
|
)
|
||||||
fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte(`{"segments":[]}`)})
|
fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte(`{"segments":[]}`)})
|
||||||
var storeInitCalls int
|
var storeInitCalls int
|
||||||
@@ -329,16 +704,16 @@ func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{
|
code := Execute([]string{
|
||||||
"artifacts", "list",
|
"session", "artifacts", "2026-05-03",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--remote",
|
"--remote",
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "narratio.transcript.trimmed remote=promoted") {
|
if !strings.Contains(stdout.String(), "narratio.transcript.final_trimmed remote=published") {
|
||||||
t.Fatalf("stdout = %q, want promoted remote availability", stdout.String())
|
t.Fatalf("stdout = %q, want promoted remote availability", stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,8 +722,8 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
|
|||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
addArchivePromotionsToPipeline(t, pipelinePath, `
|
addArchivePromotionsToPipeline(t, pipelinePath, `
|
||||||
promote_artifacts:
|
outputs:
|
||||||
- source: narratio.transcript.full
|
- source: narratio.transcript.final
|
||||||
dest: transcripts/full.json
|
dest: transcripts/full.json
|
||||||
required: true
|
required: true
|
||||||
- source: narratio.bounds.session
|
- source: narratio.bounds.session
|
||||||
@@ -357,17 +732,17 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
|
|||||||
`)
|
`)
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
||||||
fake.SeedObject(storage.FakeObject{Key: artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/full.json"), Data: []byte(`{"segments":[]}`)})
|
fake.SeedObject(storage.FakeObject{Key: artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/full.json"), Data: []byte(`{"segments":[]}`)})
|
||||||
fake.SeedObject(storage.FakeObject{Key: artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/bounds.json"), Data: []byte(`{}`)})
|
fake.SeedObject(storage.FakeObject{Key: artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/bounds.json"), Data: []byte(`{}`)})
|
||||||
var storeInitCalls int
|
var storeInitCalls int
|
||||||
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{
|
code := Execute([]string{
|
||||||
"artifacts", "list",
|
"session", "artifacts", "2026-05-03",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--remote",
|
"--remote",
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
@@ -376,7 +751,7 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
out := stdout.String()
|
out := stdout.String()
|
||||||
for _, unwanted := range []string{
|
for _, unwanted := range []string{
|
||||||
"narratio.transcript.full remote=missing",
|
"narratio.transcript.final remote=missing",
|
||||||
"narratio.bounds.session remote=missing",
|
"narratio.bounds.session remote=missing",
|
||||||
} {
|
} {
|
||||||
if strings.Contains(out, unwanted) {
|
if strings.Contains(out, unwanted) {
|
||||||
@@ -384,8 +759,8 @@ func TestExecuteArtifactsListRemoteUsesPromotionDestinations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"narratio.transcript.full dest=transcripts/full.json remote=promoted",
|
"narratio.transcript.final dest=transcripts/full.json remote=published",
|
||||||
"narratio.bounds.session dest=transcripts/bounds.json remote=promoted",
|
"narratio.bounds.session dest=transcripts/bounds.json remote=published",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Fatalf("stdout = %q, want %q", out, want)
|
t.Fatalf("stdout = %q, want %q", out, want)
|
||||||
@@ -397,36 +772,35 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
|
|||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
addArchivePromotionsToPipeline(t, pipelinePath, `
|
addArchivePromotionsToPipeline(t, pipelinePath, `
|
||||||
promote_artifacts:
|
outputs:
|
||||||
- source: narratio.transcript.trimmed
|
- source: narratio.transcript.final_trimmed
|
||||||
dest: transcripts/trimmed.json
|
dest: transcripts/final.trimmed.json
|
||||||
required: true
|
required: true
|
||||||
- source: narratio.transcript.full
|
- source: narratio.transcript.final
|
||||||
dest: transcripts/full.json
|
dest: transcripts/full.json
|
||||||
required: true
|
required: true
|
||||||
`)
|
`)
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
||||||
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
|
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
|
||||||
trimmedKey := artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/trimmed.json")
|
trimmedKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/final.trimmed.json")
|
||||||
fullKey := artifacts.S3PromotedArtifactKey(sessionPrefix, "transcripts/full.json")
|
fullKey := artifacts.S3PublishedOutputKey(sessionPrefix, "transcripts/full.json")
|
||||||
lockKey := artifacts.S3SessionLocksKey(sessionPrefix)
|
lockKey := artifacts.S3SessionLocksKey(sessionPrefix)
|
||||||
fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||||
fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")})
|
fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")})
|
||||||
fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte(`{"segments":[]}`)})
|
fake.SeedObject(storage.FakeObject{Key: trimmedKey, Data: []byte(`{"segments":[]}`)})
|
||||||
fake.SeedObject(storage.FakeObject{Key: fullKey, Data: []byte(`{"segments":[]}`)})
|
fake.SeedObject(storage.FakeObject{Key: fullKey, Data: []byte(`{"segments":[]}`)})
|
||||||
fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.trimmed\n reason: remote review\n")})
|
fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.final_trimmed\n reason: remote review\n")})
|
||||||
var storeInitCalls int
|
var storeInitCalls int
|
||||||
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{
|
code := Execute([]string{
|
||||||
"status",
|
"session", "status", "2026-05-03",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
@@ -437,16 +811,16 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
|
|||||||
"Built-in:",
|
"Built-in:",
|
||||||
"Configured:",
|
"Configured:",
|
||||||
"Previous-session:",
|
"Previous-session:",
|
||||||
"Promoted:",
|
"Published:",
|
||||||
"narratio.transcript.trimmed locked",
|
"narratio.transcript.final_trimmed locked",
|
||||||
"narratio.transcript.trimmed locked remote=promoted",
|
"narratio.transcript.final_trimmed locked remote=published",
|
||||||
"narratio.transcript.full dest=transcripts/full.json remote=promoted",
|
"narratio.transcript.final dest=transcripts/full.json remote=published",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Fatalf("stdout = %q, want %q", out, want)
|
t.Fatalf("stdout = %q, want %q", out, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.Contains(out, "narratio.transcript.merged remote=missing") {
|
if strings.Contains(out, "narratio.transcript.base remote=missing") {
|
||||||
t.Fatalf("stdout = %q, did not want catalog remote marker", out)
|
t.Fatalf("stdout = %q, did not want catalog remote marker", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -455,9 +829,9 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
|
|||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
addArchivePromotionsToPipeline(t, pipelinePath, `
|
addArchivePromotionsToPipeline(t, pipelinePath, `
|
||||||
promote_artifacts:
|
outputs:
|
||||||
- source: narratio.transcript.trimmed
|
- source: narratio.transcript.final_trimmed
|
||||||
dest: transcripts/trimmed.json
|
dest: transcripts/final.trimmed.json
|
||||||
required: true
|
required: true
|
||||||
`)
|
`)
|
||||||
fake := &storage.FakeBackend{ExistsErr: fmt.Errorf("exists failed")}
|
fake := &storage.FakeBackend{ExistsErr: fmt.Errorf("exists failed")}
|
||||||
@@ -467,23 +841,22 @@ func TestExecuteStatusReportsRemoteArtifactCatalogErrorsWithoutFailing(t *testin
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{
|
code := Execute([]string{
|
||||||
"status",
|
"session", "status", "2026-05-03",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
}, &stdout, &stderr)
|
}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
out := stdout.String()
|
out := stdout.String()
|
||||||
if !strings.Contains(out, "Remote archive: missing or unavailable:") {
|
if !strings.Contains(out, "Remote publish: missing or unavailable:") {
|
||||||
t.Fatalf("stdout = %q, want remote archive unavailable state", out)
|
t.Fatalf("stdout = %q, want remote archive unavailable state", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "Remote outputs:") || !strings.Contains(out, "narratio.transcript.trimmed remote=error") {
|
if !strings.Contains(out, "Remote outputs:") || !strings.Contains(out, "narratio.transcript.final_trimmed remote=error") {
|
||||||
t.Fatalf("stdout = %q, want remote output error state", out)
|
t.Fatalf("stdout = %q, want remote output error state", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "Archive locks: error:") {
|
if !strings.Contains(out, "Publish locks: error:") {
|
||||||
t.Fatalf("stdout = %q, want archive locks error", out)
|
t.Fatalf("stdout = %q, want archive locks error", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,7 +866,7 @@ func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) {
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidArchiveConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidArchiveConfigFiles(t, workspaceRoot)
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
lockKey := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"))
|
lockKey := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"))
|
||||||
fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.trimmed\n reason: remote review\n")})
|
fake.SeedObject(storage.FakeObject{Key: lockKey, Data: []byte("locks:\n - source: narratio.transcript.final_trimmed\n reason: remote review\n")})
|
||||||
var storeInitCalls int
|
var storeInitCalls int
|
||||||
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||||
|
|
||||||
@@ -502,15 +875,15 @@ func TestExecuteArchiveLoadsRemoteLocks(t *testing.T) {
|
|||||||
// The archive stage only checks the manifest statuses and source files.
|
// The archive stage only checks the manifest statuses and source files.
|
||||||
_ = stageName
|
_ = stageName
|
||||||
}
|
}
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "trimmed.json"), `{"segments":[]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`)
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"run-stage", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "archive"}, &stdout, &stderr)
|
code := Execute([]string{"run-stage", "publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
promotedKey := artifacts.S3PromotedArtifactKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "transcripts/trimmed.json")
|
promotedKey := artifacts.S3PublishedOutputKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"), "transcripts/final.trimmed.json")
|
||||||
if _, ok := fake.Objects[promotedKey]; ok {
|
if _, ok := fake.Objects[promotedKey]; ok {
|
||||||
t.Fatalf("locked promoted key %q was uploaded", promotedKey)
|
t.Fatalf("locked promoted key %q was uploaded", promotedKey)
|
||||||
}
|
}
|
||||||
@@ -576,8 +949,8 @@ func addStaticArchiveLockToPipelineConfig(t *testing.T, pipelinePath, source str
|
|||||||
}
|
}
|
||||||
updated := strings.Replace(
|
updated := strings.Replace(
|
||||||
string(data),
|
string(data),
|
||||||
"archive:\n enabled: true\n upload_run: false\n",
|
"publish:\n enabled: true\n upload_run: false\n",
|
||||||
"archive:\n enabled: true\n upload_run: false\n locks:\n - source: "+source+"\n reason: static review\n",
|
"publish:\n enabled: true\n upload_run: false\n locks:\n - source: "+source+"\n reason: static review\n",
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
if updated == string(data) {
|
if updated == string(data) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
@@ -16,29 +17,43 @@ import (
|
|||||||
|
|
||||||
// Plan validates configuration, prepares the local workdir, and prints stage order.
|
// Plan validates configuration, prepares the local workdir, and prints stage order.
|
||||||
func Plan(ctx context.Context, args []string, out io.Writer) error {
|
func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
|
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var campaignPath string
|
var campaignPath string
|
||||||
|
var campaignFilePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
var sessionID string
|
var sessionID string
|
||||||
var previousSessionID string
|
var previousSessionID string
|
||||||
var force bool
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||||
|
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
|
||||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("plan: invalid flags: %w", err)
|
return fmt.Errorf("plan: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("plan: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("plan", fs, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("plan: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("plan", positionalSessionID, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return fmt.Errorf("plan: session_id is required")
|
||||||
|
}
|
||||||
|
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
PreviousSessionID: previousSessionID,
|
PreviousSessionID: previousSessionID,
|
||||||
})
|
})
|
||||||
@@ -68,7 +83,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
runCount := 0
|
runCount := 0
|
||||||
skipCount := 0
|
skipCount := 0
|
||||||
if _, err := fmt.Fprintf(out, "narratio plan: workdir prepared at %s\n", paths.Root); err != nil {
|
if _, err := fmt.Fprintf(out, "narratio session plan: workdir prepared at %s\n", paths.Root); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, d := range decisions {
|
for _, d := range decisions {
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
args := []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}
|
args := []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}
|
||||||
|
|
||||||
if err := Plan(context.Background(), args, &out); err != nil {
|
if err := Plan(context.Background(), args, &out); err != nil {
|
||||||
t.Fatalf("first Plan() error = %v", err)
|
t.Fatalf("first Plan() error = %v", err)
|
||||||
}
|
}
|
||||||
got := out.String()
|
got := out.String()
|
||||||
if !strings.Contains(got, "narratio plan: workdir prepared at") {
|
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
|
||||||
t.Fatalf("first output = %q, want workdir prepared", got)
|
t.Fatalf("first output = %q, want workdir prepared", got)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||||
if !strings.Contains(got, name+": run") {
|
if !strings.Contains(got, name+": run") {
|
||||||
t.Fatalf("first output = %q, missing stage %q", got, name)
|
t.Fatalf("first output = %q, missing stage %q", got, name)
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
|||||||
if err := Plan(context.Background(), args, &out); err != nil {
|
if err := Plan(context.Background(), args, &out); err != nil {
|
||||||
t.Fatalf("second Plan() error = %v", err)
|
t.Fatalf("second Plan() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "narratio plan: workdir prepared at") {
|
if !strings.Contains(out.String(), "narratio session plan: workdir prepared at") {
|
||||||
t.Fatalf("second output = %q, want workdir prepared", out.String())
|
t.Fatalf("second output = %q, want workdir prepared", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
if err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out); err != nil {
|
if err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out); err != nil {
|
||||||
t.Fatalf("Plan() error = %v", err)
|
t.Fatalf("Plan() error = %v", err)
|
||||||
}
|
}
|
||||||
got := out.String()
|
got := out.String()
|
||||||
@@ -108,8 +108,6 @@ seriatim:
|
|||||||
binary: seriatim
|
binary: seriatim
|
||||||
audita:
|
audita:
|
||||||
binary: audita
|
binary: audita
|
||||||
analyzer:
|
|
||||||
timeout: 20m
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
`
|
`
|
||||||
@@ -129,7 +127,7 @@ inputs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "testing"
|
|||||||
|
|
||||||
func TestBuildFullPlanOrder(t *testing.T) {
|
func TestBuildFullPlanOrder(t *testing.T) {
|
||||||
got := BuildFullPlan()
|
got := BuildFullPlan()
|
||||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"}
|
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ func runPostArchiveCleanup(ctx context.Context, env *Env, manifestPath string, m
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
spoolRequested := env.Config.Pipeline.Spool.DeleteAudioAfterArchive
|
spoolRequested := env.Config.Pipeline.Spool.DeleteAudioAfterPublish
|
||||||
workRequested := env.Config.Pipeline.Workspace.CleanupAfterArchive
|
workRequested := env.Config.Pipeline.Workspace.CleanupAfterPublish
|
||||||
if !spoolRequested && !workRequested {
|
if !spoolRequested && !workRequested {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -63,9 +63,9 @@ func runPostArchiveCleanup(ctx context.Context, env *Env, manifestPath string, m
|
|||||||
}
|
}
|
||||||
|
|
||||||
if spoolRequested {
|
if spoolRequested {
|
||||||
if err := removeRunScopedDir(strings.TrimSpace(env.Config.Pipeline.Spool.Root), spoolDir, "pipeline.spool.delete_audio_after_archive"); err != nil {
|
if err := removeRunScopedDir(strings.TrimSpace(env.Config.Pipeline.Spool.Root), spoolDir, "pipeline.spool.delete_audio_after_publish"); err != nil {
|
||||||
sr.Metadata["cleanup_failed"] = true
|
sr.Metadata["cleanup_failed"] = true
|
||||||
sr.Metadata["cleanup_failed_policy"] = "pipeline.spool.delete_audio_after_archive"
|
sr.Metadata["cleanup_failed_policy"] = "pipeline.spool.delete_audio_after_publish"
|
||||||
sr.Metadata["cleanup_failed_path"] = spoolDir
|
sr.Metadata["cleanup_failed_path"] = spoolDir
|
||||||
_ = env.ManifestStore.Save(ctx, manifestPath, m)
|
_ = env.ManifestStore.Save(ctx, manifestPath, m)
|
||||||
return err
|
return err
|
||||||
@@ -82,9 +82,9 @@ func runPostArchiveCleanup(ctx context.Context, env *Env, manifestPath string, m
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := removeRunScopedDir(strings.TrimSpace(env.Config.Pipeline.Workspace.Root), workDir, "pipeline.workspace.cleanup_after_archive"); err != nil {
|
if err := removeRunScopedDir(strings.TrimSpace(env.Config.Pipeline.Workspace.Root), workDir, "pipeline.workspace.cleanup_after_publish"); err != nil {
|
||||||
sr.Metadata["cleanup_failed"] = true
|
sr.Metadata["cleanup_failed"] = true
|
||||||
sr.Metadata["cleanup_failed_policy"] = "pipeline.workspace.cleanup_after_archive"
|
sr.Metadata["cleanup_failed_policy"] = "pipeline.workspace.cleanup_after_publish"
|
||||||
sr.Metadata["cleanup_failed_path"] = workDir
|
sr.Metadata["cleanup_failed_path"] = workDir
|
||||||
_ = env.ManifestStore.Save(ctx, manifestPath, m)
|
_ = env.ManifestStore.Save(ctx, manifestPath, m)
|
||||||
return err
|
return err
|
||||||
@@ -100,17 +100,17 @@ func archiveStageRecordForCleanup(m *manifest.Manifest, executed []string) *mani
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
archiveRan := false
|
publishRan := false
|
||||||
for _, name := range executed {
|
for _, name := range executed {
|
||||||
if name == "archive" {
|
if name == "publish" {
|
||||||
archiveRan = true
|
publishRan = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !archiveRan {
|
if !publishRan {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
sr := m.Stages["archive"]
|
sr := m.Stages["publish"]
|
||||||
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -118,37 +118,37 @@ func archiveStageRecordForCleanup(m *manifest.Manifest, executed []string) *mani
|
|||||||
}
|
}
|
||||||
|
|
||||||
func archiveCleanupEligible(cfg *config.Config, sr *manifest.StageRecord) (bool, string) {
|
func archiveCleanupEligible(cfg *config.Config, sr *manifest.StageRecord) (bool, string) {
|
||||||
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Archive == nil {
|
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Publish == nil {
|
||||||
return false, "archive configuration is missing"
|
return false, "publish configuration is missing"
|
||||||
}
|
}
|
||||||
enabled := true
|
enabled := true
|
||||||
if cfg.Pipeline.Archive.Enabled != nil {
|
if cfg.Pipeline.Publish.Enabled != nil {
|
||||||
enabled = *cfg.Pipeline.Archive.Enabled
|
enabled = *cfg.Pipeline.Publish.Enabled
|
||||||
}
|
}
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return false, "archive.enabled is false"
|
return false, "publish.enabled is false"
|
||||||
}
|
}
|
||||||
uploadRun := true
|
uploadRun := true
|
||||||
if cfg.Pipeline.Archive.UploadRun != nil {
|
if cfg.Pipeline.Publish.UploadRun != nil {
|
||||||
uploadRun = *cfg.Pipeline.Archive.UploadRun
|
uploadRun = *cfg.Pipeline.Publish.UploadRun
|
||||||
}
|
}
|
||||||
if !uploadRun {
|
if !uploadRun {
|
||||||
return false, "archive.upload_run is false"
|
return false, "publish.upload_run is false"
|
||||||
}
|
}
|
||||||
if sr == nil || sr.Metadata == nil {
|
if sr == nil || sr.Metadata == nil {
|
||||||
return false, "archive metadata is missing"
|
return false, "publish metadata is missing"
|
||||||
}
|
}
|
||||||
if skipped, _ := sr.Metadata["skipped"].(bool); skipped {
|
if skipped, _ := sr.Metadata["skipped"].(bool); skipped {
|
||||||
return false, "archive stage was skipped"
|
return false, "publish stage was skipped"
|
||||||
}
|
}
|
||||||
if uploaded, _ := sr.Metadata["uploaded"].(bool); !uploaded {
|
if uploaded, _ := sr.Metadata["uploaded"].(bool); !uploaded {
|
||||||
return false, "archive did not upload run record"
|
return false, "publish did not upload run record"
|
||||||
}
|
}
|
||||||
if pointer, _ := sr.Metadata["current_pointer_written"].(bool); !pointer {
|
if pointer, _ := sr.Metadata["current_pointer_written"].(bool); !pointer {
|
||||||
return false, "archive did not write current pointer"
|
return false, "publish did not write current pointer"
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(asString(sr.Metadata["current_run_id_key"])) == "" {
|
if strings.TrimSpace(asString(sr.Metadata["current_run_id_key"])) == "" {
|
||||||
return false, "archive current run pointer key is missing"
|
return false, "publish current run pointer key is missing"
|
||||||
}
|
}
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ type archiveSuccessStage struct {
|
|||||||
metadata map[string]any
|
metadata map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func (archiveSuccessStage) Name() string { return "archive" }
|
func (archiveSuccessStage) Name() string { return "publish" }
|
||||||
func (archiveSuccessStage) Declares() stage.IODecl { return stage.IODecl{} }
|
func (archiveSuccessStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||||
func (s archiveSuccessStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
func (s archiveSuccessStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
md := map[string]any{
|
md := map[string]any{
|
||||||
"stage": "archive",
|
"stage": "publish",
|
||||||
"uploaded": true,
|
"uploaded": true,
|
||||||
"current_pointer_written": true,
|
"current_pointer_written": true,
|
||||||
"current_run_id_key": "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/run_id.txt",
|
"current_run_id_key": "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/run_id.txt",
|
||||||
@@ -45,8 +45,8 @@ func (notifyFailStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest
|
|||||||
|
|
||||||
func TestPostArchiveCleanupDisabledKeepsLocalDirs(t *testing.T) {
|
func TestPostArchiveCleanupDisabledKeepsLocalDirs(t *testing.T) {
|
||||||
cfg, seed := cleanupFixtureConfig(t)
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = false
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = false
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = false
|
cfg.Pipeline.Workspace.CleanupAfterPublish = false
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
@@ -59,8 +59,8 @@ func TestPostArchiveCleanupDisabledKeepsLocalDirs(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupSpoolOnly(t *testing.T) {
|
func TestPostArchiveCleanupSpoolOnly(t *testing.T) {
|
||||||
cfg, seed := cleanupFixtureConfig(t)
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = false
|
cfg.Pipeline.Workspace.CleanupAfterPublish = false
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
@@ -73,8 +73,8 @@ func TestPostArchiveCleanupSpoolOnly(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupWorkdirOnly(t *testing.T) {
|
func TestPostArchiveCleanupWorkdirOnly(t *testing.T) {
|
||||||
cfg, seed := cleanupFixtureConfig(t)
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = false
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = false
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
@@ -89,8 +89,8 @@ func TestPostArchiveCleanupWorkdirOnly(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupBothPolicies(t *testing.T) {
|
func TestPostArchiveCleanupBothPolicies(t *testing.T) {
|
||||||
cfg, seed := cleanupFixtureConfig(t)
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
@@ -104,12 +104,12 @@ func TestPostArchiveCleanupBothPolicies(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupNotRunWhenArchiveFails(t *testing.T) {
|
func TestPostArchiveCleanupNotRunWhenArchiveFails(t *testing.T) {
|
||||||
cfg, seed := cleanupFixtureConfig(t)
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||||
|
|
||||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{failingStage{name: "archive", err: errors.New("archive failed")}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{failingStage{name: "publish", err: errors.New("archive failed")}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||||
if err == nil || !strings.Contains(err.Error(), "stage \"archive\" failed") {
|
if err == nil || !strings.Contains(err.Error(), "stage \"publish\" failed") {
|
||||||
t.Fatalf("executeStages() error = %v, want archive failure", err)
|
t.Fatalf("executeStages() error = %v, want publish failure", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertExists(t, seed.spoolAudioDir)
|
assertExists(t, seed.spoolAudioDir)
|
||||||
@@ -118,8 +118,8 @@ func TestPostArchiveCleanupNotRunWhenArchiveFails(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupNotRunWhenArchiveSkipped(t *testing.T) {
|
func TestPostArchiveCleanupNotRunWhenArchiveSkipped(t *testing.T) {
|
||||||
cfg, seed := cleanupFixtureConfig(t)
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{metadata: map[string]any{"skipped": true}}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{metadata: map[string]any{"skipped": true}}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
@@ -131,8 +131,8 @@ func TestPostArchiveCleanupNotRunWhenArchiveSkipped(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupNotRunWhenCurrentPointerMissing(t *testing.T) {
|
func TestPostArchiveCleanupNotRunWhenCurrentPointerMissing(t *testing.T) {
|
||||||
cfg, seed := cleanupFixtureConfig(t)
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{metadata: map[string]any{"current_pointer_written": false}}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{metadata: map[string]any{"current_pointer_written": false}}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
@@ -144,9 +144,9 @@ func TestPostArchiveCleanupNotRunWhenCurrentPointerMissing(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupNotRunWhenArchiveUploadDisabled(t *testing.T) {
|
func TestPostArchiveCleanupNotRunWhenArchiveUploadDisabled(t *testing.T) {
|
||||||
cfg, seed := cleanupFixtureConfig(t)
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||||
cfg.Pipeline.Archive.UploadRun = boolPtr(false)
|
cfg.Pipeline.Publish.UploadRun = boolPtr(false)
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
@@ -158,8 +158,8 @@ func TestPostArchiveCleanupNotRunWhenArchiveUploadDisabled(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupWaitsUntilAllStagesSucceed(t *testing.T) {
|
func TestPostArchiveCleanupWaitsUntilAllStagesSucceed(t *testing.T) {
|
||||||
cfg, seed := cleanupFixtureConfig(t)
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||||
|
|
||||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}, notifyFailStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}, notifyFailStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||||
if err == nil || !strings.Contains(err.Error(), "stage \"notify\" failed") {
|
if err == nil || !strings.Contains(err.Error(), "stage \"notify\" failed") {
|
||||||
@@ -172,8 +172,8 @@ func TestPostArchiveCleanupWaitsUntilAllStagesSucceed(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupFailsOnUnsafePath(t *testing.T) {
|
func TestPostArchiveCleanupFailsOnUnsafePath(t *testing.T) {
|
||||||
cfg, _ := cleanupFixtureConfig(t)
|
cfg, _ := cleanupFixtureConfig(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = false
|
cfg.Pipeline.Workspace.CleanupAfterPublish = false
|
||||||
|
|
||||||
manifestPath := manifestPathFor(cfg)
|
manifestPath := manifestPathFor(cfg)
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
@@ -194,19 +194,19 @@ func TestPostArchiveCleanupFailsOnUnsafePath(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupNotRunWhenPromotionIsMissing(t *testing.T) {
|
func TestPostArchiveCleanupNotRunWhenPromotionIsMissing(t *testing.T) {
|
||||||
cfg, seed, runID := archiveStageCleanupFixture(t)
|
cfg, seed, runID := archiveStageCleanupFixture(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||||
cfg.Pipeline.Archive.PromoteArtifacts = []config.ArchivePromotionRule{
|
cfg.Pipeline.Publish.Outputs = []config.PublishOutputRule{
|
||||||
{Source: "narratio.transcript.merged", Dest: "transcripts/merged.json", Required: boolPtr(true)},
|
{Source: "narratio.transcript.base", Dest: "transcripts/base.json", Required: boolPtr(true)},
|
||||||
}
|
}
|
||||||
|
|
||||||
archiveStageImpl, err := stage.Select("archive")
|
archiveStageImpl, err := stage.Select("publish")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Select(archive) error = %v", err)
|
t.Fatalf("Select(publish) error = %v", err)
|
||||||
}
|
}
|
||||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||||
if err == nil || !strings.Contains(err.Error(), "required promotion source unavailable") {
|
if err == nil || !strings.Contains(err.Error(), "required output source unavailable") {
|
||||||
t.Fatalf("executeStages() error = %v, want promotion-missing failure", err)
|
t.Fatalf("executeStages() error = %v, want required output source unavailable failure", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertExists(t, seed.spoolAudioDir)
|
assertExists(t, seed.spoolAudioDir)
|
||||||
@@ -217,13 +217,13 @@ func TestPostArchiveCleanupNotRunWhenPromotionIsMissing(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupNotRunWhenCurrentManifestUploadFails(t *testing.T) {
|
func TestPostArchiveCleanupNotRunWhenCurrentManifestUploadFails(t *testing.T) {
|
||||||
cfg, seed, _ := archiveStageCleanupFixture(t)
|
cfg, seed, _ := archiveStageCleanupFixture(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||||
failKey := seed.sessionPrefix + "current/manifest.json"
|
failKey := seed.sessionPrefix + "current/manifest.json"
|
||||||
|
|
||||||
archiveStageImpl, err := stage.Select("archive")
|
archiveStageImpl, err := stage.Select("publish")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Select(archive) error = %v", err)
|
t.Fatalf("Select(publish) error = %v", err)
|
||||||
}
|
}
|
||||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{
|
||||||
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
|
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
|
||||||
@@ -238,13 +238,13 @@ func TestPostArchiveCleanupNotRunWhenCurrentManifestUploadFails(t *testing.T) {
|
|||||||
|
|
||||||
func TestPostArchiveCleanupNotRunWhenCurrentPointerUploadFails(t *testing.T) {
|
func TestPostArchiveCleanupNotRunWhenCurrentPointerUploadFails(t *testing.T) {
|
||||||
cfg, seed, _ := archiveStageCleanupFixture(t)
|
cfg, seed, _ := archiveStageCleanupFixture(t)
|
||||||
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
cfg.Pipeline.Spool.DeleteAudioAfterPublish = true
|
||||||
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
cfg.Pipeline.Workspace.CleanupAfterPublish = true
|
||||||
failKey := seed.sessionPrefix + "current/run_id.txt"
|
failKey := seed.sessionPrefix + "current/run_id.txt"
|
||||||
|
|
||||||
archiveStageImpl, err := stage.Select("archive")
|
archiveStageImpl, err := stage.Select("publish")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Select(archive) error = %v", err)
|
t.Fatalf("Select(publish) error = %v", err)
|
||||||
}
|
}
|
||||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{
|
||||||
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
|
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
|
||||||
@@ -270,7 +270,7 @@ func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
cfg.Pipeline.Archive = &config.ArchiveConfig{Enabled: boolPtr(true), UploadRun: boolPtr(true)}
|
cfg.Pipeline.Publish = &config.PublishConfig{Enabled: boolPtr(true), UploadRun: boolPtr(true)}
|
||||||
cfg.Pipeline.Spool.Root = filepath.Join(t.TempDir(), "spool")
|
cfg.Pipeline.Spool.Root = filepath.Join(t.TempDir(), "spool")
|
||||||
|
|
||||||
runID := "20260516T010203Z-1a2b3c4d"
|
runID := "20260516T010203Z-1a2b3c4d"
|
||||||
@@ -329,11 +329,11 @@ func archiveStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
|
|||||||
Bucket: "my-dnd-archive",
|
Bucket: "my-dnd-archive",
|
||||||
RootPrefix: "dnd",
|
RootPrefix: "dnd",
|
||||||
}
|
}
|
||||||
cfg.Pipeline.Archive = &config.ArchiveConfig{
|
cfg.Pipeline.Publish = &config.PublishConfig{
|
||||||
Enabled: boolPtr(true),
|
Enabled: boolPtr(true),
|
||||||
UploadRun: boolPtr(true),
|
UploadRun: boolPtr(true),
|
||||||
PromoteArtifacts: []config.ArchivePromotionRule{
|
Outputs: []config.PublishOutputRule{
|
||||||
{Source: "narratio.transcript.trimmed", Dest: "transcripts/trimmed.json", Required: boolPtr(true)},
|
{Source: "narratio.transcript.final_trimmed", Dest: "transcripts/final.trimmed.json", Required: boolPtr(true)},
|
||||||
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
|
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -371,14 +371,14 @@ func writeArchiveFixtureRunFiles(t *testing.T, runWorkDir, sessionRoot string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
mustWriteFile(t, filepath.Join(runWorkDir, "prepare", "inputs", "session.yml"), "session_id: 2026-05-03\n")
|
mustWriteFile(t, filepath.Join(runWorkDir, "prepare", "inputs", "session.yml"), "session_id: 2026-05-03\n")
|
||||||
mustWriteFile(t, filepath.Join(runWorkDir, "transcribe", "outputs", "transcripts", "raw", "speaker.json"), "{}\n")
|
mustWriteFile(t, filepath.Join(runWorkDir, "transcribe", "outputs", "transcripts", "raw", "speaker.json"), "{}\n")
|
||||||
mustWriteFile(t, filepath.Join(runWorkDir, "trim", "outputs", "transcripts", "trimmed.json"), "{\"segments\":[]}\n")
|
mustWriteFile(t, filepath.Join(runWorkDir, "trim", "outputs", "transcripts", "final.trimmed.json"), "{\"segments\":[]}\n")
|
||||||
mustWriteFile(t, filepath.Join(runWorkDir, "analyze", "outputs", "artifacts", "session_recap.md"), "# recap\n")
|
mustWriteFile(t, filepath.Join(runWorkDir, "analyze", "outputs", "artifacts", "session_recap.md"), "# recap\n")
|
||||||
mustWriteFile(t, filepath.Join(runWorkDir, "polish", "reports", "audita.report.json"), "{}\n")
|
mustWriteFile(t, filepath.Join(runWorkDir, "polish", "reports", "audita.report.json"), "{}\n")
|
||||||
mustWriteFile(t, filepath.Join(runWorkDir, "merge", "config", "seriatim.generated.yml"), "key: value\n")
|
mustWriteFile(t, filepath.Join(runWorkDir, "merge", "config", "seriatim.generated.yml"), "key: value\n")
|
||||||
mustWriteFile(t, filepath.Join(runWorkDir, "logs", "audita.stderr.log"), "stderr\n")
|
mustWriteFile(t, filepath.Join(runWorkDir, "logs", "audita.stderr.log"), "stderr\n")
|
||||||
mustWriteFile(t, filepath.Join(runWorkDir, "manifest.json"), "{}\n")
|
mustWriteFile(t, filepath.Join(runWorkDir, "manifest.json"), "{}\n")
|
||||||
|
|
||||||
mustWriteFile(t, filepath.Join(sessionRoot, "transcripts", "trimmed.json"), "{\"segments\":[]}\n")
|
mustWriteFile(t, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json"), "{\"segments\":[]}\n")
|
||||||
mustWriteFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
|
mustWriteFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type effectiveLocks struct {
|
type effectiveLocks struct {
|
||||||
Static []config.ArchiveLockRule
|
Static []config.PublishLockRule
|
||||||
Remote []config.ArchiveLockRule
|
Remote []config.PublishLockRule
|
||||||
All []config.ArchiveLockRule
|
All []config.PublishLockRule
|
||||||
Key string
|
Key string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ func remoteLocksKey(cfg *config.Config) (string, error) {
|
|||||||
return artifacts.S3SessionLocksKey(sessionPrefix), nil
|
return artifacts.S3SessionLocksKey(sessionPrefix), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadRemoteLockStore(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*config.ArchiveLockStore, string, error) {
|
func loadRemoteLockStore(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*config.PublishLockStore, string, error) {
|
||||||
key, err := remoteLocksKey(cfg)
|
key, err := remoteLocksKey(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
@@ -44,7 +44,7 @@ func loadRemoteLockStore(ctx context.Context, cfg *config.Config, store storage.
|
|||||||
return nil, key, fmt.Errorf("check remote locks %q: %w", key, err)
|
return nil, key, fmt.Errorf("check remote locks %q: %w", key, err)
|
||||||
}
|
}
|
||||||
if !exists {
|
if !exists {
|
||||||
return &config.ArchiveLockStore{}, key, nil
|
return &config.PublishLockStore{}, key, nil
|
||||||
}
|
}
|
||||||
tmp, err := downloadObjectToTemp(ctx, store, key, "narratio-locks-*.yml")
|
tmp, err := downloadObjectToTemp(ctx, store, key, "narratio-locks-*.yml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,7 +55,7 @@ func loadRemoteLockStore(ctx context.Context, cfg *config.Config, store storage.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, key, fmt.Errorf("read remote locks %q: %w", key, err)
|
return nil, key, fmt.Errorf("read remote locks %q: %w", key, err)
|
||||||
}
|
}
|
||||||
lockStore, err := config.LoadArchiveLockStoreBytes("s3://"+s3BucketName(cfg.Pipeline)+"/"+key, data, cfg.Pipeline.Scriptorium)
|
lockStore, err := config.LoadPublishLockStoreBytes("s3://"+s3BucketName(cfg.Pipeline)+"/"+key, data, cfg.Pipeline.Scriptorium)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, key, err
|
return nil, key, err
|
||||||
}
|
}
|
||||||
@@ -67,41 +67,41 @@ func loadEffectiveLocks(ctx context.Context, cfg *config.Config, store storage.O
|
|||||||
if store == nil {
|
if store == nil {
|
||||||
return &effectiveLocks{
|
return &effectiveLocks{
|
||||||
Static: staticLocks,
|
Static: staticLocks,
|
||||||
All: append([]config.ArchiveLockRule(nil), staticLocks...),
|
All: append([]config.PublishLockRule(nil), staticLocks...),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
lockStore, key, err := loadRemoteLockStore(ctx, cfg, store)
|
lockStore, key, err := loadRemoteLockStore(ctx, cfg, store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
remoteLocks := append([]config.ArchiveLockRule(nil), lockStore.Locks...)
|
remoteLocks := append([]config.PublishLockRule(nil), lockStore.Locks...)
|
||||||
return &effectiveLocks{
|
return &effectiveLocks{
|
||||||
Static: staticLocks,
|
Static: staticLocks,
|
||||||
Remote: remoteLocks,
|
Remote: remoteLocks,
|
||||||
All: config.MergeArchiveLockRules(staticLocks, remoteLocks),
|
All: config.MergePublishLockRules(staticLocks, remoteLocks),
|
||||||
Key: key,
|
Key: key,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func staticArchiveLocks(cfg *config.Config) []config.ArchiveLockRule {
|
func staticArchiveLocks(cfg *config.Config) []config.PublishLockRule {
|
||||||
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Archive == nil {
|
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Publish == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return append([]config.ArchiveLockRule(nil), cfg.Pipeline.Archive.Locks...)
|
return append([]config.PublishLockRule(nil), cfg.Pipeline.Publish.Locks...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyEffectiveLocks(cfg *config.Config, locks []config.ArchiveLockRule) {
|
func applyEffectiveLocks(cfg *config.Config, locks []config.PublishLockRule) {
|
||||||
if cfg == nil || cfg.Pipeline == nil {
|
if cfg == nil || cfg.Pipeline == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Archive == nil {
|
if cfg.Pipeline.Publish == nil {
|
||||||
cfg.Pipeline.Archive = &config.ArchiveConfig{}
|
cfg.Pipeline.Publish = &config.PublishConfig{}
|
||||||
}
|
}
|
||||||
cfg.Pipeline.Archive.Locks = append([]config.ArchiveLockRule(nil), locks...)
|
cfg.Pipeline.Publish.Locks = append([]config.PublishLockRule(nil), locks...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadRemoteLockStore(ctx context.Context, store storage.ObjectStore, key string, lockStore *config.ArchiveLockStore) error {
|
func uploadRemoteLockStore(ctx context.Context, store storage.ObjectStore, key string, lockStore *config.PublishLockStore) error {
|
||||||
data, err := config.MarshalArchiveLockStore(lockStore)
|
data, err := config.MarshalPublishLockStore(lockStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -124,8 +124,8 @@ func uploadRemoteLockStore(ctx context.Context, store storage.ObjectStore, key s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func lockSourceSet(locks []config.ArchiveLockRule) map[string]config.ArchiveLockRule {
|
func lockSourceSet(locks []config.PublishLockRule) map[string]config.PublishLockRule {
|
||||||
out := make(map[string]config.ArchiveLockRule, len(locks))
|
out := make(map[string]config.PublishLockRule, len(locks))
|
||||||
for _, lock := range locks {
|
for _, lock := range locks {
|
||||||
source := strings.TrimSpace(lock.Source)
|
source := strings.TrimSpace(lock.Source)
|
||||||
if source == "" {
|
if source == "" {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
|
|||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
remoteKey := seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}"
|
remoteKey := seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
|
||||||
inputs:
|
inputs:
|
||||||
audio_s3:
|
audio_s3:
|
||||||
prefix: audio/
|
prefix: audio/
|
||||||
@@ -29,14 +29,14 @@ inputs:
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
if storeInitCalls != 1 {
|
if storeInitCalls != 1 {
|
||||||
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
|
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "narratio plan: workdir prepared") {
|
if !strings.Contains(stdout.String(), "narratio session plan: workdir prepared") {
|
||||||
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
||||||
}
|
}
|
||||||
if _, ok := fake.Objects[remoteKey]; !ok {
|
if _, ok := fake.Objects[remoteKey]; !ok {
|
||||||
@@ -56,7 +56,7 @@ func TestExecuteRemoteSessionFallbackLoadsSecretsBeforeObjectStoreInit(t *testin
|
|||||||
addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv)
|
addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv)
|
||||||
|
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}"
|
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
|
||||||
inputs:
|
inputs:
|
||||||
audio_s3:
|
audio_s3:
|
||||||
prefix: audio/
|
prefix: audio/
|
||||||
@@ -77,7 +77,7 @@ inputs:
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ func TestExecuteExplicitLocalSessionPrecedenceSkipsRemote(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ func TestExecuteLocalSessionDiscoveryPrecedenceSkipsRemote(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ func TestExecuteRemoteSessionMissingObjectFailsClearly(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -149,12 +149,12 @@ func TestExecuteRemoteSessionRequiresSessionID(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath}, &stdout, &stderr)
|
code := Execute([]string{"session", "plan", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
if !strings.Contains(stderr.String(), "remote session loading requires --session-id") {
|
if !strings.Contains(stderr.String(), "plan: session_id is required") {
|
||||||
t.Fatalf("stderr = %q, want session-id guidance", stderr.String())
|
t.Fatalf("stderr = %q, want session_id guidance", stderr.String())
|
||||||
}
|
}
|
||||||
if storeInitCalls != 0 {
|
if storeInitCalls != 0 {
|
||||||
t.Fatalf("object store init calls = %d, want 0", storeInitCalls)
|
t.Fatalf("object store init calls = %d, want 0", storeInitCalls)
|
||||||
@@ -177,7 +177,7 @@ func TestExecuteRemoteSessionStorageInitErrorFailsClearly(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -196,7 +196,7 @@ func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -205,6 +205,48 @@ func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteRemoteSessionTemplateFailsConcreteSessionCheck(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}"
|
||||||
|
inputs:
|
||||||
|
audio_s3:
|
||||||
|
prefix: audio/
|
||||||
|
`)
|
||||||
|
var storeInitCalls int
|
||||||
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "session.yml must be concrete") || !strings.Contains(stderr.String(), "run narratio session init") {
|
||||||
|
t.Fatalf("stderr = %q, want concrete session guidance", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRemoteSessionMismatchFails(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
seedRemoteSessionConfig(t, fake, "2026-05-03", "session_id: 2026-05-04\ninputs:\n audio_s3:\n prefix: audio/\n")
|
||||||
|
var storeInitCalls int
|
||||||
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "session_id mismatch") {
|
||||||
|
t.Fatalf("stderr = %q, want session_id mismatch", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func restoreAppConfigTestGlobals(t *testing.T, fake *storage.FakeBackend, storeInitCalls *int, sessionDefaults []string) {
|
func restoreAppConfigTestGlobals(t *testing.T, fake *storage.FakeBackend, storeInitCalls *int, sessionDefaults []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
origStoreFn := newObjectStoreFromConfigFn
|
origStoreFn := newObjectStoreFromConfigFn
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
@@ -22,11 +23,13 @@ var executeRestorePlanFn = executeRestorePlan
|
|||||||
|
|
||||||
// Restore validates restore CLI/config inputs and storage preflight for future restore phases.
|
// Restore validates restore CLI/config inputs and storage preflight for future restore phases.
|
||||||
func Restore(ctx context.Context, args []string, out io.Writer) error {
|
func Restore(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
|
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
|
||||||
fs.SetOutput(out)
|
fs.SetOutput(out)
|
||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var campaignPath string
|
var campaignPath string
|
||||||
|
var campaignFilePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
var sessionID string
|
var sessionID string
|
||||||
var previousSessionID string
|
var previousSessionID string
|
||||||
@@ -34,15 +37,15 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
var force bool
|
var force bool
|
||||||
var includeAudio bool
|
var includeAudio bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||||
|
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
|
||||||
fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files")
|
fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files")
|
||||||
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
|
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
|
||||||
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
|
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
_, _ = fmt.Fprintln(out, "Usage: narratio restore [--config <path>] [--campaign <path>] [--session <path>] [--session-id <value>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]")
|
_, _ = fmt.Fprintln(out, "Usage: narratio session restore <session_id> [--config <path>] [--campaign <id>] [--campaign-file <path>] [--session <path>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]")
|
||||||
_, _ = fmt.Fprintln(out)
|
_, _ = fmt.Fprintln(out)
|
||||||
_, _ = fmt.Fprintln(out, "Flags:")
|
_, _ = fmt.Fprintln(out, "Flags:")
|
||||||
fs.PrintDefaults()
|
fs.PrintDefaults()
|
||||||
@@ -54,10 +57,22 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("restore: invalid flags: %w", err)
|
return fmt.Errorf("restore: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("restore: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("restore", fs, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("restore: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("restore", positionalSessionID, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return fmt.Errorf("restore: session_id is required")
|
||||||
|
}
|
||||||
|
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
PreviousSessionID: previousSessionID,
|
PreviousSessionID: previousSessionID,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("first restore exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
|
|||||||
}
|
}
|
||||||
stdout.Reset()
|
stdout.Reset()
|
||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
code = Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
code = Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("second restore exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -128,23 +128,29 @@ func TestExecuteRestoreIncludeAudioUsesCacheAfterWorkspaceDeletion(t *testing.T)
|
|||||||
func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
|
func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
|
||||||
|
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
||||||
seedRestoreObject(fake, sessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`))
|
seedRestorePreviousCurrent(t, fake, cfg, "# previous recap\n")
|
||||||
seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
|
|
||||||
|
|
||||||
restoreWithStoreAndRealPhases(t, fake)
|
restoreWithStoreAndRealPhases(t, fake)
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "manifest.json"), `{"session_id":"2026-04-26"}`)
|
previousManifestBytes, err := os.ReadFile(filepath.Join(sessionRoot, "previous", "manifest.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read restored previous manifest: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(previousManifestBytes), `"session_id":"2026-04-26"`) {
|
||||||
|
t.Fatalf("restored previous manifest = %q, want previous session id", string(previousManifestBytes))
|
||||||
|
}
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
|
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
|
||||||
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
|
||||||
if report.Execution.Downloaded != 3 {
|
if report.Execution.Downloaded != 3 {
|
||||||
@@ -152,6 +158,33 @@ func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteRestoreDryRunReportsPreviousCacheWithoutWriting(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
|
||||||
|
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
||||||
|
seedRestorePreviousCurrent(t, fake, cfg, "# previous recap\n")
|
||||||
|
|
||||||
|
restoreWithStoreAndRealPhases(t, fake)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--dry-run"}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "previous/artifacts/session_recap.md") {
|
||||||
|
t.Fatalf("stdout = %q, want planned previous-cache artifact", stdout.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
if _, err := os.Stat(filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("previous artifact should not be written during dry-run; stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
|
func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
@@ -167,7 +200,7 @@ func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -199,7 +232,7 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -213,10 +246,11 @@ func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
|
|||||||
func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
|
func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
appendRestoreWorkflowPreviousInputConfig(t, pipelinePath, sessionPath)
|
||||||
|
|
||||||
fake := &storage.FakeBackend{}
|
fake := &storage.FakeBackend{}
|
||||||
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
cfg, _, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, campaignPath, sessionPath)
|
||||||
seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# remote previous recap\n"))
|
seedRestorePreviousCurrent(t, fake, cfg, "# remote previous recap\n")
|
||||||
|
|
||||||
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
|
||||||
@@ -225,7 +259,7 @@ func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
@@ -251,7 +285,7 @@ func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -298,7 +332,7 @@ func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -401,6 +435,50 @@ func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipeline
|
|||||||
return cfg, sessionPrefix, manifestKey, runIDKey
|
return cfg, sessionPrefix, manifestKey, runIDKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendRestoreWorkflowPreviousInputConfig(t *testing.T, pipelinePath, sessionPath string) {
|
||||||
|
t.Helper()
|
||||||
|
appendRestoreWorkflowScriptoriumConfig(t, pipelinePath, `
|
||||||
|
scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
previous_recap:
|
||||||
|
source: narratio.previous_session.artifact.session_recap
|
||||||
|
required: true
|
||||||
|
`)
|
||||||
|
appendRestoreWorkflowScriptoriumConfig(t, sessionPath, `
|
||||||
|
previous_session_id: 2026-04-26
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedRestorePreviousCurrent(t *testing.T, fake *storage.FakeBackend, cfg *config.Config, artifactBody string) {
|
||||||
|
t.Helper()
|
||||||
|
seedRestorePreviousCurrentManifestOnly(t, fake, cfg)
|
||||||
|
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
||||||
|
seedRestoreObject(fake, previousPrefix+"artifacts/session_recap.md", []byte(artifactBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedRestorePreviousCurrentManifestOnly(t *testing.T, fake *storage.FakeBackend, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
previousPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
||||||
|
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(previousPrefix)
|
||||||
|
previousRunID := "20260426T010203Z-a1b2c3d4"
|
||||||
|
seedRestoreObject(fake, runIDKey, []byte(previousRunID+"\n"))
|
||||||
|
|
||||||
|
m := manifest.New(cfg.Session.PreviousSessionID, nowUTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
m.RunID = previousRunID
|
||||||
|
data, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal previous restore manifest: %v", err)
|
||||||
|
}
|
||||||
|
seedRestoreObject(fake, manifestKey, append(data, '\n'))
|
||||||
|
}
|
||||||
|
|
||||||
func mustReadEquals(t *testing.T, path, want string) {
|
func mustReadEquals(t *testing.T, path, want string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/previouscache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RestoreActionKind identifies one restore planner action.
|
// RestoreActionKind identifies one restore planner action.
|
||||||
@@ -114,6 +115,12 @@ func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCu
|
|||||||
actions = append(actions, action)
|
actions = append(actions, action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousActions, err := buildPreviousCacheRestoreActions(ctx, cfg, sessionPaths, store, opts.Force)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
actions = append(actions, previousActions...)
|
||||||
|
|
||||||
sort.Slice(actions, func(i, j int) bool {
|
sort.Slice(actions, func(i, j int) bool {
|
||||||
if actions[i].LocalRelativePath == actions[j].LocalRelativePath {
|
if actions[i].LocalRelativePath == actions[j].LocalRelativePath {
|
||||||
return actions[i].RemoteKey < actions[j].RemoteKey
|
return actions[i].RemoteKey < actions[j].RemoteKey
|
||||||
@@ -195,7 +202,7 @@ func restoreLocalRelativePathForKey(sessionPrefix, currentManifestKey, key strin
|
|||||||
return cleanRel, true, nil
|
return cleanRel, true, nil
|
||||||
}
|
}
|
||||||
if cleanRel == config.PathPreviousDirSegment || strings.HasPrefix(cleanRel, config.PathPreviousDirSegment+"/") {
|
if cleanRel == config.PathPreviousDirSegment || strings.HasPrefix(cleanRel, config.PathPreviousDirSegment+"/") {
|
||||||
return cleanRel, true, nil
|
return "", false, nil
|
||||||
}
|
}
|
||||||
if includeAudio && (cleanRel == config.PathAudioDirSegment || strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/")) {
|
if includeAudio && (cleanRel == config.PathAudioDirSegment || strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/")) {
|
||||||
return cleanRel, true, nil
|
return cleanRel, true, nil
|
||||||
@@ -223,6 +230,35 @@ func joinWithinSessionRoot(sessionRoot, relative string) (string, error) {
|
|||||||
return abs, nil
|
return abs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildPreviousCacheRestoreActions(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg *config.Config,
|
||||||
|
sessionPaths artifacts.SessionPaths,
|
||||||
|
store storage.ObjectStore,
|
||||||
|
force bool,
|
||||||
|
) ([]RestoreAction, error) {
|
||||||
|
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Scriptorium == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
requirements := artifacts.CollectPreviousArtifactRequirements(cfg.Pipeline.Scriptorium.Artifacts)
|
||||||
|
if len(requirements) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
plan, err := previouscache.BuildPlan(ctx, cfg, sessionPaths, requirements, store)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("plan previous-session cache restore: %w", err)
|
||||||
|
}
|
||||||
|
actions := make([]RestoreAction, 0, len(plan.Records))
|
||||||
|
for _, record := range plan.Records {
|
||||||
|
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{Key: record.RemoteKey}, record.LocalRelativePath, record.LocalPath, force)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("classify previous-session cache object %q: %w", record.RemoteKey, err)
|
||||||
|
}
|
||||||
|
actions = append(actions, action)
|
||||||
|
}
|
||||||
|
return actions, nil
|
||||||
|
}
|
||||||
|
|
||||||
func classifyRestoreAction(
|
func classifyRestoreAction(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
store storage.ObjectStore,
|
store storage.ObjectStore,
|
||||||
|
|||||||
@@ -86,6 +86,27 @@ func TestRestorePlanExistingAudioUsesSizeWithoutRemoteChecksumDownload(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) {
|
func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
configureRestorePlanPreviousRequirement(cfg, true)
|
||||||
|
current := restorePlanCurrentState(t, cfg)
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
|
||||||
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
||||||
|
seedRestorePreviousCurrent(t, store, cfg, "# previous recap\n")
|
||||||
|
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := actionRelPaths(plan.Actions)
|
||||||
|
want := []string{"manifest.json", "previous/artifacts/session_recap.md", "previous/manifest.json"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestorePlanIgnoresCurrentSessionArchivedPreviousCache(t *testing.T) {
|
||||||
cfg := restorePlanConfig(t)
|
cfg := restorePlanConfig(t)
|
||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
store := &storage.FakeBackend{}
|
store := &storage.FakeBackend{}
|
||||||
@@ -100,12 +121,76 @@ func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := actionRelPaths(plan.Actions)
|
got := actionRelPaths(plan.Actions)
|
||||||
want := []string{"manifest.json", "previous/artifacts/session_recap.md", "previous/manifest.json"}
|
want := []string{"manifest.json"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRestorePlanMissingOptionalPreviousCacheSkipsArtifact(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
configureRestorePlanPreviousRequirement(cfg, false)
|
||||||
|
current := restorePlanCurrentState(t, cfg)
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
|
||||||
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
||||||
|
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
|
||||||
|
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := actionRelPaths(plan.Actions)
|
||||||
|
want := []string{"manifest.json", "previous/manifest.json"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("action local paths = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestorePlanMissingRequiredPreviousCacheFails(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
configureRestorePlanPreviousRequirement(cfg, true)
|
||||||
|
current := restorePlanCurrentState(t, cfg)
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
|
||||||
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
||||||
|
seedRestorePreviousCurrentManifestOnly(t, store, cfg)
|
||||||
|
|
||||||
|
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "required previous-session artifact") {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v, want required previous artifact failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestorePlanPreviousCacheConflictRequiresForce(t *testing.T) {
|
||||||
|
cfg := restorePlanConfig(t)
|
||||||
|
configureRestorePlanPreviousRequirement(cfg, true)
|
||||||
|
current := restorePlanCurrentState(t, cfg)
|
||||||
|
store := &storage.FakeBackend{}
|
||||||
|
|
||||||
|
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
|
||||||
|
seedRestorePreviousCurrent(t, store, cfg, "# remote previous recap\n")
|
||||||
|
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
|
||||||
|
|
||||||
|
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan() error = %v", err)
|
||||||
|
}
|
||||||
|
if plan.ConflictCount != 1 {
|
||||||
|
t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err = buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildRestorePlan(force) error = %v", err)
|
||||||
|
}
|
||||||
|
if plan.ConflictCount != 0 {
|
||||||
|
t.Fatalf("force ConflictCount = %d, want 0", plan.ConflictCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRestorePlanClassifiesSameAndConflict(t *testing.T) {
|
func TestRestorePlanClassifiesSameAndConflict(t *testing.T) {
|
||||||
cfg := restorePlanConfig(t)
|
cfg := restorePlanConfig(t)
|
||||||
current := restorePlanCurrentState(t, cfg)
|
current := restorePlanCurrentState(t, cfg)
|
||||||
@@ -207,6 +292,10 @@ func restorePlanConfig(t *testing.T) *config.Config {
|
|||||||
return &config.Config{
|
return &config.Config{
|
||||||
Pipeline: &config.PipelineConfig{
|
Pipeline: &config.PipelineConfig{
|
||||||
Workspace: config.WorkspaceConfig{Root: workspaceRoot},
|
Workspace: config.WorkspaceConfig{Root: workspaceRoot},
|
||||||
|
Storage: config.StorageConfig{S3: &config.StorageS3Config{
|
||||||
|
Bucket: "test-bucket",
|
||||||
|
RootPrefix: "dnd",
|
||||||
|
}},
|
||||||
},
|
},
|
||||||
Session: &config.SessionConfig{
|
Session: &config.SessionConfig{
|
||||||
SessionID: "2026-05-03",
|
SessionID: "2026-05-03",
|
||||||
@@ -215,6 +304,24 @@ func restorePlanConfig(t *testing.T) *config.Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configureRestorePlanPreviousRequirement(cfg *config.Config, required bool) {
|
||||||
|
cfg.Session.PreviousSessionID = "2026-04-26"
|
||||||
|
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
|
||||||
|
Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
||||||
|
"session_recap": {
|
||||||
|
Enabled: true,
|
||||||
|
OutputPath: "artifacts/session_recap.md",
|
||||||
|
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||||
|
"previous_recap": {
|
||||||
|
Source: "narratio.previous_session.artifact.session_recap",
|
||||||
|
Required: required,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState {
|
func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestExecuteRestoreHelp(t *testing.T) {
|
|||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
code := Execute([]string{"restore", "--help"}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "--help"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0", code)
|
t.Fatalf("exit code = %d, want 0", code)
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ func TestExecuteRestoreHelp(t *testing.T) {
|
|||||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
}
|
}
|
||||||
out := stdout.String()
|
out := stdout.String()
|
||||||
if !strings.Contains(out, "Usage: narratio restore") {
|
if !strings.Contains(out, "Usage: narratio session restore <session_id>") {
|
||||||
t.Fatalf("stdout = %q, want restore usage", out)
|
t.Fatalf("stdout = %q, want restore usage", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "--include-audio") {
|
if !strings.Contains(out, "--include-audio") {
|
||||||
@@ -79,11 +79,10 @@ func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
|
|||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(
|
code := Execute(
|
||||||
[]string{
|
[]string{
|
||||||
"restore",
|
"session", "restore", "2026-05-03",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
"--force",
|
"--force",
|
||||||
"--include-audio",
|
"--include-audio",
|
||||||
@@ -124,7 +123,7 @@ func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "extra"}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -146,7 +145,7 @@ func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -174,7 +173,7 @@ func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
|
|||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -263,11 +262,10 @@ func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
|
|||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute(
|
code := Execute(
|
||||||
[]string{
|
[]string{
|
||||||
"restore",
|
"session", "restore", "2026-05-03",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
},
|
},
|
||||||
&stdout,
|
&stdout,
|
||||||
@@ -315,7 +313,7 @@ func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
if code == 0 {
|
if code == 0 {
|
||||||
t.Fatal("exit code = 0, want non-zero")
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
}
|
}
|
||||||
@@ -367,7 +365,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
code := Execute([]string{"restore", "--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
code := Execute([]string{"session", "restore", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &stdout, &stderr)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,11 +46,10 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
|||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
restoreCode := Execute(
|
restoreCode := Execute(
|
||||||
[]string{
|
[]string{
|
||||||
"restore",
|
"session", "restore", cfg.Session.SessionID,
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", cfg.Session.SessionID,
|
|
||||||
},
|
},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
@@ -85,14 +84,12 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
|||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
runStageCode := Execute(
|
runStageCode := Execute(
|
||||||
[]string{
|
[]string{
|
||||||
"run-stage",
|
"run-stage", "analyze", cfg.Session.SessionID,
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", cfg.Session.SessionID,
|
|
||||||
"--force",
|
"--force",
|
||||||
"--artifacts", "player_handout",
|
"--artifacts", "player_handout",
|
||||||
"analyze",
|
|
||||||
},
|
},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
@@ -170,20 +167,22 @@ scriptorium:
|
|||||||
output_path: artifacts/session_recap.md
|
output_path: artifacts/session_recap.md
|
||||||
inputs:
|
inputs:
|
||||||
transcript:
|
transcript:
|
||||||
source: narratio.transcript.trimmed
|
source: narratio.transcript.final_trimmed
|
||||||
required: true
|
required: true
|
||||||
previous_recap:
|
previous_recap:
|
||||||
source: narratio.previous_session.artifact.session_recap
|
source: narratio.previous_session.artifact.session_recap
|
||||||
required: true
|
required: true
|
||||||
|
`)
|
||||||
|
appendRestoreWorkflowScriptoriumConfig(t, sessionPath, `
|
||||||
|
previous_session_id: 2026-04-26
|
||||||
`)
|
`)
|
||||||
|
|
||||||
fakeStore := &storage.FakeBackend{}
|
fakeStore := &storage.FakeBackend{}
|
||||||
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fakeStore, pipelinePath, campaignPath, sessionPath)
|
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fakeStore, pipelinePath, campaignPath, sessionPath)
|
||||||
seedRestoreObject(fakeStore, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
|
seedRestoreObject(fakeStore, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
|
||||||
seedRestoreObject(fakeStore, manifestKey, restoreWorkflowManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
|
seedRestoreObject(fakeStore, manifestKey, restoreWorkflowManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
|
||||||
seedRestoreObject(fakeStore, sessionPrefix+"transcripts/trimmed.json", []byte(`{"segments":[]}`+"\n"))
|
seedRestoreObject(fakeStore, sessionPrefix+"transcripts/final.trimmed.json", []byte(`{"segments":[]}`+"\n"))
|
||||||
seedRestoreObject(fakeStore, sessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`))
|
seedRestorePreviousCurrent(t, fakeStore, cfg, "# previous recap\n")
|
||||||
seedRestoreObject(fakeStore, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
|
|
||||||
|
|
||||||
restoreWithStoreAndRealPhases(t, fakeStore)
|
restoreWithStoreAndRealPhases(t, fakeStore)
|
||||||
|
|
||||||
@@ -191,11 +190,10 @@ scriptorium:
|
|||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
restoreCode := Execute(
|
restoreCode := Execute(
|
||||||
[]string{
|
[]string{
|
||||||
"restore",
|
"session", "restore", cfg.Session.SessionID,
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", cfg.Session.SessionID,
|
|
||||||
},
|
},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
@@ -208,8 +206,14 @@ scriptorium:
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "trimmed.json"), `{"segments":[]}`+"\n")
|
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`+"\n")
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "manifest.json"), `{"session_id":"2026-04-26"}`)
|
previousManifestBytes, err := os.ReadFile(filepath.Join(sessionRoot, "previous", "manifest.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read restored previous manifest: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(previousManifestBytes), `"session_id":"2026-04-26"`) {
|
||||||
|
t.Fatalf("restored previous manifest = %q, want previous session id", string(previousManifestBytes))
|
||||||
|
}
|
||||||
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
|
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
|
||||||
|
|
||||||
scriptoriumFake := &scriptorium.FakeRunner{}
|
scriptoriumFake := &scriptorium.FakeRunner{}
|
||||||
@@ -236,14 +240,12 @@ scriptorium:
|
|||||||
stderr.Reset()
|
stderr.Reset()
|
||||||
runStageCode := Execute(
|
runStageCode := Execute(
|
||||||
[]string{
|
[]string{
|
||||||
"run-stage",
|
"run-stage", "analyze", cfg.Session.SessionID,
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", cfg.Session.SessionID,
|
|
||||||
"--force",
|
"--force",
|
||||||
"--artifacts", "session_recap",
|
"--artifacts", "session_recap",
|
||||||
"analyze",
|
|
||||||
},
|
},
|
||||||
&stdout,
|
&stdout,
|
||||||
&stderr,
|
&stderr,
|
||||||
@@ -261,7 +263,7 @@ scriptorium:
|
|||||||
t.Fatalf("scriptorium run requests = %d, want 1", len(scriptoriumFake.RunRequests))
|
t.Fatalf("scriptorium run requests = %d, want 1", len(scriptoriumFake.RunRequests))
|
||||||
}
|
}
|
||||||
req := scriptoriumFake.RunRequests[0]
|
req := scriptoriumFake.RunRequests[0]
|
||||||
if got := req.InputPaths["transcript"]; got != filepath.Join(sessionRoot, "transcripts", "trimmed.json") {
|
if got := req.InputPaths["transcript"]; got != filepath.Join(sessionRoot, "transcripts", "final.trimmed.json") {
|
||||||
t.Fatalf("transcript input = %q, want trimmed transcript path", got)
|
t.Fatalf("transcript input = %q, want trimmed transcript path", got)
|
||||||
}
|
}
|
||||||
if got := req.InputPaths["previous_recap"]; got != filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md") {
|
if got := req.InputPaths["previous_recap"]; got != filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md") {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
@@ -13,31 +14,45 @@ import (
|
|||||||
|
|
||||||
// Resume continues execution from the first non-succeeded stage in the manifest.
|
// Resume continues execution from the first non-succeeded stage in the manifest.
|
||||||
func Resume(ctx context.Context, args []string, out io.Writer) error {
|
func Resume(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("resume", flag.ContinueOnError)
|
fs := flag.NewFlagSet("resume", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var campaignPath string
|
var campaignPath string
|
||||||
|
var campaignFilePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
var sessionID string
|
var sessionID string
|
||||||
var previousSessionID string
|
var previousSessionID string
|
||||||
var force bool
|
var force bool
|
||||||
var selectedArtifacts artifactSelectionFlag
|
var selectedArtifacts artifactSelectionFlag
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||||
|
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
|
||||||
fs.BoolVar(&force, "force", false, "force stage execution")
|
fs.BoolVar(&force, "force", false, "force stage execution")
|
||||||
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
|
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("resume: invalid flags: %w", err)
|
return fmt.Errorf("resume: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("resume: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("resume", fs, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("resume: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("resume", positionalSessionID, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return fmt.Errorf("resume: session_id is required")
|
||||||
|
}
|
||||||
|
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
PreviousSessionID: previousSessionID,
|
PreviousSessionID: previousSessionID,
|
||||||
})
|
})
|
||||||
@@ -51,7 +66,7 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resume: invalid --artifacts: %w", err)
|
return fmt.Errorf("resume: invalid --artifacts: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateSelectedAnalyzeArtifacts(cfg, normalizedArtifacts); err != nil {
|
if err := validateSelectedArtifacts(cfg, normalizedArtifacts); err != nil {
|
||||||
return fmt.Errorf("resume: %w", err)
|
return fmt.Errorf("resume: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
|
|||||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resume() error = %v", err)
|
t.Fatalf("Resume() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestResumeNoRemainingStages(t *testing.T) {
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
@@ -64,7 +64,7 @@ func TestResumeNoRemainingStages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resume() error = %v", err)
|
t.Fatalf("Resume() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
|
|||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
@@ -93,7 +93,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force"}, &out)
|
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resume() error = %v", err)
|
t.Fatalf("Resume() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -107,11 +107,11 @@ func TestRunStageExecutesOnlySelectedStage(t *testing.T) {
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`)
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, &out)
|
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage() error = %v", err)
|
t.Fatalf("RunStage() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`)
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
@@ -148,7 +148,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "polish"}, &out)
|
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage() error = %v", err)
|
t.Fatalf("RunStage() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out.Reset()
|
out.Reset()
|
||||||
err = RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &out)
|
err = RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage(force) error = %v", err)
|
t.Fatalf("RunStage(force) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -171,12 +171,12 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "merged.json"), `{"segments":[]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`)
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||||
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||||
@@ -184,7 +184,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--force", "polish"}, &out)
|
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage(force) error = %v", err)
|
t.Fatalf("RunStage(force) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -196,14 +196,14 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load manifest after force: %v", err)
|
t.Fatalf("load manifest after force: %v", err)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"normalize", "trim", "analyze", "archive", "notify"} {
|
for _, name := range []string{"normalize", "trim", "analyze", "publish", "notify"} {
|
||||||
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
||||||
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out.Reset()
|
out.Reset()
|
||||||
err = Resume(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath}, &out)
|
err = Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resume() error = %v", err)
|
t.Fatalf("Resume() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -217,10 +217,10 @@ func TestRunStageTrimExecutes(t *testing.T) {
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "normalized.json"), `{"segments":[{"id":1},{"id":2}]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1},{"id":2}]}`)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "trim"}, &out)
|
err := RunStage(context.Background(), []string{"trim", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage(trim) error = %v", err)
|
t.Fatalf("RunStage(trim) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -246,10 +246,10 @@ func TestRunStageNormalizeExecutes(t *testing.T) {
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "processed.json"), `{"segments":[{"id":1},{"id":2}]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "polished.json"), `{"segments":[{"id":1},{"id":2}]}`)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "normalize"}, &out)
|
err := RunStage(context.Background(), []string{"normalize", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage(normalize) error = %v", err)
|
t.Fatalf("RunStage(normalize) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,37 +5,52 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run executes the pipeline plan and persists manifest state.
|
// Run executes the pipeline plan and persists manifest state.
|
||||||
func Run(ctx context.Context, args []string, out io.Writer) error {
|
func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var campaignPath string
|
var campaignPath string
|
||||||
|
var campaignFilePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
var sessionID string
|
var sessionID string
|
||||||
var previousSessionID string
|
var previousSessionID string
|
||||||
var force bool
|
var force bool
|
||||||
var selectedArtifacts artifactSelectionFlag
|
var selectedArtifacts artifactSelectionFlag
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||||
|
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
|
||||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||||
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
|
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("run: invalid flags: %w", err)
|
return fmt.Errorf("run: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("run: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("run", fs, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("run: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("run", positionalSessionID, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return fmt.Errorf("run: session_id is required")
|
||||||
|
}
|
||||||
|
cfg, err := loadCommandConfig(ctx, pipelinePath, campaignPath, campaignFilePath, sessionPath, config.SessionLoadOptions{
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
PreviousSessionID: previousSessionID,
|
PreviousSessionID: previousSessionID,
|
||||||
})
|
})
|
||||||
@@ -49,7 +64,7 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: invalid --artifacts: %w", err)
|
return fmt.Errorf("run: invalid --artifacts: %w", err)
|
||||||
}
|
}
|
||||||
if err := validateSelectedAnalyzeArtifacts(cfg, normalizedArtifacts); err != nil {
|
if err := validateSelectedArtifacts(cfg, normalizedArtifacts); err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestDecideStageActions(t *testing.T) {
|
|||||||
|
|
||||||
func TestDownstreamStageNames(t *testing.T) {
|
func TestDownstreamStageNames(t *testing.T) {
|
||||||
got := downstreamStageNames("polish")
|
got := downstreamStageNames("polish")
|
||||||
want := []string{"normalize", "trim", "analyze", "archive", "notify"}
|
want := []string{"normalize", "trim", "analyze", "publish", "notify"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
@@ -65,11 +65,11 @@ func TestInvalidateDownstreamSucceededStages(t *testing.T) {
|
|||||||
m.MarkStageSucceeded("normalize", now, nil)
|
m.MarkStageSucceeded("normalize", now, nil)
|
||||||
m.MarkStageSucceeded("trim", now, nil)
|
m.MarkStageSucceeded("trim", now, nil)
|
||||||
m.MarkStageFailed("analyze", now, "analysis failed")
|
m.MarkStageFailed("analyze", now, "analysis failed")
|
||||||
m.MarkStageSucceeded("archive", now, nil)
|
m.MarkStageSucceeded("publish", now, nil)
|
||||||
m.MarkStageSucceeded("notify", now, nil)
|
m.MarkStageSucceeded("notify", now, nil)
|
||||||
|
|
||||||
got := invalidateDownstreamSucceededStages(m, "polish", now.Add(1*time.Second))
|
got := invalidateDownstreamSucceededStages(m, "polish", now.Add(1*time.Second))
|
||||||
want := []string{"normalize", "trim", "archive", "notify"}
|
want := []string{"normalize", "trim", "publish", "notify"}
|
||||||
if !reflect.DeepEqual(got, want) {
|
if !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("invalidateDownstreamSucceededStages() = %#v, want %#v", got, want)
|
t.Fatalf("invalidateDownstreamSucceededStages() = %#v, want %#v", got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,43 +5,66 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunStage executes exactly one selected stage.
|
// RunStage executes exactly one selected stage.
|
||||||
func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
var stageName string
|
||||||
|
var positionalSessionID string
|
||||||
|
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
|
||||||
|
stageName = strings.TrimSpace(args[0])
|
||||||
|
positionalSessionID = strings.TrimSpace(args[1])
|
||||||
|
args = append([]string(nil), args[2:]...)
|
||||||
|
}
|
||||||
|
|
||||||
fs := flag.NewFlagSet("run-stage", flag.ContinueOnError)
|
fs := flag.NewFlagSet("run-stage", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var campaignPath string
|
var campaignPath string
|
||||||
|
var campaignFilePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
var sessionID string
|
var sessionID string
|
||||||
var previousSessionID string
|
var previousSessionID string
|
||||||
var force bool
|
var force bool
|
||||||
var selectedArtifacts artifactSelectionFlag
|
var selectedArtifacts artifactSelectionFlag
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||||
|
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
|
||||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||||
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
|
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute or publish (comma-separated or repeatable)")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("run-stage: invalid flags: %w", err)
|
return fmt.Errorf("run-stage: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 1 {
|
if stageName == "" {
|
||||||
return fmt.Errorf("run-stage: expected exactly one stage name")
|
switch fs.NArg() {
|
||||||
|
case 2:
|
||||||
|
stageName = strings.TrimSpace(fs.Arg(0))
|
||||||
|
positionalSessionID = strings.TrimSpace(fs.Arg(1))
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("run-stage: expected stage name and session_id")
|
||||||
|
}
|
||||||
|
} else if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("run-stage: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("run-stage", positionalSessionID, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return fmt.Errorf("run-stage: session_id is required")
|
||||||
}
|
}
|
||||||
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run-stage: invalid --artifacts: %w", err)
|
return fmt.Errorf("run-stage: invalid --artifacts: %w", err)
|
||||||
}
|
}
|
||||||
stageName := fs.Arg(0)
|
if len(normalizedArtifacts) > 0 && stageName != "analyze" && stageName != "publish" {
|
||||||
if len(normalizedArtifacts) > 0 && stageName != "analyze" {
|
return fmt.Errorf("run-stage: --artifacts is only supported for stages \"analyze\" and \"publish\"")
|
||||||
return fmt.Errorf("run-stage: --artifacts is only supported for stage \"analyze\"")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := runSingleStageCommand(ctx, singleStageCommand{
|
summary, err := runSingleStageCommand(ctx, singleStageCommand{
|
||||||
@@ -49,6 +72,7 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
StageName: stageName,
|
StageName: stageName,
|
||||||
PipelinePath: pipelinePath,
|
PipelinePath: pipelinePath,
|
||||||
CampaignPath: campaignPath,
|
CampaignPath: campaignPath,
|
||||||
|
CampaignFilePath: campaignFilePath,
|
||||||
SessionPath: sessionPath,
|
SessionPath: sessionPath,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
PreviousSessionID: previousSessionID,
|
PreviousSessionID: previousSessionID,
|
||||||
@@ -73,27 +97,41 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
// Analyze force-runs the analyze stage.
|
// Analyze force-runs the analyze stage.
|
||||||
func Analyze(ctx context.Context, args []string, out io.Writer) error {
|
func Analyze(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
fs := flag.NewFlagSet("analyze", flag.ContinueOnError)
|
fs := flag.NewFlagSet("analyze", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var campaignPath string
|
var campaignPath string
|
||||||
|
var campaignFilePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
var sessionID string
|
var sessionID string
|
||||||
var previousSessionID string
|
var previousSessionID string
|
||||||
var selectedArtifacts artifactSelectionFlag
|
var selectedArtifacts artifactSelectionFlag
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||||
|
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||||
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
|
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute during analyze (comma-separated or repeatable)")
|
||||||
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
|
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("analyze: invalid flags: %w", err)
|
return fmt.Errorf("analyze: invalid flags: %w", err)
|
||||||
}
|
}
|
||||||
if fs.NArg() != 0 {
|
if positionalSessionID == "" {
|
||||||
return fmt.Errorf("analyze: unexpected positional arguments")
|
if err := applyParsedSessionIDArg("analyze", fs, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("analyze: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("analyze", positionalSessionID, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return fmt.Errorf("analyze: session_id is required")
|
||||||
}
|
}
|
||||||
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,6 +143,7 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
StageName: "analyze",
|
StageName: "analyze",
|
||||||
PipelinePath: pipelinePath,
|
PipelinePath: pipelinePath,
|
||||||
CampaignPath: campaignPath,
|
CampaignPath: campaignPath,
|
||||||
|
CampaignFilePath: campaignFilePath,
|
||||||
SessionPath: sessionPath,
|
SessionPath: sessionPath,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
PreviousSessionID: previousSessionID,
|
PreviousSessionID: previousSessionID,
|
||||||
@@ -125,11 +164,81 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish force-runs the publish stage.
|
||||||
|
func Publish(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
positionalSessionID, args := pullLeadingSessionID(args)
|
||||||
|
fs := flag.NewFlagSet("publish", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(io.Discard)
|
||||||
|
|
||||||
|
var pipelinePath string
|
||||||
|
var campaignPath string
|
||||||
|
var campaignFilePath string
|
||||||
|
var sessionPath string
|
||||||
|
var sessionID string
|
||||||
|
var previousSessionID string
|
||||||
|
var selectedArtifacts artifactSelectionFlag
|
||||||
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||||
|
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||||
|
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||||
|
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||||
|
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to publish (comma-separated or repeatable)")
|
||||||
|
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return fmt.Errorf("publish: invalid flags: %w", err)
|
||||||
|
}
|
||||||
|
if positionalSessionID == "" {
|
||||||
|
if err := applyParsedSessionIDArg("publish", fs, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return fmt.Errorf("publish: unexpected positional arguments")
|
||||||
|
}
|
||||||
|
if err := applyPositionalSessionID("publish", positionalSessionID, &sessionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(sessionID) == "" {
|
||||||
|
return fmt.Errorf("publish: session_id is required")
|
||||||
|
}
|
||||||
|
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("publish: invalid --artifacts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := runSingleStageCommand(ctx, singleStageCommand{
|
||||||
|
CommandName: "publish",
|
||||||
|
StageName: "publish",
|
||||||
|
PipelinePath: pipelinePath,
|
||||||
|
CampaignPath: campaignPath,
|
||||||
|
CampaignFilePath: campaignFilePath,
|
||||||
|
SessionPath: sessionPath,
|
||||||
|
SessionID: sessionID,
|
||||||
|
PreviousSessionID: previousSessionID,
|
||||||
|
Force: true,
|
||||||
|
SelectedArtifacts: normalizedArtifacts,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(
|
||||||
|
out,
|
||||||
|
"narratio publish: executed=%d skipped=%d force=true; manifest=%s\n",
|
||||||
|
len(summary.Executed),
|
||||||
|
len(summary.Skipped),
|
||||||
|
summary.ManifestPath,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
type singleStageCommand struct {
|
type singleStageCommand struct {
|
||||||
CommandName string
|
CommandName string
|
||||||
StageName string
|
StageName string
|
||||||
PipelinePath string
|
PipelinePath string
|
||||||
CampaignPath string
|
CampaignPath string
|
||||||
|
CampaignFilePath string
|
||||||
SessionPath string
|
SessionPath string
|
||||||
SessionID string
|
SessionID string
|
||||||
PreviousSessionID string
|
PreviousSessionID string
|
||||||
@@ -143,7 +252,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
|
|||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.SessionPath, config.SessionLoadOptions{
|
cfg, err := loadCommandConfig(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{
|
||||||
SessionID: req.SessionID,
|
SessionID: req.SessionID,
|
||||||
PreviousSessionID: req.PreviousSessionID,
|
PreviousSessionID: req.PreviousSessionID,
|
||||||
})
|
})
|
||||||
@@ -153,7 +262,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
|
|||||||
if err := config.Validate(cfg); err != nil {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
}
|
}
|
||||||
if err := validateSelectedAnalyzeArtifacts(cfg, req.SelectedArtifacts); err != nil {
|
if err := validateSelectedArtifacts(cfg, req.SelectedArtifacts); err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
if env.Config == nil {
|
if env.Config == nil {
|
||||||
env.Config = cfg
|
env.Config = cfg
|
||||||
}
|
}
|
||||||
env.SelectedAnalyzeArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
env.SelectedArtifactKeys = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
if env.ArtifactStore == nil {
|
if env.ArtifactStore == nil {
|
||||||
env.ArtifactStore = artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
env.ArtifactStore = artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
||||||
}
|
}
|
||||||
@@ -568,16 +568,16 @@ func needsObjectStoreForRun(cfg *config.Config, stages []stage.Stage) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !stageRequested("archive") {
|
if !stageRequested("publish") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Archive == nil {
|
if cfg.Pipeline.Publish == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Archive.Enabled != nil && !*cfg.Pipeline.Archive.Enabled {
|
if cfg.Pipeline.Publish.Enabled != nil && !*cfg.Pipeline.Publish.Enabled {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Archive.UploadRun != nil && !*cfg.Pipeline.Archive.UploadRun {
|
if cfg.Pipeline.Publish.UploadRun != nil && !*cfg.Pipeline.Publish.UploadRun {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -587,23 +587,23 @@ func needsRemoteLocksForRun(cfg *config.Config, stages []stage.Stage) bool {
|
|||||||
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
|
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
archiveRequested := false
|
publishRequested := false
|
||||||
for _, s := range stages {
|
for _, s := range stages {
|
||||||
if s != nil && s.Name() == "archive" {
|
if s != nil && s.Name() == "publish" {
|
||||||
archiveRequested = true
|
publishRequested = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !archiveRequested {
|
if !publishRequested {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Archive == nil {
|
if cfg.Pipeline.Publish == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Archive.Enabled != nil && !*cfg.Pipeline.Archive.Enabled {
|
if cfg.Pipeline.Publish.Enabled != nil && !*cfg.Pipeline.Publish.Enabled {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Archive.UploadRun != nil && !*cfg.Pipeline.Archive.UploadRun {
|
if cfg.Pipeline.Publish.UploadRun != nil && !*cfg.Pipeline.Publish.UploadRun {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return cfg.Pipeline.Storage.S3 != nil
|
return cfg.Pipeline.Storage.S3 != nil
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (s captureSelectedArtifactsStage) Name() string { return s.name }
|
|||||||
func (s captureSelectedArtifactsStage) Declares() stage.IODecl { return stage.IODecl{} }
|
func (s captureSelectedArtifactsStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||||
func (s captureSelectedArtifactsStage) Run(_ context.Context, env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
func (s captureSelectedArtifactsStage) Run(_ context.Context, env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
if s.captured != nil {
|
if s.captured != nil {
|
||||||
*s.captured = append((*s.captured)[:0], env.SelectedAnalyzeArtifacts...)
|
*s.captured = append((*s.captured)[:0], env.SelectedArtifactKeys...)
|
||||||
}
|
}
|
||||||
return &stage.StageResult{Metadata: map[string]any{"captured": true}}, nil
|
return &stage.StageResult{Metadata: map[string]any{"captured": true}}, nil
|
||||||
}
|
}
|
||||||
@@ -78,12 +78,12 @@ type selectedAnalyzeArtifactStage struct {
|
|||||||
func (s selectedAnalyzeArtifactStage) Name() string { return "analyze" }
|
func (s selectedAnalyzeArtifactStage) Name() string { return "analyze" }
|
||||||
func (s selectedAnalyzeArtifactStage) Declares() stage.IODecl { return stage.IODecl{} }
|
func (s selectedAnalyzeArtifactStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||||
func (s selectedAnalyzeArtifactStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
func (s selectedAnalyzeArtifactStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
if len(env.SelectedAnalyzeArtifacts) != len(s.expected) {
|
if len(env.SelectedArtifactKeys) != len(s.expected) {
|
||||||
return nil, fmt.Errorf("selected artifacts len = %d, want %d", len(env.SelectedAnalyzeArtifacts), len(s.expected))
|
return nil, fmt.Errorf("selected artifacts len = %d, want %d", len(env.SelectedArtifactKeys), len(s.expected))
|
||||||
}
|
}
|
||||||
for i := range s.expected {
|
for i := range s.expected {
|
||||||
if env.SelectedAnalyzeArtifacts[i] != s.expected[i] {
|
if env.SelectedArtifactKeys[i] != s.expected[i] {
|
||||||
return nil, fmt.Errorf("selected artifacts[%d] = %q, want %q", i, env.SelectedAnalyzeArtifacts[i], s.expected[i])
|
return nil, fmt.Errorf("selected artifacts[%d] = %q, want %q", i, env.SelectedArtifactKeys[i], s.expected[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,16 +248,16 @@ func TestNeedsObjectStoreForRunPrepareWithPreviousRequirements(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteStagesArchiveFailsWhenRequiredRecapPromotionMissingForSelectedArtifacts(t *testing.T) {
|
func TestExecuteStagesArchiveSkipsRequiredUnselectedConfiguredPromotion(t *testing.T) {
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{
|
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{
|
||||||
Bucket: "my-dnd-archive",
|
Bucket: "my-dnd-archive",
|
||||||
RootPrefix: "dnd",
|
RootPrefix: "dnd",
|
||||||
}
|
}
|
||||||
cfg.Pipeline.Archive = &config.ArchiveConfig{
|
cfg.Pipeline.Publish = &config.PublishConfig{
|
||||||
Enabled: boolPtr(true),
|
Enabled: boolPtr(true),
|
||||||
UploadRun: boolPtr(true),
|
UploadRun: boolPtr(true),
|
||||||
PromoteArtifacts: []config.ArchivePromotionRule{
|
Outputs: []config.PublishOutputRule{
|
||||||
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
|
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -283,12 +283,12 @@ func TestExecuteStagesArchiveFailsWhenRequiredRecapPromotionMissingForSelectedAr
|
|||||||
t.Fatalf("Save manifest error = %v", err)
|
t.Fatalf("Save manifest error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
archiveStageImpl, err := stage.Select("archive")
|
archiveStageImpl, err := stage.Select("publish")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Select(archive) error = %v", err)
|
t.Fatalf("Select(publish) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = executeStages(
|
summary, err := executeStages(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
cfg,
|
cfg,
|
||||||
[]stage.Stage{
|
[]stage.Stage{
|
||||||
@@ -300,11 +300,28 @@ func TestExecuteStagesArchiveFailsWhenRequiredRecapPromotionMissingForSelectedAr
|
|||||||
Env: &Env{ObjectStore: &storage.FakeBackend{}},
|
Env: &Env{ObjectStore: &storage.FakeBackend{}},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("expected archive promotion failure, got nil")
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "required promotion source unavailable") {
|
if len(summary.Executed) != 2 || summary.Executed[0] != "analyze" || summary.Executed[1] != "publish" {
|
||||||
t.Fatalf("error = %q, want required promotion source unavailable", err.Error())
|
t.Fatalf("executed = %#v, want analyze and archive", summary.Executed)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedManifest, err := store.Load(context.Background(), summary.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load manifest error = %v", err)
|
||||||
|
}
|
||||||
|
meta := loadedManifest.Stages["publish"].Metadata
|
||||||
|
skipped, ok := meta["skipped_unselected_outputs"].([]any)
|
||||||
|
if !ok || len(skipped) != 1 {
|
||||||
|
t.Fatalf("skipped_unselected_outputs = %#v, want one item", meta["skipped_unselected_outputs"])
|
||||||
|
}
|
||||||
|
item, ok := skipped[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("skipped item = %#v, want object", skipped[0])
|
||||||
|
}
|
||||||
|
if item["source"] != "narratio.artifact.session_recap" || item["dest"] != "artifacts/session_recap.md" || item["required"] != true {
|
||||||
|
t.Fatalf("skipped item = %#v, want required session_recap promotion", item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,7 +342,7 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
|||||||
t.Fatalf("Load manifest error = %v", err)
|
t.Fatalf("Load manifest error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "archive", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||||
sr := m.Stages[name]
|
sr := m.Stages[name]
|
||||||
if sr == nil {
|
if sr == nil {
|
||||||
t.Fatalf("missing stage record %q", name)
|
t.Fatalf("missing stage record %q", name)
|
||||||
@@ -408,9 +425,9 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if name == "archive" {
|
if name == "publish" {
|
||||||
if sr.Metadata == nil || sr.Metadata["stage"] != "archive" {
|
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
|
||||||
t.Fatalf("archive metadata missing stage=archive: %#v", sr.Metadata)
|
t.Fatalf("archive metadata missing stage=publish: %#v", sr.Metadata)
|
||||||
}
|
}
|
||||||
if sr.Metadata["skipped"] != true {
|
if sr.Metadata["skipped"] != true {
|
||||||
t.Fatalf("archive metadata missing skipped=true for test config without archive section: %#v", sr.Metadata)
|
t.Fatalf("archive metadata missing skipped=true for test config without archive section: %#v", sr.Metadata)
|
||||||
@@ -505,7 +522,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
|
|||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
|
|
||||||
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "archive", "notify"} {
|
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "publish", "notify"} {
|
||||||
existing.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
existing.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
||||||
}
|
}
|
||||||
existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure")
|
existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure")
|
||||||
@@ -536,7 +553,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
|
|||||||
if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded {
|
if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded {
|
||||||
t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"])
|
t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"])
|
||||||
}
|
}
|
||||||
for _, stageName := range []string{"normalize", "trim", "archive", "notify"} {
|
for _, stageName := range []string{"normalize", "trim", "publish", "notify"} {
|
||||||
if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale {
|
if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale {
|
||||||
t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName])
|
t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName])
|
||||||
}
|
}
|
||||||
@@ -744,7 +761,7 @@ func TestExecuteStagesRunLocalArtifactsAndCanonicalPromotion(t *testing.T) {
|
|||||||
filepath.Join(runRoot, "merge", "logs", "seriatim.stdout.log"),
|
filepath.Join(runRoot, "merge", "logs", "seriatim.stdout.log"),
|
||||||
filepath.Join(runRoot, "polish", "config", "audita.generated.yml"),
|
filepath.Join(runRoot, "polish", "config", "audita.generated.yml"),
|
||||||
filepath.Join(runRoot, "normalize", "logs", "seriatim.normalize.stdout.log"),
|
filepath.Join(runRoot, "normalize", "logs", "seriatim.normalize.stdout.log"),
|
||||||
filepath.Join(runRoot, "trim", "outputs", "transcripts", "trimmed.json"),
|
filepath.Join(runRoot, "trim", "outputs", "transcripts", "final.trimmed.json"),
|
||||||
}
|
}
|
||||||
for _, p := range runLocalChecks {
|
for _, p := range runLocalChecks {
|
||||||
if _, statErr := os.Stat(p); statErr != nil {
|
if _, statErr := os.Stat(p); statErr != nil {
|
||||||
@@ -754,10 +771,10 @@ func TestExecuteStagesRunLocalArtifactsAndCanonicalPromotion(t *testing.T) {
|
|||||||
|
|
||||||
canonicalChecks := []string{
|
canonicalChecks := []string{
|
||||||
filepath.Join(paths.TranscriptsRawDir, "alice.json"),
|
filepath.Join(paths.TranscriptsRawDir, "alice.json"),
|
||||||
filepath.Join(paths.TranscriptsDir, "merged.json"),
|
filepath.Join(paths.TranscriptsDir, "base.json"),
|
||||||
filepath.Join(paths.TranscriptsDir, "processed.json"),
|
filepath.Join(paths.TranscriptsDir, "polished.json"),
|
||||||
filepath.Join(paths.TranscriptsDir, "normalized.json"),
|
filepath.Join(paths.TranscriptsDir, "final.json"),
|
||||||
filepath.Join(paths.TranscriptsDir, "trimmed.json"),
|
filepath.Join(paths.TranscriptsDir, "final.trimmed.json"),
|
||||||
}
|
}
|
||||||
for _, p := range canonicalChecks {
|
for _, p := range canonicalChecks {
|
||||||
if _, statErr := os.Stat(p); statErr != nil {
|
if _, statErr := os.Stat(p); statErr != nil {
|
||||||
@@ -847,7 +864,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
|
|||||||
{name: "merge", env: &Env{Seriatim: &seriatim.FakeRunner{Err: errors.New("merge fail")}}},
|
{name: "merge", env: &Env{Seriatim: &seriatim.FakeRunner{Err: errors.New("merge fail")}}},
|
||||||
{name: "polish", env: &Env{Audita: &audita.FakeRunner{Err: errors.New("polish fail")}}},
|
{name: "polish", env: &Env{Audita: &audita.FakeRunner{Err: errors.New("polish fail")}}},
|
||||||
{name: "analyze", env: &Env{Scriptorium: &scriptorium.FakeRunner{RunErr: errors.New("analyze fail")}}},
|
{name: "analyze", env: &Env{Scriptorium: &scriptorium.FakeRunner{RunErr: errors.New("analyze fail")}}},
|
||||||
{name: "archive", env: &Env{ObjectStore: &storage.FakeBackend{UploadErr: errors.New("archive fail")}}},
|
{name: "publish", env: &Env{ObjectStore: &storage.FakeBackend{UploadErr: errors.New("archive fail")}}},
|
||||||
{name: "notify", env: &Env{Notifier: ¬ify.FakeSender{Err: errors.New("notify fail")}}},
|
{name: "notify", env: &Env{Notifier: ¬ify.FakeSender{Err: errors.New("notify fail")}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -902,7 +919,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
|
|||||||
if ensureErr != nil {
|
if ensureErr != nil {
|
||||||
t.Fatalf("EnsureLayout() error = %v", ensureErr)
|
t.Fatalf("EnsureLayout() error = %v", ensureErr)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "merged.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "base.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
|
||||||
t.Fatalf("write merged transcript: %v", err)
|
t.Fatalf("write merged transcript: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(paths.InputsDir, "glossary.yml"), []byte("terms: []\n"), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(paths.InputsDir, "glossary.yml"), []byte("terms: []\n"), 0o644); err != nil {
|
||||||
@@ -914,7 +931,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
|
|||||||
if ensureErr != nil {
|
if ensureErr != nil {
|
||||||
t.Fatalf("EnsureLayout() error = %v", ensureErr)
|
t.Fatalf("EnsureLayout() error = %v", ensureErr)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "processed.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(paths.TranscriptsDir, "polished.json"), []byte(`{"segments":[]}`), 0o644); err != nil {
|
||||||
t.Fatalf("write processed transcript: %v", err)
|
t.Fatalf("write processed transcript: %v", err)
|
||||||
}
|
}
|
||||||
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
|
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
|
||||||
@@ -932,8 +949,8 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tc.name == "archive" {
|
if tc.name == "publish" {
|
||||||
cfg.Pipeline.Archive = &config.ArchiveConfig{
|
cfg.Pipeline.Publish = &config.PublishConfig{
|
||||||
Enabled: boolPtr(true),
|
Enabled: boolPtr(true),
|
||||||
UploadRun: boolPtr(true),
|
UploadRun: boolPtr(true),
|
||||||
}
|
}
|
||||||
@@ -998,7 +1015,7 @@ func testConfig(t *testing.T) *config.Config {
|
|||||||
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
|
||||||
|
|
||||||
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
|
||||||
mustWriteFile(t, campaignPath, "campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
|
||||||
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
|
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
|
||||||
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
|
||||||
@@ -1007,7 +1024,7 @@ func testConfig(t *testing.T) *config.Config {
|
|||||||
|
|
||||||
return &config.Config{
|
return &config.Config{
|
||||||
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
|
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
|
||||||
Campaign: &config.CampaignConfig{Campaign: "sample-campaign"},
|
Campaign: &config.CampaignConfig{CampaignID: "sample-campaign"},
|
||||||
PipelinePath: pipelinePath,
|
PipelinePath: pipelinePath,
|
||||||
CampaignPath: campaignPath,
|
CampaignPath: campaignPath,
|
||||||
SessionPath: sessionPath,
|
SessionPath: sessionPath,
|
||||||
@@ -1050,12 +1067,10 @@ func TestBuildDefaultRunnersWithOmittedToolSections(t *testing.T) {
|
|||||||
root: ` + t.TempDir() + `
|
root: ` + t.TempDir() + `
|
||||||
whisperx:
|
whisperx:
|
||||||
transcribe_url: https://example.com/transcribe
|
transcribe_url: https://example.com/transcribe
|
||||||
analyzer:
|
|
||||||
timeout: 20m
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
`
|
`
|
||||||
campaignYAML := `campaign: sample-campaign
|
campaignYAML := `campaign_id: sample-campaign
|
||||||
inputs:
|
inputs:
|
||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
|||||||
43
internal/app/session_args.go
Normal file
43
internal/app/session_args.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isCLIFlagToken(arg string) bool {
|
||||||
|
return strings.HasPrefix(arg, "-") && arg != "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
func pullLeadingSessionID(args []string) (string, []string) {
|
||||||
|
if len(args) == 0 || isCLIFlagToken(args[0]) {
|
||||||
|
return "", args
|
||||||
|
}
|
||||||
|
rest := append([]string(nil), args[1:]...)
|
||||||
|
return strings.TrimSpace(args[0]), rest
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyPositionalSessionID(command, positional string, sessionID *string) error {
|
||||||
|
positional = strings.TrimSpace(positional)
|
||||||
|
if positional == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
existing := strings.TrimSpace(*sessionID)
|
||||||
|
if existing != "" && existing != positional {
|
||||||
|
return fmt.Errorf("%s: positional session id %q does not match expected session id %q", command, positional, existing)
|
||||||
|
}
|
||||||
|
*sessionID = positional
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyParsedSessionIDArg(command string, fs *flag.FlagSet, sessionID *string) error {
|
||||||
|
switch fs.NArg() {
|
||||||
|
case 0:
|
||||||
|
return nil
|
||||||
|
case 1:
|
||||||
|
return applyPositionalSessionID(command, fs.Arg(0), sessionID)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s: unexpected positional arguments", command)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPlanUsesDiscoveredSessionTemplateWithSessionIDs(t *testing.T) {
|
func TestPlanRejectsDiscoveredSessionTemplate(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
@@ -32,16 +32,20 @@ inputs:
|
|||||||
t.Cleanup(func() { config.DefaultSessionConfigSearchPaths = origSessionDefaults })
|
t.Cleanup(func() { config.DefaultSessionConfigSearchPaths = origSessionDefaults })
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
if err := Plan(context.Background(), []string{
|
err := Plan(context.Background(), []string{
|
||||||
|
"2026-04-04",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session-id", "2026-04-04",
|
|
||||||
"--previous-session-id", "2026-03-28",
|
"--previous-session-id", "2026-03-28",
|
||||||
}, &out); err != nil {
|
}, &out)
|
||||||
t.Fatalf("Plan() error = %v", err)
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "narratio plan: workdir prepared") {
|
if !strings.Contains(err.Error(), "session.yml must be concrete") {
|
||||||
t.Fatalf("output = %q, want plan output", out.String())
|
t.Fatalf("error = %q, want concrete session guidance", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "run narratio session init") {
|
||||||
|
t.Fatalf("error = %q, want session init guidance", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +54,7 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
|
|||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Plan(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-04-04"}, &out)
|
err := Plan(context.Background(), []string{"2026-04-04", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
@@ -78,10 +82,10 @@ inputs:
|
|||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Plan(context.Background(), []string{
|
err := Plan(context.Background(), []string{
|
||||||
|
"2026-05-03",
|
||||||
"--config", pipelinePath,
|
"--config", pipelinePath,
|
||||||
"--campaign", campaignPath,
|
"--campaign-file", campaignPath,
|
||||||
"--session", sessionPath,
|
"--session", sessionPath,
|
||||||
"--session-id", "2026-05-03",
|
|
||||||
"--previous-session-id", "2026-04-25",
|
"--previous-session-id", "2026-04-25",
|
||||||
}, &out)
|
}, &out)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -92,12 +96,12 @@ inputs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) {
|
func TestRunStageAcceptsPositionalSessionIDAndParsesStageName(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--campaign", campaignPath, "--session", sessionPath, "--session-id", "2026-05-03", "prepare"}, &out)
|
err := RunStage(context.Background(), []string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage() error = %v", err)
|
t.Fatalf("RunStage() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
353
internal/app/session_oriented_cli_test.go
Normal file
353
internal/app/session_oriented_cli_test.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
|
var capturedSessionID string
|
||||||
|
origExecuteStagesFn := executeStagesFn
|
||||||
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||||
|
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
|
||||||
|
capturedSessionID = cfg.Session.SessionID
|
||||||
|
return &RunSummary{
|
||||||
|
SessionID: cfg.Session.SessionID,
|
||||||
|
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
|
||||||
|
Executed: []string{"prepare"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"run",
|
||||||
|
"2026-05-03",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03") {
|
||||||
|
t.Fatalf("stdout = %q, want run summary", stdout.String())
|
||||||
|
}
|
||||||
|
if capturedSessionID != "2026-05-03" {
|
||||||
|
t.Fatalf("captured session = %q, want positional session id", capturedSessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutePositionalSessionIDMismatchFails(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"run",
|
||||||
|
"2026-05-04",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "session_id mismatch") {
|
||||||
|
t.Fatalf("stderr = %q, want session mismatch", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionIDFlagFails(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"session", "status", "2026-05-03", "--session-id", "2026-05-04"}, &stdout, &stderr)
|
||||||
|
if code == 0 {
|
||||||
|
t.Fatal("exit code = 0, want non-zero")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "flag provided but not defined: -session-id") {
|
||||||
|
t.Fatalf("stderr = %q, want invalid --session-id flag", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRemoteSessionFallbackUsesPositionalSessionID(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
var storeInitCalls int
|
||||||
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
||||||
|
remoteKey := seedRemoteSessionConfig(t, fake, "2026-06-07", `session_id: 2026-06-07
|
||||||
|
inputs:
|
||||||
|
audio_s3:
|
||||||
|
prefix: audio/
|
||||||
|
`)
|
||||||
|
origExecuteStagesFn := executeStagesFn
|
||||||
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||||
|
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
|
||||||
|
return &RunSummary{
|
||||||
|
SessionID: cfg.Session.SessionID,
|
||||||
|
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
|
||||||
|
Executed: []string{"prepare"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"run",
|
||||||
|
"2026-06-07",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
downloaded := false
|
||||||
|
for _, call := range fake.Downloads {
|
||||||
|
if call.Key == remoteKey {
|
||||||
|
downloaded = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !downloaded {
|
||||||
|
t.Fatalf("remote session %q was not downloaded; downloads=%v", remoteKey, fake.Downloads)
|
||||||
|
}
|
||||||
|
if storeInitCalls == 0 {
|
||||||
|
t.Fatal("object store was not initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantStage string
|
||||||
|
wantForce bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "resume",
|
||||||
|
args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
|
wantStage: "prepare",
|
||||||
|
wantForce: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "analyze",
|
||||||
|
args: []string{"analyze", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||||
|
wantStage: "analyze",
|
||||||
|
wantForce: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "publish",
|
||||||
|
args: []string{"publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||||
|
wantStage: "publish",
|
||||||
|
wantForce: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "run-stage",
|
||||||
|
args: []string{"run-stage", "publish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||||
|
wantStage: "publish",
|
||||||
|
wantForce: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var capturedStages []string
|
||||||
|
var capturedForce bool
|
||||||
|
var capturedArtifacts []string
|
||||||
|
origExecuteStagesFn := executeStagesFn
|
||||||
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||||
|
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||||
|
for _, s := range stages {
|
||||||
|
capturedStages = append(capturedStages, s.Name())
|
||||||
|
}
|
||||||
|
capturedForce = opts.Force
|
||||||
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
|
return &RunSummary{
|
||||||
|
SessionID: "2026-05-03",
|
||||||
|
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
|
||||||
|
Executed: []string{tt.wantStage},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute(tt.args, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if len(capturedStages) == 0 || capturedStages[0] != tt.wantStage {
|
||||||
|
t.Fatalf("captured stages = %#v, want first %q", capturedStages, tt.wantStage)
|
||||||
|
}
|
||||||
|
if capturedForce != tt.wantForce {
|
||||||
|
t.Fatalf("captured force = %t, want %t", capturedForce, tt.wantForce)
|
||||||
|
}
|
||||||
|
if tt.name == "analyze" || tt.name == "publish" || tt.name == "run-stage" {
|
||||||
|
if strings.Join(capturedArtifacts, ",") != "session_recap" {
|
||||||
|
t.Fatalf("captured artifacts = %#v, want [session_recap]", capturedArtifacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
sessionPrefix := artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
||||||
|
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
|
||||||
|
fake.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||||
|
fake.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "2026-05-03", "sample-campaign")})
|
||||||
|
var storeInitCalls int
|
||||||
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "validate",
|
||||||
|
args: []string{"session", "validate", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
|
want: "OK config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status",
|
||||||
|
args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
|
want: "Session: 2026-05-03",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plan",
|
||||||
|
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
|
want: "narratio session plan: workdir prepared",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "artifacts",
|
||||||
|
args: []string{"session", "artifacts", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
|
want: "Built-in:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "locks",
|
||||||
|
args: []string{"session", "locks", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
|
want: "Publish locks:",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute(tt.args, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), tt.want) {
|
||||||
|
t.Fatalf("stdout = %q, want %q", stdout.String(), tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionInitAcceptsPositionalSessionID(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
outputPath := filepath.Join(t.TempDir(), "session.yml")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "init", "2026-06-07",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--output", outputPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read generated session: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `session_id: "2026-06-07"`) {
|
||||||
|
t.Fatalf("generated session = %q, want positional session id", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteSessionLocksMutationAcceptsPositionalSessionID(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
fake := &storage.FakeBackend{}
|
||||||
|
var storeInitCalls int
|
||||||
|
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{
|
||||||
|
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath,
|
||||||
|
"--reason", "review",
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("locks add exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
key := artifacts.S3SessionLocksKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-05-03"))
|
||||||
|
if !strings.Contains(string(fake.Objects[key].Data), "reason: review") {
|
||||||
|
t.Fatalf("lock store data = %q, want reason", string(fake.Objects[key].Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout.Reset()
|
||||||
|
stderr.Reset()
|
||||||
|
code = Execute([]string{
|
||||||
|
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath,
|
||||||
|
}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("locks remove exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
store, err := config.LoadPublishLockStoreBytes("locks.yml", fake.Objects[key].Data, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadPublishLockStoreBytes() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(store.Locks) != 0 {
|
||||||
|
t.Fatalf("locks after remove = %#v, want empty", store.Locks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteCleanAcceptsPositionalSessionID(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
workDir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
||||||
|
spoolDir := artifacts.SessionSpoolDir(filepath.Join(workspaceRoot, "spool"), "sample-campaign", "2026-05-03")
|
||||||
|
mustWriteTestFile(t, filepath.Join(workDir, "manifest.json"), "{}")
|
||||||
|
mustWriteTestFile(t, filepath.Join(spoolDir, "run-1", "audio", "alice.flac"), "audio")
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"clean", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
cleanAssertMissing(t, workDir)
|
||||||
|
cleanAssertMissing(t, spoolDir)
|
||||||
|
}
|
||||||
@@ -3,71 +3,28 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestStatusCommandReadsManifest(t *testing.T) {
|
func TestStatusCommandRequiresSessionID(t *testing.T) {
|
||||||
manifestPath := writeManifestForStatus(t)
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
err := Status(context.Background(), []string{"--manifest", manifestPath}, &out)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Status() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s := out.String()
|
|
||||||
if !strings.Contains(s, "session_id: 2026-05-03") {
|
|
||||||
t.Fatalf("output = %q, want session_id", s)
|
|
||||||
}
|
|
||||||
if !strings.Contains(s, "- merge: succeeded") {
|
|
||||||
t.Fatalf("output = %q, want stage status", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatusCommandMissingManifestFlag(t *testing.T) {
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Status(context.Background(), nil, &out)
|
err := Status(context.Background(), nil, &out)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "--manifest is required") {
|
if !strings.Contains(err.Error(), "status: session_id is required") {
|
||||||
t.Fatalf("error = %q, want missing manifest flag", err.Error())
|
t.Fatalf("error = %q, want missing session_id error", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusCommandBadManifest(t *testing.T) {
|
func TestStatusCommandRejectsManifestFlag(t *testing.T) {
|
||||||
dir := t.TempDir()
|
|
||||||
path := filepath.Join(dir, "manifest.json")
|
|
||||||
if err := os.WriteFile(path, []byte("{not-json"), 0o644); err != nil {
|
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Status(context.Background(), []string{"--manifest", path}, &out)
|
err := Status(context.Background(), []string{"2026-05-03", "--manifest", "manifest.json"}, &out)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "decode manifest") {
|
if !strings.Contains(err.Error(), "status: invalid flags: flag provided but not defined: -manifest") {
|
||||||
t.Fatalf("error = %q, want decode error", err.Error())
|
t.Fatalf("error = %q, want invalid manifest flag", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeManifestForStatus(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
|
||||||
m.MarkStageSucceeded("merge", time.Date(2026, 5, 3, 10, 5, 0, 0, time.UTC), nil)
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
|
||||||
if err := store.Save(context.Background(), path, m); err != nil {
|
|
||||||
t.Fatalf("Save() error = %v", err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|||||||
84
internal/artifactmodel/transcripts.go
Normal file
84
internal/artifactmodel/transcripts.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package artifactmodel
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceTranscriptBase = "narratio.transcript.base"
|
||||||
|
SourceTranscriptPolished = "narratio.transcript.polished"
|
||||||
|
SourceTranscriptFinal = "narratio.transcript.final"
|
||||||
|
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TranscriptPathBase = "transcripts/base.json"
|
||||||
|
TranscriptPathPolished = "transcripts/polished.json"
|
||||||
|
TranscriptPathFinal = "transcripts/final.json"
|
||||||
|
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TranscriptOutputKindBase = "transcript_base"
|
||||||
|
TranscriptOutputKindPolished = "transcript_polished"
|
||||||
|
TranscriptOutputKindFinal = "transcript_final"
|
||||||
|
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TranscriptArtifactSpec describes one built-in transcript artifact mapping.
|
||||||
|
type TranscriptArtifactSpec struct {
|
||||||
|
SourceID string
|
||||||
|
CanonicalRelPath string
|
||||||
|
ProducerStage string
|
||||||
|
OutputKind string
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtimeTranscriptArtifacts = []TranscriptArtifactSpec{
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptBase,
|
||||||
|
CanonicalRelPath: TranscriptPathBase,
|
||||||
|
ProducerStage: "merge",
|
||||||
|
OutputKind: TranscriptOutputKindBase,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptPolished,
|
||||||
|
CanonicalRelPath: TranscriptPathPolished,
|
||||||
|
ProducerStage: "polish",
|
||||||
|
OutputKind: TranscriptOutputKindPolished,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptFinal,
|
||||||
|
CanonicalRelPath: TranscriptPathFinal,
|
||||||
|
ProducerStage: "normalize",
|
||||||
|
OutputKind: TranscriptOutputKindFinal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: SourceTranscriptFinalTrimmed,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalTrimmed,
|
||||||
|
ProducerStage: "trim",
|
||||||
|
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuntimeTranscriptArtifacts returns transcript mappings in pipeline order.
|
||||||
|
func RuntimeTranscriptArtifacts() []TranscriptArtifactSpec {
|
||||||
|
return cloneTranscriptSpecs(runtimeTranscriptArtifacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupRuntimeTranscriptArtifact returns runtime transcript metadata by source ID.
|
||||||
|
func LookupRuntimeTranscriptArtifact(sourceID string) (TranscriptArtifactSpec, bool) {
|
||||||
|
trimmed := strings.TrimSpace(sourceID)
|
||||||
|
for _, spec := range runtimeTranscriptArtifacts {
|
||||||
|
if spec.SourceID == trimmed {
|
||||||
|
return spec, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TranscriptArtifactSpec{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneTranscriptSpecs(specs []TranscriptArtifactSpec) []TranscriptArtifactSpec {
|
||||||
|
if len(specs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]TranscriptArtifactSpec, len(specs))
|
||||||
|
copy(out, specs)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -9,23 +9,38 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ArtifactTranscriptMerged = "narratio.transcript.merged"
|
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
|
||||||
ArtifactTranscriptPolished = "narratio.transcript.polished"
|
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
|
||||||
ArtifactTranscriptFull = "narratio.transcript.full"
|
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
|
||||||
ArtifactTranscriptTrimmed = "narratio.transcript.trimmed"
|
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
|
||||||
ArtifactBoundsSession = "narratio.bounds.session"
|
ArtifactBoundsSession = "narratio.bounds.session"
|
||||||
|
|
||||||
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
|
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
|
||||||
ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache"
|
ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TranscriptPathBase = artifactmodel.TranscriptPathBase
|
||||||
|
TranscriptPathPolished = artifactmodel.TranscriptPathPolished
|
||||||
|
TranscriptPathFinal = artifactmodel.TranscriptPathFinal
|
||||||
|
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase
|
||||||
|
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
|
||||||
|
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
|
||||||
|
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
|
||||||
|
)
|
||||||
|
|
||||||
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
||||||
var ErrSessionArtifactNotFound = errors.New("session artifact not found")
|
var ErrSessionArtifactNotFound = errors.New("session artifact not found")
|
||||||
var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.[a-z][a-z0-9_]*$`)
|
var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||||
var previousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
|
var previousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||||
|
|
||||||
type artifactContentKind string
|
type artifactContentKind string
|
||||||
@@ -44,42 +59,27 @@ type artifactSpec struct {
|
|||||||
ContentKind artifactContentKind
|
ContentKind artifactContentKind
|
||||||
}
|
}
|
||||||
|
|
||||||
var artifactRegistry = map[string]artifactSpec{
|
var artifactRegistry = buildArtifactRegistry()
|
||||||
ArtifactTranscriptMerged: {
|
|
||||||
ID: ArtifactTranscriptMerged,
|
func buildArtifactRegistry() map[string]artifactSpec {
|
||||||
CanonicalRelPath: "transcripts/merged.json",
|
registry := map[string]artifactSpec{}
|
||||||
ProducerStage: "merge",
|
for _, transcript := range RuntimeTranscriptArtifacts() {
|
||||||
OutputKind: "transcript_merged",
|
registry[transcript.SourceID] = artifactSpec{
|
||||||
ContentKind: contentTranscriptJSON,
|
ID: transcript.SourceID,
|
||||||
},
|
CanonicalRelPath: transcript.CanonicalRelPath,
|
||||||
ArtifactTranscriptPolished: {
|
ProducerStage: transcript.ProducerStage,
|
||||||
ID: ArtifactTranscriptPolished,
|
OutputKind: transcript.OutputKind,
|
||||||
CanonicalRelPath: "transcripts/processed.json",
|
ContentKind: contentTranscriptJSON,
|
||||||
ProducerStage: "polish",
|
}
|
||||||
OutputKind: "transcript_processed",
|
}
|
||||||
ContentKind: contentTranscriptJSON,
|
registry[ArtifactBoundsSession] = artifactSpec{
|
||||||
},
|
|
||||||
ArtifactTranscriptFull: {
|
|
||||||
ID: ArtifactTranscriptFull,
|
|
||||||
CanonicalRelPath: "transcripts/normalized.json",
|
|
||||||
ProducerStage: "normalize",
|
|
||||||
OutputKind: "transcript_normalized",
|
|
||||||
ContentKind: contentTranscriptJSON,
|
|
||||||
},
|
|
||||||
ArtifactTranscriptTrimmed: {
|
|
||||||
ID: ArtifactTranscriptTrimmed,
|
|
||||||
CanonicalRelPath: "transcripts/trimmed.json",
|
|
||||||
ProducerStage: "trim",
|
|
||||||
OutputKind: "transcript_trimmed",
|
|
||||||
ContentKind: contentTranscriptJSON,
|
|
||||||
},
|
|
||||||
ArtifactBoundsSession: {
|
|
||||||
ID: ArtifactBoundsSession,
|
ID: ArtifactBoundsSession,
|
||||||
CanonicalRelPath: "artifacts/session_bounds.json",
|
CanonicalRelPath: "artifacts/session_bounds.json",
|
||||||
ProducerStage: "trim",
|
ProducerStage: "trim",
|
||||||
OutputKind: "session_bounds",
|
OutputKind: "session_bounds",
|
||||||
ContentKind: contentJSON,
|
ContentKind: contentJSON,
|
||||||
},
|
}
|
||||||
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvedSessionArtifact describes one session-level artifact lookup result.
|
// ResolvedSessionArtifact describes one session-level artifact lookup result.
|
||||||
@@ -122,6 +122,15 @@ func IsConfiguredArtifactSource(source string) bool {
|
|||||||
return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source))
|
return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfiguredArtifactName extracts <name> from narratio.artifact.<name>.
|
||||||
|
func ConfiguredArtifactName(source string) (string, bool) {
|
||||||
|
matches := configuredArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source))
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return matches[1], true
|
||||||
|
}
|
||||||
|
|
||||||
// IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.<name>.
|
// IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.<name>.
|
||||||
func IsPreviousSessionArtifactSource(source string) bool {
|
func IsPreviousSessionArtifactSource(source string) bool {
|
||||||
_, ok := PreviousSessionArtifactName(source)
|
_, ok := PreviousSessionArtifactName(source)
|
||||||
@@ -261,7 +270,7 @@ func ResolvePreviousSessionArtifactWithCatalog(
|
|||||||
ID: source,
|
ID: source,
|
||||||
Path: candidate,
|
Path: candidate,
|
||||||
ProducerStage: "prepare",
|
ProducerStage: "prepare",
|
||||||
OutputKind: "previous_session_artifact",
|
OutputKind: "previous_session_cache",
|
||||||
Provenance: ArtifactProvenancePreviousCacheManifestInput,
|
Provenance: ArtifactProvenancePreviousCacheManifestInput,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -275,7 +284,7 @@ func ResolvePreviousSessionArtifactWithCatalog(
|
|||||||
ID: source,
|
ID: source,
|
||||||
Path: fallback,
|
Path: fallback,
|
||||||
ProducerStage: "prepare",
|
ProducerStage: "prepare",
|
||||||
OutputKind: "previous_session_artifact",
|
OutputKind: "previous_session_cache",
|
||||||
Provenance: ArtifactProvenancePreviousCacheFilesystem,
|
Provenance: ArtifactProvenancePreviousCacheFilesystem,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestNormalizeSessionArtifactSource(t *testing.T) {
|
|||||||
{name: "legacy alias normalized unsupported", source: "normalized_transcript", wantErr: "unsupported artifact source"},
|
{name: "legacy alias normalized unsupported", source: "normalized_transcript", wantErr: "unsupported artifact source"},
|
||||||
{name: "legacy alias trimmed unsupported", source: "trimmed_transcript", wantErr: "unsupported artifact source"},
|
{name: "legacy alias trimmed unsupported", source: "trimmed_transcript", wantErr: "unsupported artifact source"},
|
||||||
{name: "configured source unsupported in built-in normalization", source: "narratio.artifact.session_recap", wantErr: "unsupported artifact source"},
|
{name: "configured source unsupported in built-in normalization", source: "narratio.artifact.session_recap", wantErr: "unsupported artifact source"},
|
||||||
{name: "canonical", source: ArtifactTranscriptTrimmed, wantID: ArtifactTranscriptTrimmed},
|
{name: "canonical", source: ArtifactTranscriptFinalTrimmed, wantID: ArtifactTranscriptFinalTrimmed},
|
||||||
{name: "unsupported", source: "narratio.unknown", wantErr: "unsupported artifact source"},
|
{name: "unsupported", source: "narratio.unknown", wantErr: "unsupported artifact source"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +46,58 @@ func TestNormalizeSessionArtifactSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfiguredArtifactSourceHelpers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
wantName string
|
||||||
|
wantMatch bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
source: "narratio.artifact.session_recap",
|
||||||
|
wantName: "session_recap",
|
||||||
|
wantMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid with surrounding whitespace",
|
||||||
|
source: " narratio.artifact.player_handout ",
|
||||||
|
wantName: "player_handout",
|
||||||
|
wantMatch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing name",
|
||||||
|
source: "narratio.artifact.",
|
||||||
|
wantMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid hyphen",
|
||||||
|
source: "narratio.artifact.session-recap",
|
||||||
|
wantMatch: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "built-in",
|
||||||
|
source: ArtifactTranscriptBase,
|
||||||
|
wantMatch: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := IsConfiguredArtifactSource(tt.source); got != tt.wantMatch {
|
||||||
|
t.Fatalf("IsConfiguredArtifactSource(%q) = %t, want %t", tt.source, got, tt.wantMatch)
|
||||||
|
}
|
||||||
|
gotName, gotOK := ConfiguredArtifactName(tt.source)
|
||||||
|
if gotOK != tt.wantMatch {
|
||||||
|
t.Fatalf("ConfiguredArtifactName(%q) ok = %t, want %t", tt.source, gotOK, tt.wantMatch)
|
||||||
|
}
|
||||||
|
if gotName != tt.wantName {
|
||||||
|
t.Fatalf("ConfiguredArtifactName(%q) name = %q, want %q", tt.source, gotName, tt.wantName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPreviousSessionArtifactSourceHelpers(t *testing.T) {
|
func TestPreviousSessionArtifactSourceHelpers(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -108,7 +160,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
|
|||||||
if err := os.WriteFile(manifestPath, []byte(`{"segments":[]}`), 0o644); err != nil {
|
if err := os.WriteFile(manifestPath, []byte(`{"segments":[]}`), 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
}
|
}
|
||||||
canonicalPath := filepath.Join(paths.TranscriptsDir, "normalized.json")
|
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.json")
|
||||||
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -118,10 +170,10 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
|
|||||||
|
|
||||||
m := manifest.New("session", time.Now().UTC())
|
m := manifest.New("session", time.Now().UTC())
|
||||||
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{
|
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{
|
||||||
{Kind: "transcript_normalized", LocalPath: manifestPath, ProducerRunID: "run-123"},
|
{Kind: "transcript_final", LocalPath: manifestPath, ProducerRunID: "run-123"},
|
||||||
})
|
})
|
||||||
|
|
||||||
resolved, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFull)
|
resolved, err := ResolveSessionArtifact(paths, m, ArtifactTranscriptFinal)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveSessionArtifact() error = %v", err)
|
t.Fatalf("ResolveSessionArtifact() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -139,7 +191,7 @@ func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
|
|||||||
func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
|
func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
canonicalPath := filepath.Join(paths.TranscriptsDir, "trimmed.json")
|
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
|
||||||
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -147,7 +199,7 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
|
|||||||
t.Fatalf("WriteFile() error = %v", err)
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptTrimmed)
|
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveSessionArtifact() error = %v", err)
|
t.Fatalf("ResolveSessionArtifact() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -163,7 +215,7 @@ func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) {
|
|||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
|
|
||||||
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptTrimmed)
|
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmed)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
@@ -175,7 +227,7 @@ func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) {
|
|||||||
func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
|
func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
canonicalPath := filepath.Join(paths.TranscriptsDir, "processed.json")
|
canonicalPath := filepath.Join(paths.TranscriptsDir, "polished.json")
|
||||||
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -195,7 +247,7 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
|
|||||||
func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) {
|
func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
canonicalPath := filepath.Join(paths.TranscriptsDir, "trimmed.json")
|
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.json")
|
||||||
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,7 +255,7 @@ func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T)
|
|||||||
t.Fatalf("WriteFile() error = %v", err)
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, ArtifactTranscriptTrimmed, NewArtifactCatalog())
|
resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, ArtifactTranscriptFinalTrimmed, NewArtifactCatalog())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,10 +215,10 @@ func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
|||||||
|
|
||||||
func runtimeBuiltInArtifactIDs() []string {
|
func runtimeBuiltInArtifactIDs() []string {
|
||||||
return []string{
|
return []string{
|
||||||
ArtifactTranscriptMerged,
|
ArtifactTranscriptBase,
|
||||||
ArtifactTranscriptPolished,
|
ArtifactTranscriptPolished,
|
||||||
ArtifactTranscriptFull,
|
ArtifactTranscriptFinal,
|
||||||
ArtifactTranscriptTrimmed,
|
ArtifactTranscriptFinalTrimmed,
|
||||||
ArtifactBoundsSession,
|
ArtifactBoundsSession,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
|
|||||||
t.Fatalf("RegisterBuiltIns() error = %v", err)
|
t.Fatalf("RegisterBuiltIns() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, ok := catalog.Lookup(ArtifactTranscriptFull)
|
entry, ok := catalog.Lookup(ArtifactTranscriptFinal)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("Lookup(%q) ok = false, want true", ArtifactTranscriptFull)
|
t.Fatalf("Lookup(%q) ok = false, want true", ArtifactTranscriptFinal)
|
||||||
}
|
}
|
||||||
if !entry.Planned {
|
if !entry.Planned {
|
||||||
t.Fatalf("entry.Planned = false, want true")
|
t.Fatalf("entry.Planned = false, want true")
|
||||||
@@ -18,8 +18,8 @@ func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
|
|||||||
if entry.Executable {
|
if entry.Executable {
|
||||||
t.Fatalf("entry.Executable = true, want false")
|
t.Fatalf("entry.Executable = true, want false")
|
||||||
}
|
}
|
||||||
if entry.CanonicalRelPath != "transcripts/normalized.json" {
|
if entry.CanonicalRelPath != "transcripts/final.json" {
|
||||||
t.Fatalf("entry.CanonicalRelPath = %q, want transcripts/normalized.json", entry.CanonicalRelPath)
|
t.Fatalf("entry.CanonicalRelPath = %q, want transcripts/final.json", entry.CanonicalRelPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestCollectPreviousArtifactRequirements(t *testing.T) {
|
|||||||
"session_recap": {
|
"session_recap": {
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Inputs: map[string]config.ScriptoriumInputConfig{
|
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||||
"transcript": {Source: "narratio.transcript.trimmed", Required: true},
|
"transcript": {Source: "narratio.transcript.final_trimmed", Required: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ func S3CurrentRunPointerKey(sessionPrefix string) string {
|
|||||||
return path.Join(strings.TrimSuffix(cleanS3Key(sessionPrefix), "/"), config.S3CurrentSegment, config.S3RunIDFile)
|
return path.Join(strings.TrimSuffix(cleanS3Key(sessionPrefix), "/"), config.S3CurrentSegment, config.S3RunIDFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// S3PromotedArtifactKey returns the destination key for one promoted artifact.
|
// S3PublishedOutputKey returns the destination key for one published output.
|
||||||
// Format: {session_prefix}/{promotion.to}
|
// Format: {session_prefix}/{output.dest}
|
||||||
func S3PromotedArtifactKey(sessionPrefix, to string) string {
|
func S3PublishedOutputKey(sessionPrefix, to string) string {
|
||||||
return path.Join(strings.TrimSuffix(cleanS3Key(sessionPrefix), "/"), cleanS3Key(to))
|
return path.Join(strings.TrimSuffix(cleanS3Key(sessionPrefix), "/"), cleanS3Key(to))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ func TestS3KeyConstruction(t *testing.T) {
|
|||||||
t.Fatalf("manifest key = %q", manifestKey)
|
t.Fatalf("manifest key = %q", manifestKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
promoted := S3PromotedArtifactKey(sessionPrefix, "transcripts/trimmed.json")
|
promoted := S3PublishedOutputKey(sessionPrefix, "transcripts/final.trimmed.json")
|
||||||
if promoted != "dnd/campaigns/forsaken/sessions/2026-04-19/transcripts/trimmed.json" {
|
if promoted != "dnd/campaigns/forsaken/sessions/2026-04-19/transcripts/final.trimmed.json" {
|
||||||
t.Fatalf("promoted key = %q", promoted)
|
t.Fatalf("promoted key = %q", promoted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
internal/artifacts/transcripts.go
Normal file
25
internal/artifacts/transcripts.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
|
||||||
|
type TranscriptArtifactSpec = artifactmodel.TranscriptArtifactSpec
|
||||||
|
|
||||||
|
// RuntimeTranscriptArtifacts returns the current runtime transcript mappings in pipeline order.
|
||||||
|
func RuntimeTranscriptArtifacts() []TranscriptArtifactSpec {
|
||||||
|
return artifactmodel.RuntimeTranscriptArtifacts()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlannedTranscriptArtifacts returns the target transcript mappings for the transcript naming roadmap.
|
||||||
|
func PlannedTranscriptArtifacts() []TranscriptArtifactSpec {
|
||||||
|
return artifactmodel.RuntimeTranscriptArtifacts()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupRuntimeTranscriptArtifact returns current runtime transcript metadata by source ID.
|
||||||
|
func LookupRuntimeTranscriptArtifact(sourceID string) (TranscriptArtifactSpec, bool) {
|
||||||
|
return artifactmodel.LookupRuntimeTranscriptArtifact(sourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupPlannedTranscriptArtifact returns target transcript metadata by source ID.
|
||||||
|
func LookupPlannedTranscriptArtifact(sourceID string) (TranscriptArtifactSpec, bool) {
|
||||||
|
return artifactmodel.LookupRuntimeTranscriptArtifact(sourceID)
|
||||||
|
}
|
||||||
123
internal/artifacts/transcripts_test.go
Normal file
123
internal/artifacts/transcripts_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuntimeTranscriptArtifacts(t *testing.T) {
|
||||||
|
want := []TranscriptArtifactSpec{
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptBase,
|
||||||
|
CanonicalRelPath: TranscriptPathBase,
|
||||||
|
ProducerStage: "merge",
|
||||||
|
OutputKind: TranscriptOutputKindBase,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptPolished,
|
||||||
|
CanonicalRelPath: TranscriptPathPolished,
|
||||||
|
ProducerStage: "polish",
|
||||||
|
OutputKind: TranscriptOutputKindPolished,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptFinal,
|
||||||
|
CanonicalRelPath: TranscriptPathFinal,
|
||||||
|
ProducerStage: "normalize",
|
||||||
|
OutputKind: TranscriptOutputKindFinal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptFinalTrimmed,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalTrimmed,
|
||||||
|
ProducerStage: "trim",
|
||||||
|
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := RuntimeTranscriptArtifacts()
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("RuntimeTranscriptArtifacts() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, spec := range want {
|
||||||
|
gotSpec, ok := LookupRuntimeTranscriptArtifact(spec.SourceID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("LookupRuntimeTranscriptArtifact(%q) ok = false, want true", spec.SourceID)
|
||||||
|
}
|
||||||
|
if gotSpec != spec {
|
||||||
|
t.Fatalf("LookupRuntimeTranscriptArtifact(%q) = %#v, want %#v", spec.SourceID, gotSpec, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlannedTranscriptArtifacts(t *testing.T) {
|
||||||
|
want := []TranscriptArtifactSpec{
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptBase,
|
||||||
|
CanonicalRelPath: TranscriptPathBase,
|
||||||
|
ProducerStage: "merge",
|
||||||
|
OutputKind: TranscriptOutputKindBase,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptPolished,
|
||||||
|
CanonicalRelPath: TranscriptPathPolished,
|
||||||
|
ProducerStage: "polish",
|
||||||
|
OutputKind: TranscriptOutputKindPolished,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptFinal,
|
||||||
|
CanonicalRelPath: TranscriptPathFinal,
|
||||||
|
ProducerStage: "normalize",
|
||||||
|
OutputKind: TranscriptOutputKindFinal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
SourceID: ArtifactTranscriptFinalTrimmed,
|
||||||
|
CanonicalRelPath: TranscriptPathFinalTrimmed,
|
||||||
|
ProducerStage: "trim",
|
||||||
|
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := PlannedTranscriptArtifacts()
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("PlannedTranscriptArtifacts() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, spec := range want {
|
||||||
|
gotSpec, ok := LookupPlannedTranscriptArtifact(spec.SourceID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("LookupPlannedTranscriptArtifact(%q) ok = false, want true", spec.SourceID)
|
||||||
|
}
|
||||||
|
if gotSpec != spec {
|
||||||
|
t.Fatalf("LookupPlannedTranscriptArtifact(%q) = %#v, want %#v", spec.SourceID, gotSpec, spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscriptArtifactSlicesAreCopies(t *testing.T) {
|
||||||
|
runtime := RuntimeTranscriptArtifacts()
|
||||||
|
runtime[0].SourceID = "changed"
|
||||||
|
if got := RuntimeTranscriptArtifacts()[0].SourceID; got != ArtifactTranscriptBase {
|
||||||
|
t.Fatalf("RuntimeTranscriptArtifacts()[0].SourceID = %q, want %q", got, ArtifactTranscriptBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
planned := PlannedTranscriptArtifacts()
|
||||||
|
planned[0].SourceID = "changed"
|
||||||
|
if got := PlannedTranscriptArtifacts()[0].SourceID; got != ArtifactTranscriptBase {
|
||||||
|
t.Fatalf("PlannedTranscriptArtifacts()[0].SourceID = %q, want %q", got, ArtifactTranscriptBase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeArtifactRegistryUsesTranscriptSpecs(t *testing.T) {
|
||||||
|
for _, transcript := range RuntimeTranscriptArtifacts() {
|
||||||
|
spec, ok := artifactRegistry[transcript.SourceID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("artifactRegistry missing %q", transcript.SourceID)
|
||||||
|
}
|
||||||
|
if spec.CanonicalRelPath != transcript.CanonicalRelPath ||
|
||||||
|
spec.ProducerStage != transcript.ProducerStage ||
|
||||||
|
spec.OutputKind != transcript.OutputKind ||
|
||||||
|
spec.ContentKind != contentTranscriptJSON {
|
||||||
|
t.Fatalf("artifactRegistry[%q] = %#v, want transcript spec %#v", transcript.SourceID, spec, transcript)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,6 @@ func TestCacheDefaults(t *testing.T) {
|
|||||||
root: /tmp/narratio
|
root: /tmp/narratio
|
||||||
whisperx:
|
whisperx:
|
||||||
transcribe_url: https://example.com/transcribe
|
transcribe_url: https://example.com/transcribe
|
||||||
analyzer:
|
|
||||||
timeout: 20m
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
`, `session_id: 2026-05-03
|
`, `session_id: 2026-05-03
|
||||||
|
|||||||
@@ -7,24 +7,52 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCampaignConfigDefaultSearchOrder(t *testing.T) {
|
func TestPipelineCampaignRegistryStrictDecode(t *testing.T) {
|
||||||
want := []string{
|
dir := t.TempDir()
|
||||||
"/usr/local/etc/narratio/campaign.yml",
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
"/etc/narratio/campaign.yml",
|
pipelineYAML := `workspace:
|
||||||
|
root: /tmp/narratio-work
|
||||||
|
campaigns:
|
||||||
|
root: /srv/narratio/campaigns
|
||||||
|
default_campaign_id: dilfs
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://example.com/transcribe
|
||||||
|
notification:
|
||||||
|
timeout: 10s
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
|
t.Fatalf("write pipeline.yml: %v", err)
|
||||||
}
|
}
|
||||||
if len(DefaultCampaignConfigSearchPaths) != len(want) {
|
cfg, err := LoadPipeline(pipelinePath)
|
||||||
t.Fatalf("DefaultCampaignConfigSearchPaths = %#v, want %#v", DefaultCampaignConfigSearchPaths, want)
|
if err != nil {
|
||||||
|
t.Fatalf("LoadPipeline() error = %v", err)
|
||||||
}
|
}
|
||||||
for i := range want {
|
if cfg.Campaigns.Root != "/srv/narratio/campaigns" {
|
||||||
if DefaultCampaignConfigSearchPaths[i] != want[i] {
|
t.Fatalf("campaigns.root = %q", cfg.Campaigns.Root)
|
||||||
t.Fatalf("DefaultCampaignConfigSearchPaths[%d] = %q, want %q", i, DefaultCampaignConfigSearchPaths[i], want[i])
|
}
|
||||||
}
|
if cfg.Campaigns.DefaultCampaignID != "dilfs" {
|
||||||
|
t.Fatalf("campaigns.default_campaign_id = %q", cfg.Campaigns.DefaultCampaignID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||||
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||||
|
}
|
||||||
|
if CampaignID(cfg.Campaign) != "sample-campaign" {
|
||||||
|
t.Fatalf("CampaignID() = %q, want sample-campaign", CampaignID(cfg.Campaign))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
|
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +65,39 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
|
"campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||||
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected load error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "campaign file") || !strings.Contains(err.Error(), "strict decode failed") {
|
||||||
|
t.Fatalf("error = %q, want campaign strict decode context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
|
"campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||||
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Campaign.SessionTemplateFile != "./session.template.yml" {
|
||||||
|
t.Fatalf("SessionTemplateFile = %q, want ./session.template.yml", cfg.Campaign.SessionTemplateFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,7 +118,7 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
|
|||||||
|
|
||||||
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
|
||||||
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n",
|
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,7 +136,7 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
|||||||
|
|
||||||
func TestCampaignSessionMismatchFails(t *testing.T) {
|
func TestCampaignSessionMismatchFails(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||||
"session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,7 +151,7 @@ func TestCampaignSessionMismatchFails(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadMissingCampaignFileFails(t *testing.T) {
|
func TestLoadMissingCampaignFileFails(t *testing.T) {
|
||||||
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
|
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
|
||||||
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")
|
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")
|
||||||
@@ -115,7 +173,7 @@ func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string
|
|||||||
campaignPath := filepath.Join(dir, "campaign.yml")
|
campaignPath := filepath.Join(dir, "campaign.yml")
|
||||||
sessionPath := filepath.Join(dir, "session.yml")
|
sessionPath := filepath.Join(dir, "session.yml")
|
||||||
|
|
||||||
pipelineYAML := "workspace:\n root: " + filepath.ToSlash(filepath.Join(dir, "work")) + "\nwhisperx:\n transcribe_url: https://example.com/transcribe\nanalyzer:\n timeout: 20m\nnotification:\n timeout: 10s\n"
|
pipelineYAML := "workspace:\n root: " + filepath.ToSlash(filepath.Join(dir, "work")) + "\nwhisperx:\n transcribe_url: https://example.com/transcribe\nnotification:\n timeout: 10s\n"
|
||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline.yml: %v", err)
|
t.Fatalf("write pipeline.yml: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ type Config struct {
|
|||||||
// PipelineConfig contains durable pipeline-level settings.
|
// PipelineConfig contains durable pipeline-level settings.
|
||||||
type PipelineConfig struct {
|
type PipelineConfig struct {
|
||||||
Workspace WorkspaceConfig `yaml:"workspace"`
|
Workspace WorkspaceConfig `yaml:"workspace"`
|
||||||
|
Campaigns CampaignsConfig `yaml:"campaigns"`
|
||||||
Storage StorageConfig `yaml:"storage"`
|
Storage StorageConfig `yaml:"storage"`
|
||||||
Spool SpoolConfig `yaml:"spool"`
|
Spool SpoolConfig `yaml:"spool"`
|
||||||
Cache CacheConfig `yaml:"cache"`
|
Cache CacheConfig `yaml:"cache"`
|
||||||
Archive *ArchiveConfig `yaml:"archive"`
|
Publish *PublishConfig `yaml:"publish"`
|
||||||
Secrets *SecretsConfig `yaml:"secrets"`
|
Secrets *SecretsConfig `yaml:"secrets"`
|
||||||
WhisperX WhisperXConfig `yaml:"whisperx"`
|
WhisperX WhisperXConfig `yaml:"whisperx"`
|
||||||
Seriatim SeriatimConfig `yaml:"seriatim"`
|
Seriatim SeriatimConfig `yaml:"seriatim"`
|
||||||
@@ -28,14 +29,20 @@ type PipelineConfig struct {
|
|||||||
Normalize *NormalizeConfig `yaml:"normalize"`
|
Normalize *NormalizeConfig `yaml:"normalize"`
|
||||||
Trim *TrimConfig `yaml:"trim"`
|
Trim *TrimConfig `yaml:"trim"`
|
||||||
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
||||||
Analyzer AnalyzerConfig `yaml:"analyzer"`
|
|
||||||
Notification NotificationConfig `yaml:"notification"`
|
Notification NotificationConfig `yaml:"notification"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CampaignsConfig configures the local campaign registry.
|
||||||
|
type CampaignsConfig struct {
|
||||||
|
Root string `yaml:"root"`
|
||||||
|
DefaultCampaignID string `yaml:"default_campaign_id"`
|
||||||
|
}
|
||||||
|
|
||||||
// CampaignConfig contains stable campaign-level identity and input defaults.
|
// CampaignConfig contains stable campaign-level identity and input defaults.
|
||||||
type CampaignConfig struct {
|
type CampaignConfig struct {
|
||||||
Campaign string `yaml:"campaign"`
|
CampaignID string `yaml:"campaign_id"`
|
||||||
Inputs CampaignInputsConfig `yaml:"inputs"`
|
SessionTemplateFile string `yaml:"session_template_file"`
|
||||||
|
Inputs CampaignInputsConfig `yaml:"inputs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CampaignInputsConfig contains stable campaign-level input file references.
|
// CampaignInputsConfig contains stable campaign-level input file references.
|
||||||
@@ -58,7 +65,7 @@ type SessionConfig struct {
|
|||||||
// WorkspaceConfig configures local workspace behavior.
|
// WorkspaceConfig configures local workspace behavior.
|
||||||
type WorkspaceConfig struct {
|
type WorkspaceConfig struct {
|
||||||
Root string `yaml:"root"`
|
Root string `yaml:"root"`
|
||||||
CleanupAfterArchive bool `yaml:"cleanup_after_archive"`
|
CleanupAfterPublish bool `yaml:"cleanup_after_publish"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretsConfig configures optional local filesystem secret loading.
|
// SecretsConfig configures optional local filesystem secret loading.
|
||||||
@@ -69,8 +76,6 @@ type SecretsConfig struct {
|
|||||||
// StorageConfig configures storage backends and related parameters.
|
// StorageConfig configures storage backends and related parameters.
|
||||||
type StorageConfig struct {
|
type StorageConfig struct {
|
||||||
Backend string `yaml:"backend"`
|
Backend string `yaml:"backend"`
|
||||||
Bucket string `yaml:"bucket"`
|
|
||||||
Prefix string `yaml:"prefix"`
|
|
||||||
S3 *StorageS3Config `yaml:"s3"`
|
S3 *StorageS3Config `yaml:"s3"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +93,7 @@ type StorageS3Config struct {
|
|||||||
// SpoolConfig configures local spool storage for staged data.
|
// SpoolConfig configures local spool storage for staged data.
|
||||||
type SpoolConfig struct {
|
type SpoolConfig struct {
|
||||||
Root string `yaml:"root"`
|
Root string `yaml:"root"`
|
||||||
DeleteAudioAfterArchive bool `yaml:"delete_audio_after_archive"`
|
DeleteAudioAfterPublish bool `yaml:"delete_audio_after_publish"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CacheConfig configures durable local caches for reusable remote inputs.
|
// CacheConfig configures durable local caches for reusable remote inputs.
|
||||||
@@ -97,31 +102,31 @@ type CacheConfig struct {
|
|||||||
S3Audio *bool `yaml:"s3_audio"`
|
S3Audio *bool `yaml:"s3_audio"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveConfig configures archive behavior and artifact promotions.
|
// PublishConfig configures publish behavior and source-based output uploads.
|
||||||
type ArchiveConfig struct {
|
type PublishConfig struct {
|
||||||
Enabled *bool `yaml:"enabled"`
|
Enabled *bool `yaml:"enabled"`
|
||||||
UploadRun *bool `yaml:"upload_run"`
|
UploadRun *bool `yaml:"upload_run"`
|
||||||
PromoteArtifacts []ArchivePromotionRule `yaml:"promote_artifacts"`
|
Outputs []PublishOutputRule `yaml:"outputs"`
|
||||||
Locks []ArchiveLockRule `yaml:"locks"`
|
Locks []PublishLockRule `yaml:"locks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchivePromotionRule configures one artifact promotion mapping.
|
// PublishOutputRule configures one source-to-destination publish mapping.
|
||||||
type ArchivePromotionRule struct {
|
type PublishOutputRule struct {
|
||||||
Source string `yaml:"source"`
|
Source string `yaml:"source"`
|
||||||
Dest string `yaml:"dest"`
|
Dest string `yaml:"dest"`
|
||||||
Required *bool `yaml:"required"`
|
Required *bool `yaml:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveLockRule prevents one source-based promotion from overwriting its
|
// PublishLockRule prevents one source from overwriting its top-level published
|
||||||
// top-level archive destination.
|
// destination.
|
||||||
type ArchiveLockRule struct {
|
type PublishLockRule struct {
|
||||||
Source string `yaml:"source"`
|
Source string `yaml:"source"`
|
||||||
Reason string `yaml:"reason"`
|
Reason string `yaml:"reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveLockStore is the mutable per-session remote lock store.
|
// PublishLockStore is the mutable per-session remote lock store.
|
||||||
type ArchiveLockStore struct {
|
type PublishLockStore struct {
|
||||||
Locks []ArchiveLockRule `yaml:"locks"`
|
Locks []PublishLockRule `yaml:"locks"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WhisperXConfig configures WhisperX adapter settings.
|
// WhisperXConfig configures WhisperX adapter settings.
|
||||||
@@ -234,13 +239,6 @@ type ScriptoriumInputConfig struct {
|
|||||||
Required bool `yaml:"required"`
|
Required bool `yaml:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzerConfig configures analyzer adapter settings.
|
|
||||||
type AnalyzerConfig struct {
|
|
||||||
BinaryPath string `yaml:"binary_path"`
|
|
||||||
Timeout string `yaml:"timeout"`
|
|
||||||
Artifacts ArtifactSettings `yaml:"artifacts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotificationConfig configures notification backend settings.
|
// NotificationConfig configures notification backend settings.
|
||||||
type NotificationConfig struct {
|
type NotificationConfig struct {
|
||||||
Backend string `yaml:"backend"`
|
Backend string `yaml:"backend"`
|
||||||
@@ -248,12 +246,6 @@ type NotificationConfig struct {
|
|||||||
Timeout string `yaml:"timeout"`
|
Timeout string `yaml:"timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactSettings configures generated artifact selection and paths.
|
|
||||||
type ArtifactSettings struct {
|
|
||||||
OutputDir string `yaml:"output_dir"`
|
|
||||||
Types []string `yaml:"types"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionInputsConfig contains per-session input references.
|
// SessionInputsConfig contains per-session input references.
|
||||||
type SessionInputsConfig struct {
|
type SessionInputsConfig struct {
|
||||||
AudioDir string `yaml:"audio_dir"`
|
AudioDir string `yaml:"audio_dir"`
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
|
||||||
// Default filesystem locations for config lookup when config path flags are
|
// Default filesystem locations for config lookup when config path flags are
|
||||||
// omitted. Order is highest to lowest precedence.
|
// omitted. Order is highest to lowest precedence.
|
||||||
const (
|
const (
|
||||||
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
|
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
|
||||||
DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml"
|
DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml"
|
||||||
DefaultCampaignConfigPathUsrLocal = "/usr/local/etc/narratio/campaign.yml"
|
|
||||||
DefaultCampaignConfigPathEtc = "/etc/narratio/campaign.yml"
|
|
||||||
DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml"
|
DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml"
|
||||||
DefaultSessionConfigPathEtc = "/etc/narratio/session.yml"
|
DefaultSessionConfigPathEtc = "/etc/narratio/session.yml"
|
||||||
DefaultS3AccessKeyIDEnv = "OBJECT_STORAGE_KEY_ID"
|
DefaultS3AccessKeyIDEnv = "OBJECT_STORAGE_KEY_ID"
|
||||||
DefaultS3SecretAccessKeyEnv = "OBJECT_STORAGE_KEY"
|
DefaultS3SecretAccessKeyEnv = "OBJECT_STORAGE_KEY"
|
||||||
DefaultStorageS3RootPrefix = "dnd"
|
DefaultStorageS3RootPrefix = "dnd"
|
||||||
DefaultWorkspaceRoot = "/var/lib/narratio"
|
DefaultWorkspaceRoot = "/var/lib/narratio"
|
||||||
|
DefaultCampaignsRoot = "/usr/local/share/narratio/campaigns"
|
||||||
DefaultSpoolRoot = "/var/spool/narratio"
|
DefaultSpoolRoot = "/var/spool/narratio"
|
||||||
DefaultCacheRoot = "/var/cache/narratio"
|
DefaultCacheRoot = "/var/cache/narratio"
|
||||||
DefaultCacheS3Audio = true
|
DefaultCacheS3Audio = true
|
||||||
@@ -40,32 +41,32 @@ const (
|
|||||||
DefaultTrimBoundsTimeout = "10m"
|
DefaultTrimBoundsTimeout = "10m"
|
||||||
DefaultTrimSeriatimReport = false
|
DefaultTrimSeriatimReport = false
|
||||||
|
|
||||||
DefaultNormalizeOutputPath = "transcripts/normalized.json"
|
DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
|
||||||
DefaultNormalizeOutputSchema = "seriatim-intermediate"
|
DefaultNormalizeOutputSchema = "seriatim-intermediate"
|
||||||
DefaultNormalizeReport = true
|
DefaultNormalizeReport = true
|
||||||
|
|
||||||
DefaultArchiveEnabled = true
|
DefaultArchiveEnabled = true
|
||||||
DefaultArchiveUploadRun = true
|
DefaultArchiveUploadRun = true
|
||||||
|
|
||||||
PathWorkDirSegment = "work"
|
PathWorkDirSegment = "work"
|
||||||
PathInputsDirSegment = "inputs"
|
PathInputsDirSegment = "inputs"
|
||||||
PathAudioDirSegment = "audio"
|
PathAudioDirSegment = "audio"
|
||||||
PathTranscriptsSegment = "transcripts"
|
PathTranscriptsSegment = "transcripts"
|
||||||
PathTranscriptsRaw = "transcripts/raw"
|
PathTranscriptsRaw = "transcripts/raw"
|
||||||
PathTranscriptsTrimmed = "transcripts/trimmed"
|
PathTranscriptsTrimmed = "transcripts/trimmed"
|
||||||
PathArtifactsDirSegment = "artifacts"
|
PathArtifactsDirSegment = "artifacts"
|
||||||
PathReportsDirSegment = "reports"
|
PathReportsDirSegment = "reports"
|
||||||
PathConfigDirSegment = "config"
|
PathConfigDirSegment = "config"
|
||||||
PathLogsDirSegment = "logs"
|
PathLogsDirSegment = "logs"
|
||||||
PathCurrentDirSegment = "current"
|
PathCurrentDirSegment = "current"
|
||||||
PathRunsDirSegment = "runs"
|
PathRunsDirSegment = "runs"
|
||||||
PathPreviousDirSegment = "previous"
|
PathPreviousDirSegment = "previous"
|
||||||
PathManifestFile = "manifest.json"
|
PathManifestFile = "manifest.json"
|
||||||
PathLockFile = ".lock"
|
PathLockFile = ".lock"
|
||||||
PathTranscriptMerged = "transcripts/merged.json"
|
PathTranscriptBase = artifactmodel.TranscriptPathBase
|
||||||
PathTranscriptProcessed = "transcripts/processed.json"
|
PathTranscriptPolished = artifactmodel.TranscriptPathPolished
|
||||||
PathTranscriptNormalized = "transcripts/normalized.json"
|
PathTranscriptFinal = artifactmodel.TranscriptPathFinal
|
||||||
PathTranscriptTrimmed = "transcripts/trimmed.json"
|
PathTranscriptFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
|
||||||
|
|
||||||
S3CampaignsSegment = "campaigns"
|
S3CampaignsSegment = "campaigns"
|
||||||
S3SessionsSegment = "sessions"
|
S3SessionsSegment = "sessions"
|
||||||
@@ -75,10 +76,10 @@ const (
|
|||||||
S3RunIDFile = "run_id.txt"
|
S3RunIDFile = "run_id.txt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultArchivePromoteArtifacts defines the default archive promotion rules.
|
// DefaultPublishOutputs defines the default publish output rules.
|
||||||
// Callers should copy this slice before mutating.
|
// Callers should copy this slice before mutating.
|
||||||
var DefaultArchivePromoteArtifacts = []ArchivePromotionRule{
|
var DefaultPublishOutputs = []PublishOutputRule{
|
||||||
{Source: "narratio.transcript.trimmed", Dest: PathTranscriptTrimmed},
|
{Source: artifactmodel.SourceTranscriptFinalTrimmed, Dest: PathTranscriptFinalTrimmed},
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPipelineConfigSearchPaths defines the default search order for
|
// DefaultPipelineConfigSearchPaths defines the default search order for
|
||||||
@@ -91,16 +92,6 @@ var DefaultPipelineConfigSearchPaths = []string{
|
|||||||
DefaultPipelineConfigPathEtc,
|
DefaultPipelineConfigPathEtc,
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultCampaignConfigSearchPaths defines the default search order for
|
|
||||||
// campaign.yml when callers do not provide an explicit path.
|
|
||||||
//
|
|
||||||
// Keep this in a variable so future defaults can be extended without changing
|
|
||||||
// call sites.
|
|
||||||
var DefaultCampaignConfigSearchPaths = []string{
|
|
||||||
DefaultCampaignConfigPathUsrLocal,
|
|
||||||
DefaultCampaignConfigPathEtc,
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultSessionConfigSearchPaths defines the default search order for
|
// DefaultSessionConfigSearchPaths defines the default search order for
|
||||||
// session.yml when callers do not provide an explicit path.
|
// session.yml when callers do not provide an explicit path.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ func LoadSession(path string) (*SessionConfig, error) {
|
|||||||
return LoadSessionWithOptions(path, SessionLoadOptions{})
|
return LoadSessionWithOptions(path, SessionLoadOptions{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionLoadOptions configures session template rendering behavior.
|
// SessionLoadOptions configures expected session identity checks.
|
||||||
type SessionLoadOptions struct {
|
type SessionLoadOptions struct {
|
||||||
SessionID string
|
SessionID string
|
||||||
PreviousSessionID string
|
PreviousSessionID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSessionWithOptions loads session configuration from a YAML file with
|
// LoadSessionWithOptions loads session configuration from a YAML file with
|
||||||
// strict field checking after template rendering.
|
// strict field checking.
|
||||||
func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) {
|
func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) {
|
||||||
sessionBytes, err := os.ReadFile(path)
|
sessionBytes, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -53,20 +53,19 @@ func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LoadSessionBytesWithOptions loads session configuration from YAML bytes with
|
// LoadSessionBytesWithOptions loads session configuration from YAML bytes with
|
||||||
// strict field checking after template rendering.
|
// strict field checking.
|
||||||
func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOptions) (*SessionConfig, error) {
|
func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOptions) (*SessionConfig, error) {
|
||||||
rendered, err := renderSessionTemplate(string(data), opts)
|
if err := rejectSessionTemplatePlaceholders(label, string(data)); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("load session config: %w", err)
|
return nil, fmt.Errorf("load session config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg SessionConfig
|
var cfg SessionConfig
|
||||||
if err := decodeStrictYAMLFromReader("session", label, strings.NewReader(rendered), &cfg); err != nil {
|
if err := decodeStrictYAMLFromReader("session", label, strings.NewReader(string(data)), &cfg); err != nil {
|
||||||
return nil, fmt.Errorf("load session config: %w", err)
|
return nil, fmt.Errorf("load session config: %w", err)
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(opts.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != strings.TrimSpace(opts.SessionID) {
|
if strings.TrimSpace(opts.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != strings.TrimSpace(opts.SessionID) {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"load session config: session file %q: session_id mismatch: --session-id %q does not match rendered session_id %q",
|
"load session config: session file %q: session_id mismatch: --session-id %q does not match session_id %q",
|
||||||
label,
|
label,
|
||||||
strings.TrimSpace(opts.SessionID),
|
strings.TrimSpace(opts.SessionID),
|
||||||
strings.TrimSpace(cfg.SessionID),
|
strings.TrimSpace(cfg.SessionID),
|
||||||
@@ -76,7 +75,7 @@ func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOpti
|
|||||||
strings.TrimSpace(cfg.PreviousSessionID) != "" &&
|
strings.TrimSpace(cfg.PreviousSessionID) != "" &&
|
||||||
strings.TrimSpace(cfg.PreviousSessionID) != strings.TrimSpace(opts.PreviousSessionID) {
|
strings.TrimSpace(cfg.PreviousSessionID) != strings.TrimSpace(opts.PreviousSessionID) {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match rendered previous_session_id %q",
|
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match previous_session_id %q",
|
||||||
label,
|
label,
|
||||||
strings.TrimSpace(opts.PreviousSessionID),
|
strings.TrimSpace(opts.PreviousSessionID),
|
||||||
strings.TrimSpace(cfg.PreviousSessionID),
|
strings.TrimSpace(cfg.PreviousSessionID),
|
||||||
@@ -85,29 +84,29 @@ func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOpti
|
|||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadArchiveLockStoreBytes loads a mutable session lock store with strict
|
// LoadPublishLockStoreBytes loads a mutable session lock store with strict
|
||||||
// field checking and source validation.
|
// field checking and source validation.
|
||||||
func LoadArchiveLockStoreBytes(label string, data []byte, scriptorium *ScriptoriumConfig) (*ArchiveLockStore, error) {
|
func LoadPublishLockStoreBytes(label string, data []byte, scriptorium *ScriptoriumConfig) (*PublishLockStore, error) {
|
||||||
var store ArchiveLockStore
|
var store PublishLockStore
|
||||||
if err := decodeStrictYAMLFromReader("archive lock store", label, strings.NewReader(string(data)), &store); err != nil {
|
if err := decodeStrictYAMLFromReader("publish lock store", label, strings.NewReader(string(data)), &store); err != nil {
|
||||||
return nil, fmt.Errorf("load archive lock store: %w", err)
|
return nil, fmt.Errorf("load publish lock store: %w", err)
|
||||||
}
|
}
|
||||||
locks, err := ValidateArchiveLockRules(store.Locks, scriptorium, "locks")
|
locks, err := ValidatePublishLockRules(store.Locks, scriptorium, "locks")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load archive lock store: %w", err)
|
return nil, fmt.Errorf("load publish lock store: %w", err)
|
||||||
}
|
}
|
||||||
store.Locks = locks
|
store.Locks = locks
|
||||||
return &store, nil
|
return &store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalArchiveLockStore serializes a mutable lock store as strict-compatible YAML.
|
// MarshalPublishLockStore serializes a mutable lock store as strict-compatible YAML.
|
||||||
func MarshalArchiveLockStore(store *ArchiveLockStore) ([]byte, error) {
|
func MarshalPublishLockStore(store *PublishLockStore) ([]byte, error) {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
store = &ArchiveLockStore{}
|
store = &PublishLockStore{}
|
||||||
}
|
}
|
||||||
data, err := yaml.Marshal(store)
|
data, err := yaml.Marshal(store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal archive lock store: %w", err)
|
return nil, fmt.Errorf("marshal publish lock store: %w", err)
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
@@ -124,7 +123,7 @@ func Load(pipelinePath string, paths ...string) (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LoadWithSessionOptions loads and resolves combined pipeline, campaign, and
|
// LoadWithSessionOptions loads and resolves combined pipeline, campaign, and
|
||||||
// session configuration with session template options.
|
// session configuration with expected session identity checks.
|
||||||
func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
|
func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
|
||||||
pipelineCfg, err := LoadPipeline(pipelinePath)
|
pipelineCfg, err := LoadPipeline(pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -195,7 +194,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
|||||||
return ResolvedStableInputs{}, fmt.Errorf("session config is required")
|
return ResolvedStableInputs{}, fmt.Errorf("session config is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
campaignName := strings.TrimSpace(campaignCfg.Campaign)
|
campaignName := CampaignID(campaignCfg)
|
||||||
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
|
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
|
||||||
if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName {
|
if sessionCampaign != "" && campaignName != "" && sessionCampaign != campaignName {
|
||||||
return ResolvedStableInputs{}, fmt.Errorf(
|
return ResolvedStableInputs{}, fmt.Errorf(
|
||||||
@@ -235,6 +234,14 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
|||||||
return stable, nil
|
return stable, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CampaignID returns the canonical campaign identity from campaign config.
|
||||||
|
func CampaignID(cfg *CampaignConfig) string {
|
||||||
|
if cfg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cfg.CampaignID)
|
||||||
|
}
|
||||||
|
|
||||||
func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath string) ResolvedInputFile {
|
func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath string) ResolvedInputFile {
|
||||||
if strings.TrimSpace(sessionValue) != "" {
|
if strings.TrimSpace(sessionValue) != "" {
|
||||||
return ResolvedInputFile{
|
return ResolvedInputFile{
|
||||||
@@ -275,75 +282,33 @@ func decodeStrictYAMLFromReader(kind, path string, r io.Reader, out any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessionTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
|
var sessionTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^}]*\}\}`)
|
||||||
|
var sessionTemplateVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
|
||||||
|
|
||||||
func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) {
|
func rejectSessionTemplatePlaceholders(label, content string) error {
|
||||||
sessionID := strings.TrimSpace(opts.SessionID)
|
placeholders := sessionTemplatePlaceholderPattern.FindAllString(content, -1)
|
||||||
previousSessionID := strings.TrimSpace(opts.PreviousSessionID)
|
if len(placeholders) == 0 {
|
||||||
rendered := content
|
return nil
|
||||||
if sessionID != "" {
|
|
||||||
rendered = replaceTemplateVariable(rendered, "session_id", sessionID)
|
|
||||||
}
|
}
|
||||||
if previousSessionID != "" {
|
|
||||||
rendered = replaceTemplateVariable(rendered, "previous_session_id", previousSessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1)
|
|
||||||
if len(unresolved) > 0 {
|
|
||||||
seenVars := map[string]struct{}{}
|
|
||||||
vars := make([]string, 0, len(unresolved))
|
|
||||||
for _, m := range unresolved {
|
|
||||||
if len(m) > 1 {
|
|
||||||
name := m[1]
|
|
||||||
if _, ok := seenVars[name]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenVars[name] = struct{}{}
|
|
||||||
vars = append(vars, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(vars)
|
|
||||||
if len(vars) > 0 {
|
|
||||||
hints := unresolvedTemplateHints(vars)
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"session file template rendering failed: unresolved template variable(s): %s%s",
|
|
||||||
strings.Join(vars, ", "),
|
|
||||||
hints,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return "", fmt.Errorf("session file template rendering failed: unresolved template placeholders remain")
|
|
||||||
}
|
|
||||||
|
|
||||||
return rendered, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func replaceTemplateVariable(content, name, value string) string {
|
|
||||||
rendered := strings.ReplaceAll(content, "{{"+name+"}}", value)
|
|
||||||
rendered = strings.ReplaceAll(rendered, "{{ "+name+" }}", value)
|
|
||||||
return rendered
|
|
||||||
}
|
|
||||||
|
|
||||||
func unresolvedTemplateHints(vars []string) string {
|
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
flags := make([]string, 0, 2)
|
vars := make([]string, 0, len(placeholders))
|
||||||
for _, name := range vars {
|
for _, placeholder := range placeholders {
|
||||||
switch name {
|
name := strings.TrimSpace(placeholder)
|
||||||
case "session_id":
|
if match := sessionTemplateVariablePattern.FindStringSubmatch(placeholder); len(match) > 1 {
|
||||||
if _, ok := seen["--session-id"]; !ok {
|
name = match[1]
|
||||||
seen["--session-id"] = struct{}{}
|
|
||||||
flags = append(flags, "--session-id")
|
|
||||||
}
|
|
||||||
case "previous_session_id":
|
|
||||||
if _, ok := seen["--previous-session-id"]; !ok {
|
|
||||||
seen["--previous-session-id"] = struct{}{}
|
|
||||||
flags = append(flags, "--previous-session-id")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if _, ok := seen[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = struct{}{}
|
||||||
|
vars = append(vars, name)
|
||||||
}
|
}
|
||||||
if len(flags) == 0 {
|
sort.Strings(vars)
|
||||||
return ""
|
return fmt.Errorf(
|
||||||
}
|
"session file %q contains template placeholder(s): %s; session.yml must be concrete; run narratio session init to generate it",
|
||||||
return "; pass " + strings.Join(flags, " and ") + " when using those template variable(s)"
|
label,
|
||||||
|
strings.Join(vars, ", "),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func shortName(path, fallback string) string {
|
func shortName(path, fallback string) string {
|
||||||
@@ -359,10 +324,11 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
applyWorkspaceDefaults(&cfg.Workspace)
|
applyWorkspaceDefaults(&cfg.Workspace)
|
||||||
|
applyCampaignsDefaults(&cfg.Campaigns)
|
||||||
applyStorageDefaults(&cfg.Storage)
|
applyStorageDefaults(&cfg.Storage)
|
||||||
applySpoolDefaults(&cfg.Spool)
|
applySpoolDefaults(&cfg.Spool)
|
||||||
applyCacheDefaults(&cfg.Cache)
|
applyCacheDefaults(&cfg.Cache)
|
||||||
applyArchiveDefaults(&cfg.Archive)
|
applyPublishDefaults(&cfg.Publish)
|
||||||
applyWhisperXDefaults(&cfg.WhisperX)
|
applyWhisperXDefaults(&cfg.WhisperX)
|
||||||
applySeriatimDefaults(&cfg.Seriatim)
|
applySeriatimDefaults(&cfg.Seriatim)
|
||||||
applyAuditaDefaults(&cfg.Audita)
|
applyAuditaDefaults(&cfg.Audita)
|
||||||
@@ -374,6 +340,15 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
|
|||||||
applyScriptoriumDefaults(cfg.Scriptorium)
|
applyScriptoriumDefaults(cfg.Scriptorium)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyCampaignsDefaults(cfg *CampaignsConfig) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.Root == "" {
|
||||||
|
cfg.Root = DefaultCampaignsRoot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func applyWorkspaceDefaults(cfg *WorkspaceConfig) {
|
func applyWorkspaceDefaults(cfg *WorkspaceConfig) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return
|
return
|
||||||
@@ -422,12 +397,12 @@ func applyCacheDefaults(cfg *CacheConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyArchiveDefaults(cfg **ArchiveConfig) {
|
func applyPublishDefaults(cfg **PublishConfig) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if *cfg == nil {
|
if *cfg == nil {
|
||||||
*cfg = &ArchiveConfig{}
|
*cfg = &PublishConfig{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (*cfg).Enabled == nil {
|
if (*cfg).Enabled == nil {
|
||||||
@@ -436,12 +411,12 @@ func applyArchiveDefaults(cfg **ArchiveConfig) {
|
|||||||
if (*cfg).UploadRun == nil {
|
if (*cfg).UploadRun == nil {
|
||||||
(*cfg).UploadRun = boolPtr(DefaultArchiveUploadRun)
|
(*cfg).UploadRun = boolPtr(DefaultArchiveUploadRun)
|
||||||
}
|
}
|
||||||
if len((*cfg).PromoteArtifacts) == 0 {
|
if len((*cfg).Outputs) == 0 {
|
||||||
(*cfg).PromoteArtifacts = append([]ArchivePromotionRule(nil), DefaultArchivePromoteArtifacts...)
|
(*cfg).Outputs = append([]PublishOutputRule(nil), DefaultPublishOutputs...)
|
||||||
}
|
}
|
||||||
for i := range (*cfg).PromoteArtifacts {
|
for i := range (*cfg).Outputs {
|
||||||
if (*cfg).PromoteArtifacts[i].Required == nil {
|
if (*cfg).Outputs[i].Required == nil {
|
||||||
(*cfg).PromoteArtifacts[i].Required = boolPtr(true)
|
(*cfg).Outputs[i].Required = boolPtr(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ seriatim:
|
|||||||
binary: seriatim
|
binary: seriatim
|
||||||
audita:
|
audita:
|
||||||
binary: audita
|
binary: audita
|
||||||
analyzer:
|
|
||||||
timeout: 20m
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 15s
|
timeout: 15s
|
||||||
`,
|
`,
|
||||||
@@ -48,8 +46,6 @@ inputs:
|
|||||||
root: /tmp/narratio
|
root: /tmp/narratio
|
||||||
whisperx:
|
whisperx:
|
||||||
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||||
analyzer:
|
|
||||||
timeout: 20m
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 15s
|
timeout: 15s
|
||||||
`,
|
`,
|
||||||
@@ -67,8 +63,6 @@ inputs:
|
|||||||
name: "workspace root defaults when omitted",
|
name: "workspace root defaults when omitted",
|
||||||
pipelineYAML: `whisperx:
|
pipelineYAML: `whisperx:
|
||||||
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||||
analyzer:
|
|
||||||
timeout: 20m
|
|
||||||
notification:
|
notification:
|
||||||
timeout: 15s
|
timeout: 15s
|
||||||
`,
|
`,
|
||||||
@@ -97,6 +91,24 @@ inputs:
|
|||||||
`,
|
`,
|
||||||
wantLoadErr: "pipeline file",
|
wantLoadErr: "pipeline file",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "legacy analyzer section fails strict decode",
|
||||||
|
pipelineYAML: `workspace:
|
||||||
|
root: /tmp/narratio
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||||
|
analyzer:
|
||||||
|
timeout: 20m
|
||||||
|
`,
|
||||||
|
sessionYAML: `session_id: 2026-05-03
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`,
|
||||||
|
wantLoadErr: "strict decode failed",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "unknown whisperx field fails",
|
name: "unknown whisperx field fails",
|
||||||
pipelineYAML: `workspace:
|
pipelineYAML: `workspace:
|
||||||
@@ -841,8 +853,8 @@ inputs:
|
|||||||
if cfg.Pipeline.Normalize == nil {
|
if cfg.Pipeline.Normalize == nil {
|
||||||
t.Fatal("normalize config should be present via defaults")
|
t.Fatal("normalize config should be present via defaults")
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Normalize.OutputPath != "transcripts/normalized.json" {
|
if cfg.Pipeline.Normalize.OutputPath != "transcripts/final.json" {
|
||||||
t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/normalized.json")
|
t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/final.json")
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" {
|
if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" {
|
||||||
t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate")
|
t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate")
|
||||||
@@ -904,7 +916,7 @@ func TestValidateMissingAudioSource(t *testing.T) {
|
|||||||
Report: boolPtr(true),
|
Report: boolPtr(true),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Campaign: &CampaignConfig{Campaign: "sample-campaign"},
|
Campaign: &CampaignConfig{CampaignID: "sample-campaign"},
|
||||||
Session: &SessionConfig{
|
Session: &SessionConfig{
|
||||||
SessionID: "2026-05-03",
|
SessionID: "2026-05-03",
|
||||||
Campaign: "sample-campaign",
|
Campaign: "sample-campaign",
|
||||||
@@ -934,7 +946,6 @@ func TestExamplesLoadAndValidate(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
pipelineFile string
|
pipelineFile string
|
||||||
sessionFile string
|
sessionFile string
|
||||||
sessionOpts SessionLoadOptions
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "minimal pipeline with local audio session",
|
name: "minimal pipeline with local audio session",
|
||||||
@@ -951,31 +962,15 @@ func TestExamplesLoadAndValidate(t *testing.T) {
|
|||||||
pipelineFile: "pipeline.full.annotated.yml",
|
pipelineFile: "pipeline.full.annotated.yml",
|
||||||
sessionFile: "session.local-audio.yml",
|
sessionFile: "session.local-audio.yml",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "template session renders with session_id option",
|
|
||||||
pipelineFile: "pipeline.minimal.yml",
|
|
||||||
sessionFile: "session.template.yml",
|
|
||||||
sessionOpts: SessionLoadOptions{
|
|
||||||
SessionID: "2026-05-03",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
pipelinePath := filepath.Join(examplesDir, tt.pipelineFile)
|
pipelinePath := filepath.Join(examplesDir, tt.pipelineFile)
|
||||||
campaignPath := filepath.Join(examplesDir, "campaign.yml")
|
campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml")
|
||||||
sessionPath := filepath.Join(examplesDir, tt.sessionFile)
|
sessionPath := filepath.Join(examplesDir, tt.sessionFile)
|
||||||
|
|
||||||
var (
|
cfg, err := Load(pipelinePath, campaignPath, sessionPath)
|
||||||
cfg *Config
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
if strings.TrimSpace(tt.sessionOpts.SessionID) == "" {
|
|
||||||
cfg, err = Load(pipelinePath, sessionPath)
|
|
||||||
} else {
|
|
||||||
cfg, err = LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, tt.sessionOpts)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load example config error = %v", err)
|
t.Fatalf("load example config error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1009,7 +1004,7 @@ func writeConfigFiles(t *testing.T, pipelineYAML, sessionYAML string) (string, s
|
|||||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline.yml: %v", err)
|
t.Fatalf("write pipeline.yml: %v", err)
|
||||||
}
|
}
|
||||||
campaignYAML := `campaign: ` + campaignNameFromSessionYAML(sessionYAML) + `
|
campaignYAML := `campaign_id: ` + campaignNameFromSessionYAML(sessionYAML) + `
|
||||||
inputs:
|
inputs:
|
||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ func TestNormalizeLoadAndValidate(t *testing.T) {
|
|||||||
if cfg.Pipeline.Normalize == nil {
|
if cfg.Pipeline.Normalize == nil {
|
||||||
t.Fatal("normalize config should be present via defaults")
|
t.Fatal("normalize config should be present via defaults")
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Normalize.OutputPath != "transcripts/normalized.json" {
|
if cfg.Pipeline.Normalize.OutputPath != "transcripts/final.json" {
|
||||||
t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/normalized.json")
|
t.Fatalf("normalize.output_path = %q, want %q", cfg.Pipeline.Normalize.OutputPath, "transcripts/final.json")
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" {
|
if cfg.Pipeline.Normalize.OutputSchema != "seriatim-intermediate" {
|
||||||
t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate")
|
t.Fatalf("normalize.output_schema = %q, want %q", cfg.Pipeline.Normalize.OutputSchema, "seriatim-intermediate")
|
||||||
@@ -58,7 +58,7 @@ func TestNormalizeLoadAndValidate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "invalid normalize output schema fails",
|
name: "invalid normalize output schema fails",
|
||||||
normalizeYAML: `normalize:
|
normalizeYAML: `normalize:
|
||||||
output_path: transcripts/normalized.json
|
output_path: transcripts/final.json
|
||||||
output_schema: not-a-schema
|
output_schema: not-a-schema
|
||||||
report: true
|
report: true
|
||||||
`,
|
`,
|
||||||
@@ -76,7 +76,7 @@ func TestNormalizeLoadAndValidate(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "unknown normalize field fails strict decoding",
|
name: "unknown normalize field fails strict decoding",
|
||||||
normalizeYAML: `normalize:
|
normalizeYAML: `normalize:
|
||||||
output_path: transcripts/normalized.json
|
output_path: transcripts/final.json
|
||||||
output_schema: seriatim-intermediate
|
output_schema: seriatim-intermediate
|
||||||
report: true
|
report: true
|
||||||
bogus: true
|
bogus: true
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestScriptoriumLoadAndValidate(t *testing.T) {
|
func TestScriptoriumLoadAndValidate(t *testing.T) {
|
||||||
|
legacyPreviousSource := "previous_session_" + "artifact"
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
scriptoriumYAML string
|
scriptoriumYAML string
|
||||||
@@ -94,7 +95,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
wantValidateErr: "pipeline.scriptorium.timeout must be a valid duration",
|
wantValidateErr: "pipeline.scriptorium.timeout must be a valid duration",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "optional previous recap input is accepted",
|
name: "legacy previous session artifact source fails validation",
|
||||||
scriptoriumYAML: `scriptorium:
|
scriptoriumYAML: `scriptorium:
|
||||||
binary: scriptorium
|
binary: scriptorium
|
||||||
artifacts:
|
artifacts:
|
||||||
@@ -107,7 +108,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
source: narratio.transcript.polished
|
source: narratio.transcript.polished
|
||||||
required: true
|
required: true
|
||||||
previous_recap:
|
previous_recap:
|
||||||
source: previous_session_artifact
|
source: ` + legacyPreviousSource + `
|
||||||
artifact: session_recap
|
artifact: session_recap
|
||||||
path: ""
|
path: ""
|
||||||
required: false
|
required: false
|
||||||
@@ -115,6 +116,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
session_id: true
|
session_id: true
|
||||||
output_kind: session_recap
|
output_kind: session_recap
|
||||||
`,
|
`,
|
||||||
|
wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "` + legacyPreviousSource + `" is unsupported`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "canonical previous-session source is accepted",
|
name: "canonical previous-session source is accepted",
|
||||||
@@ -196,7 +198,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
output_path: artifacts/session_recap.md
|
output_path: artifacts/session_recap.md
|
||||||
inputs:
|
inputs:
|
||||||
transcript:
|
transcript:
|
||||||
source: narratio.transcript.trimmed
|
source: narratio.transcript.final_trimmed
|
||||||
required: true
|
required: true
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
@@ -285,7 +287,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
output_path: artifacts/session_recap.md
|
output_path: artifacts/session_recap.md
|
||||||
inputs:
|
inputs:
|
||||||
transcript:
|
transcript:
|
||||||
source: narratio.transcript.trimmed
|
source: narratio.transcript.final_trimmed
|
||||||
required: true
|
required: true
|
||||||
player_handout:
|
player_handout:
|
||||||
enabled: true
|
enabled: true
|
||||||
@@ -505,6 +507,42 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScriptoriumLegacyTranscriptSourcesRejected(t *testing.T) {
|
||||||
|
legacyTranscriptSources := []string{
|
||||||
|
"narratio.transcript." + "merged",
|
||||||
|
"narratio.transcript." + "full",
|
||||||
|
"narratio.transcript." + "trimmed",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, source := range legacyTranscriptSources {
|
||||||
|
t.Run(source, func(t *testing.T) {
|
||||||
|
pipelineYAML := testPipelineBaseYAML + `
|
||||||
|
scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
session_recap:
|
||||||
|
enabled: true
|
||||||
|
prompt_id: dnd.session_recap
|
||||||
|
output_path: artifacts/session_recap.md
|
||||||
|
inputs:
|
||||||
|
transcript:
|
||||||
|
source: ` + source + `
|
||||||
|
required: true
|
||||||
|
`
|
||||||
|
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
|
||||||
|
cfg, err := Load(pipelinePath, sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
err = Validate(cfg)
|
||||||
|
wantErr := `pipeline.scriptorium.artifacts.session_recap.inputs.transcript.source "` + source + `" is unsupported`
|
||||||
|
if err == nil || !strings.Contains(err.Error(), wantErr) {
|
||||||
|
t.Fatalf("Validate() error = %v, want to contain %q", err, wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const testPipelineBaseYAML = `workspace:
|
const testPipelineBaseYAML = `workspace:
|
||||||
root: /tmp/narratio
|
root: /tmp/narratio
|
||||||
whisperx:
|
whisperx:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user