Compare commits
3 Commits
1665359486
...
33f7ae8f2e
| Author | SHA1 | Date | |
|---|---|---|---|
| 33f7ae8f2e | |||
| d5a9ad38f8 | |||
| 6fbefb9867 |
73
README.md
73
README.md
@@ -34,6 +34,25 @@ Pipeline config lookup for CLI commands:
|
|||||||
- `/usr/local/etc/narratio/pipeline.yml`
|
- `/usr/local/etc/narratio/pipeline.yml`
|
||||||
- `/etc/narratio/pipeline.yml`
|
- `/etc/narratio/pipeline.yml`
|
||||||
|
|
||||||
|
Session config lookup for CLI commands:
|
||||||
|
|
||||||
|
- if `--session <path>` is provided, Narratio uses that path
|
||||||
|
- if `--session` is omitted, Narratio searches in this order:
|
||||||
|
- `./session.yml`
|
||||||
|
- `/usr/local/etc/narratio/session.yml`
|
||||||
|
- `/etc/narratio/session.yml`
|
||||||
|
|
||||||
|
Session template support:
|
||||||
|
|
||||||
|
- Narratio renders `session.yml` templates before strict YAML decode.
|
||||||
|
- `--session-id <value>` provides the `session_id` template variable.
|
||||||
|
- Supported placeholder forms:
|
||||||
|
- `{{session_id}}`
|
||||||
|
- `{{ session_id }}`
|
||||||
|
- unresolved template placeholders fail with a clear error.
|
||||||
|
- strict YAML validation still runs after rendering.
|
||||||
|
- concrete `session.yml` files without templates remain fully supported.
|
||||||
|
|
||||||
Optional secrets-from-files config:
|
Optional secrets-from-files config:
|
||||||
|
|
||||||
- `pipeline.secrets.env_dir` may point to a directory of secret files
|
- `pipeline.secrets.env_dir` may point to a directory of secret files
|
||||||
@@ -64,6 +83,7 @@ Implemented foundations:
|
|||||||
Current defaults:
|
Current defaults:
|
||||||
|
|
||||||
- `pipeline.storage.s3.root_prefix`: `dnd`
|
- `pipeline.storage.s3.root_prefix`: `dnd`
|
||||||
|
- `pipeline.workspace.cleanup_after_archive`: `false`
|
||||||
- `pipeline.spool.root`: `/var/spool/narratio`
|
- `pipeline.spool.root`: `/var/spool/narratio`
|
||||||
- `pipeline.spool.delete_audio_after_archive`: `false`
|
- `pipeline.spool.delete_audio_after_archive`: `false`
|
||||||
- `pipeline.archive.enabled`: `true`
|
- `pipeline.archive.enabled`: `true`
|
||||||
@@ -85,6 +105,12 @@ Current boundaries:
|
|||||||
- archive uploads `current/run_id.txt` last as the effective commit marker
|
- archive uploads `current/run_id.txt` last as the effective commit marker
|
||||||
- required missing promotions fail archive
|
- required missing promotions fail archive
|
||||||
- optional missing promotions are skipped and recorded
|
- optional missing promotions are skipped and recorded
|
||||||
|
- cleanup remains conservative and opt-in:
|
||||||
|
- `pipeline.spool.delete_audio_after_archive: true` removes only the run-scoped spool audio directory after successful archive commit
|
||||||
|
- `pipeline.workspace.cleanup_after_archive: true` removes only the run-scoped local workdir after successful archive commit
|
||||||
|
- cleanup executes only after all selected stages for the command invocation succeed
|
||||||
|
- cleanup does not run for failed, incomplete, skipped, or unarchived runs
|
||||||
|
- local development `audio_dir`/`audio_files` source inputs are never deleted by spool cleanup
|
||||||
|
|
||||||
S3 input details and current boundaries are documented in [docs/s3-audio-input.md](docs/s3-audio-input.md).
|
S3 input details and current boundaries are documented in [docs/s3-audio-input.md](docs/s3-audio-input.md).
|
||||||
|
|
||||||
@@ -136,21 +162,40 @@ Archive run-upload details and boundaries are documented in [docs/archive-storag
|
|||||||
- `transcripts/normalized.json`: Seriatim-normalized transcript from the normalize stage
|
- `transcripts/normalized.json`: Seriatim-normalized transcript from the normalize stage
|
||||||
- `transcripts/trimmed.json`: gameplay-only normalized polished transcript from trim stage
|
- `transcripts/trimmed.json`: gameplay-only normalized polished transcript from trim stage
|
||||||
|
|
||||||
|
## Seriatim Configuration
|
||||||
|
|
||||||
|
`pipeline.seriatim` configures the Seriatim subprocess adapter used by `merge`, `normalize`, and `trim`.
|
||||||
|
|
||||||
|
Minimal behavior:
|
||||||
|
|
||||||
|
- `pipeline.seriatim` may be omitted entirely.
|
||||||
|
- when omitted, Narratio defaults to:
|
||||||
|
- `binary: seriatim`
|
||||||
|
- `timeout: 10m`
|
||||||
|
- `output_schema: seriatim-intermediate`
|
||||||
|
- `coalesce_gap: 3.0`
|
||||||
|
- `report: true`
|
||||||
|
|
||||||
|
Optional overrides in `pipeline.seriatim` continue to work, including explicit binary paths and advanced `env` tuning values.
|
||||||
|
|
||||||
## Audita Configuration
|
## Audita Configuration
|
||||||
|
|
||||||
`pipeline.audita` configures the real Audita subprocess adapter used by `polish`.
|
`pipeline.audita` configures the real Audita subprocess adapter used by `polish`.
|
||||||
|
|
||||||
Required:
|
Minimal behavior:
|
||||||
|
|
||||||
- `binary`
|
- `pipeline.audita` may be omitted entirely.
|
||||||
- `timeout`
|
- when omitted, Narratio defaults to:
|
||||||
- `base_url`
|
- `binary: audita`
|
||||||
- `model`
|
- `timeout: 3h`
|
||||||
|
- `report: true`
|
||||||
|
|
||||||
Optional:
|
Optional:
|
||||||
|
|
||||||
- `llm_api_key_env` (when set, Narratio requires that env var and passes it to Audita as `AUDITA_LLM_API_KEY`)
|
- `llm_api_key_env` (when set, Narratio requires that env var and passes it to Audita as `AUDITA_LLM_API_KEY`)
|
||||||
- `modules` override list (when empty/omitted, Narratio does not pass `--modules`)
|
- `modules` override list (when empty/omitted, Narratio does not pass `--modules`)
|
||||||
|
- `base_url` (when omitted, Narratio does not pass `--base-url`; Audita runtime defaults/config may apply)
|
||||||
|
- `model` (when omitted, Narratio does not pass `--model`; Audita runtime defaults/config may apply)
|
||||||
- `transcript_description`
|
- `transcript_description`
|
||||||
- `config_path`
|
- `config_path`
|
||||||
- `output_schema` (`bare-segments` or `audita-v1`)
|
- `output_schema` (`bare-segments` or `audita-v1`)
|
||||||
@@ -161,7 +206,7 @@ Optional:
|
|||||||
- `validation_llm_concurrency` (> 0 when provided)
|
- `validation_llm_concurrency` (> 0 when provided)
|
||||||
- `report` (defaults to `true`)
|
- `report` (defaults to `true`)
|
||||||
|
|
||||||
Narratio passes only configured optional Audita flags. Omitted optional values are left to Audita runtime defaults.
|
Narratio passes only configured optional Audita flags. Omitted optional values are left to Audita runtime defaults/config.
|
||||||
|
|
||||||
## Normalize Configuration
|
## Normalize Configuration
|
||||||
|
|
||||||
@@ -256,7 +301,7 @@ Render-debug files are diagnostics and are not treated as canonical stage output
|
|||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
|
|
||||||
- `scriptorium.binary` is required when section is present
|
- `scriptorium.binary` defaults to `scriptorium` when section is present
|
||||||
- `scriptorium.config_path` is optional
|
- `scriptorium.config_path` is optional
|
||||||
- `scriptorium.timeout` defaults to `10m` when omitted
|
- `scriptorium.timeout` defaults to `10m` when omitted
|
||||||
- `scriptorium.render_debug` enables render diagnostics globally
|
- `scriptorium.render_debug` enables render diagnostics globally
|
||||||
@@ -344,7 +389,9 @@ Expected session output paths:
|
|||||||
Starter files:
|
Starter files:
|
||||||
|
|
||||||
- `examples/pipeline.minimal.yml`
|
- `examples/pipeline.minimal.yml`
|
||||||
|
- `examples/pipeline.audita-overrides.yml`
|
||||||
- `examples/session.minimal.yml`
|
- `examples/session.minimal.yml`
|
||||||
|
- `examples/session.template.yml`
|
||||||
- `examples/speakers.yml`
|
- `examples/speakers.yml`
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -363,6 +410,12 @@ go run ./cmd/narratio plan --session examples/session.minimal.yml
|
|||||||
|
|
||||||
Use `--config <path>` to override default pipeline lookup when needed.
|
Use `--config <path>` to override default pipeline lookup when needed.
|
||||||
|
|
||||||
|
Run with a discoverable session template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/narratio run --session-id 2026-04-04
|
||||||
|
```
|
||||||
|
|
||||||
Run full pipeline:
|
Run full pipeline:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -375,6 +428,12 @@ Run analyze only:
|
|||||||
go run ./cmd/narratio run-stage --config examples/pipeline.minimal.yml --session examples/session.minimal.yml analyze
|
go run ./cmd/narratio run-stage --config examples/pipeline.minimal.yml --session examples/session.minimal.yml analyze
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Resume with a template session ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/narratio resume --config examples/pipeline.minimal.yml --session examples/session.template.yml --session-id 2026-04-04
|
||||||
|
```
|
||||||
|
|
||||||
## Operational Note
|
## Operational Note
|
||||||
|
|
||||||
Checksum-based stale detection is not implemented yet.
|
Checksum-based stale detection is not implemented yet.
|
||||||
|
|||||||
@@ -115,6 +115,25 @@ CLI pipeline config path resolution:
|
|||||||
- `/usr/local/etc/narratio/pipeline.yml`
|
- `/usr/local/etc/narratio/pipeline.yml`
|
||||||
- `/etc/narratio/pipeline.yml`
|
- `/etc/narratio/pipeline.yml`
|
||||||
|
|
||||||
|
CLI session config path resolution:
|
||||||
|
|
||||||
|
- when `--session <path>` is provided, that path is used
|
||||||
|
- when `--session` is omitted, Narratio searches defaults in order:
|
||||||
|
- `./session.yml`
|
||||||
|
- `/usr/local/etc/narratio/session.yml`
|
||||||
|
- `/etc/narratio/session.yml`
|
||||||
|
|
||||||
|
Session template rendering:
|
||||||
|
|
||||||
|
- session templates are rendered before strict YAML decode
|
||||||
|
- `--session-id <value>` provides the `session_id` template variable
|
||||||
|
- supported placeholders:
|
||||||
|
- `{{session_id}}`
|
||||||
|
- `{{ session_id }}`
|
||||||
|
- unresolved placeholders fail clearly
|
||||||
|
- strict `KnownFields(true)` YAML validation still applies after rendering
|
||||||
|
- if rendered `session.session_id` conflicts with `--session-id`, load fails clearly
|
||||||
|
|
||||||
Optional pipeline secrets directory:
|
Optional pipeline secrets directory:
|
||||||
|
|
||||||
- `pipeline.secrets.env_dir` enables loading environment variables from local files before command execution
|
- `pipeline.secrets.env_dir` enables loading environment variables from local files before command execution
|
||||||
@@ -133,7 +152,8 @@ Storage and archive foundations:
|
|||||||
- `endpoint`
|
- `endpoint`
|
||||||
- `force_path_style` (default `false`)
|
- `force_path_style` (default `false`)
|
||||||
- `pipeline.spool.root` defaults to `/var/spool/narratio`
|
- `pipeline.spool.root` defaults to `/var/spool/narratio`
|
||||||
- `pipeline.spool.delete_audio_after_archive` defaults to `false` (cleanup behavior not implemented yet)
|
- `pipeline.workspace.cleanup_after_archive` defaults to `false`
|
||||||
|
- `pipeline.spool.delete_audio_after_archive` defaults to `false`
|
||||||
- `pipeline.archive` is optional and defaults to:
|
- `pipeline.archive` is optional and defaults to:
|
||||||
- `enabled: true`
|
- `enabled: true`
|
||||||
- `upload_run: true`
|
- `upload_run: true`
|
||||||
@@ -211,6 +231,13 @@ Archive publishing behavior (implemented):
|
|||||||
- `current/run_id.txt` is the effective commit marker
|
- `current/run_id.txt` is the effective commit marker
|
||||||
- if promotion or current-manifest upload fails, archive returns failure and does not write `current/run_id.txt`
|
- if promotion or current-manifest upload fails, archive returns failure and does not write `current/run_id.txt`
|
||||||
- failed/incomplete runs remain local and are not uploaded
|
- failed/incomplete runs remain local and are not uploaded
|
||||||
|
- post-archive local cleanup (implemented, opt-in):
|
||||||
|
- cleanup runs only after archive succeeded and wrote `current/run_id.txt`
|
||||||
|
- cleanup is executed after all selected stages in the command invocation succeed (for example, a later `notify` failure leaves local files intact)
|
||||||
|
- `pipeline.spool.delete_audio_after_archive: true` removes only `{spool.root}/{campaign}/{session_id}/{run_id}/audio/`
|
||||||
|
- `pipeline.workspace.cleanup_after_archive: true` removes only `{workspace.root}/work/{campaign}/{session_id}/{run_id}/`
|
||||||
|
- cleanup does not run when archive is skipped/disabled/fails or when run upload is disabled
|
||||||
|
- local development `audio_dir`/`audio_files` inputs are never removed by spool cleanup
|
||||||
|
|
||||||
`pipeline.scriptorium` is optional. Existing pipelines without Scriptorium continue to work.
|
`pipeline.scriptorium` is optional. Existing pipelines without Scriptorium continue to work.
|
||||||
|
|
||||||
@@ -220,17 +247,18 @@ Archive publishing behavior (implemented):
|
|||||||
|
|
||||||
`pipeline.audita` drives the real Audita subprocess adapter for the `polish` stage.
|
`pipeline.audita` drives the real Audita subprocess adapter for the `polish` stage.
|
||||||
|
|
||||||
Audita required fields:
|
Audita defaulted fields:
|
||||||
|
|
||||||
- `binary`
|
- `binary` defaults to `audita`
|
||||||
- `timeout`
|
- `timeout` defaults to `3h`
|
||||||
- `base_url`
|
- `report` defaults to `true`
|
||||||
- `model`
|
|
||||||
|
|
||||||
Audita optional fields:
|
Audita optional fields:
|
||||||
|
|
||||||
- `llm_api_key_env` (enforced only when configured)
|
- `llm_api_key_env` (enforced only when configured)
|
||||||
- `modules` override list (when omitted/empty, Narratio does not pass `--modules`)
|
- `modules` override list (when omitted/empty, Narratio does not pass `--modules`)
|
||||||
|
- `base_url` (when omitted, Narratio does not pass `--base-url`)
|
||||||
|
- `model` (when omitted, Narratio does not pass `--model`)
|
||||||
- `transcript_description`
|
- `transcript_description`
|
||||||
- `config_path`
|
- `config_path`
|
||||||
- `output_schema` (`bare-segments` or `audita-v1`)
|
- `output_schema` (`bare-segments` or `audita-v1`)
|
||||||
@@ -239,9 +267,18 @@ Audita optional fields:
|
|||||||
- `proposal_llm_concurrency` (> 0 when provided)
|
- `proposal_llm_concurrency` (> 0 when provided)
|
||||||
- `validation_model`
|
- `validation_model`
|
||||||
- `validation_llm_concurrency` (> 0 when provided)
|
- `validation_llm_concurrency` (> 0 when provided)
|
||||||
- `report` (default `true`)
|
- `report` override
|
||||||
|
|
||||||
Narratio passes only configured optional Audita flags; omitted optional values defer to Audita defaults.
|
Narratio passes only configured optional Audita flags; omitted optional values defer to Audita runtime defaults/config.
|
||||||
|
|
||||||
|
Seriatim defaults:
|
||||||
|
|
||||||
|
- `pipeline.seriatim` may be omitted
|
||||||
|
- `binary` defaults to `seriatim`
|
||||||
|
- `timeout` defaults to `10m`
|
||||||
|
- `output_schema` defaults to `seriatim-intermediate`
|
||||||
|
- `coalesce_gap` defaults to `3.0`
|
||||||
|
- `report` defaults to `true`
|
||||||
|
|
||||||
When `pipeline.normalize` is omitted, defaults are applied:
|
When `pipeline.normalize` is omitted, defaults are applied:
|
||||||
|
|
||||||
@@ -275,7 +312,7 @@ When `pipeline.trim.enabled: true`:
|
|||||||
|
|
||||||
When `pipeline.scriptorium` is present:
|
When `pipeline.scriptorium` is present:
|
||||||
|
|
||||||
- `binary` is required and non-empty
|
- `binary` defaults to `scriptorium` when omitted
|
||||||
- `config_path` is optional; when provided it must be non-empty
|
- `config_path` is optional; when provided it must be non-empty
|
||||||
- `timeout` is optional; when provided it must parse as a Go duration
|
- `timeout` is optional; when provided it must parse as a Go duration
|
||||||
- default `timeout` is `10m`
|
- default `timeout` is `10m`
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ Implemented:
|
|||||||
- archive uploads configured promoted outputs to session-level keys.
|
- archive uploads configured promoted outputs to session-level keys.
|
||||||
- archive uploads `current/manifest.json`.
|
- archive uploads `current/manifest.json`.
|
||||||
- archive uploads `current/run_id.txt` last as the effective commit marker.
|
- archive uploads `current/run_id.txt` last as the effective commit marker.
|
||||||
|
- optional post-archive local cleanup:
|
||||||
|
- `pipeline.spool.delete_audio_after_archive: true` removes only the run-scoped spool audio directory
|
||||||
|
- `pipeline.workspace.cleanup_after_archive: true` removes only the run-scoped local workdir
|
||||||
- tests use fake storage and do not require live S3.
|
- tests use fake storage and do not require live S3.
|
||||||
|
|
||||||
Future work:
|
Future work:
|
||||||
|
|
||||||
- spool audio cleanup/deletion behavior
|
|
||||||
- `notify` stage behavior
|
- `notify` stage behavior
|
||||||
- stale detection
|
- stale detection
|
||||||
- optional future source-audio upload mode
|
- optional future source-audio upload mode
|
||||||
@@ -89,6 +91,7 @@ Archive writes:
|
|||||||
Writing `current/run_id.txt` last makes it the effective commit marker for published session state.
|
Writing `current/run_id.txt` last makes it the effective commit marker for published session state.
|
||||||
|
|
||||||
If any required run upload, promotion upload, or current-manifest upload fails, archive returns failure and does not write `current/run_id.txt`.
|
If any required run upload, promotion upload, or current-manifest upload fails, archive returns failure and does not write `current/run_id.txt`.
|
||||||
|
Cleanup runs only after this commit-marker write has succeeded.
|
||||||
|
|
||||||
## Audio Upload Policy
|
## Audio Upload Policy
|
||||||
|
|
||||||
@@ -99,6 +102,7 @@ Original audio is expected at the session-level audio prefix and is not duplicat
|
|||||||
|
|
||||||
- `archive.enabled: false` skips archive cleanly.
|
- `archive.enabled: false` skips archive cleanly.
|
||||||
- `archive.upload_run: false` skips run upload cleanly.
|
- `archive.upload_run: false` skips run upload cleanly.
|
||||||
|
- both skip cases also skip post-archive local cleanup.
|
||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ Implemented in repository:
|
|||||||
- campaign/session/run local work and spool path helpers
|
- campaign/session/run local work and spool path helpers
|
||||||
- manifest run/path identity fields
|
- manifest run/path identity fields
|
||||||
- examples and tests for the above foundations
|
- examples and tests for the above foundations
|
||||||
|
- session template operator UX:
|
||||||
|
- default session config discovery (`./session.yml`, `/usr/local/etc/narratio/session.yml`, `/etc/narratio/session.yml`)
|
||||||
|
- `--session-id` template injection for `session_id`
|
||||||
|
- session template rendering before strict YAML decode
|
||||||
|
- unresolved template placeholders and `session_id` mismatches fail clearly
|
||||||
- remote storage backend layer:
|
- remote storage backend layer:
|
||||||
- object-store abstraction with `List`, `Download`, `Upload`, and `Exists`
|
- object-store abstraction with `List`, `Download`, `Upload`, and `Exists`
|
||||||
- fake storage backend for deterministic, no-network testing
|
- fake storage backend for deterministic, no-network testing
|
||||||
@@ -115,10 +120,13 @@ Implemented in repository:
|
|||||||
- `current/run_id.txt` is uploaded last as the effective commit marker
|
- `current/run_id.txt` is uploaded last as the effective commit marker
|
||||||
- current pointer content is `{run_id}` plus trailing newline
|
- current pointer content is `{run_id}` plus trailing newline
|
||||||
- if promotion/current manifest upload fails, current pointer is not written
|
- if promotion/current manifest upload fails, current pointer is not written
|
||||||
|
- post-archive local cleanup behavior:
|
||||||
|
- `pipeline.spool.delete_audio_after_archive` removes run-scoped spool audio only after successful archive commit
|
||||||
|
- `pipeline.workspace.cleanup_after_archive` removes run-scoped workdir only after successful archive commit
|
||||||
|
- cleanup is skipped for failed/incomplete/skipped/unarchived runs
|
||||||
|
|
||||||
Not implemented yet:
|
Not implemented yet:
|
||||||
|
|
||||||
- spool audio cleanup / deletion behavior
|
|
||||||
- `notify` stage behavior
|
- `notify` stage behavior
|
||||||
- generic stale detection based on input/config checksums
|
- generic stale detection based on input/config checksums
|
||||||
- optional future mode for uploading source audio from local workspace/spool
|
- optional future mode for uploading source audio from local workspace/spool
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ Implemented:
|
|||||||
|
|
||||||
Not implemented:
|
Not implemented:
|
||||||
|
|
||||||
- spool cleanup/deletion behavior
|
|
||||||
- uploads of failed runs
|
- uploads of failed runs
|
||||||
|
|
||||||
## Required Configuration
|
## Required Configuration
|
||||||
|
|||||||
26
examples/pipeline.audita-overrides.yml
Normal file
26
examples/pipeline.audita-overrides.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
workspace:
|
||||||
|
root: ./tmp/narratio-workspace
|
||||||
|
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: "https://transcription.example.com/transcribe"
|
||||||
|
|
||||||
|
seriatim:
|
||||||
|
binary: "seriatim"
|
||||||
|
timeout: "10m"
|
||||||
|
output_schema: "seriatim-intermediate"
|
||||||
|
coalesce_gap: 3.0
|
||||||
|
|
||||||
|
audita:
|
||||||
|
binary: "audita"
|
||||||
|
timeout: "3h"
|
||||||
|
base_url: "https://openrouter.ai/api/v1"
|
||||||
|
model: "openrouter/google/gemma-4-31b-it"
|
||||||
|
llm_api_key_env: "AUDITA_LLM_API_KEY"
|
||||||
|
modules: ["glossary", "homophones", "spoken_word", "grammar"]
|
||||||
|
output_schema: "audita-v1"
|
||||||
|
work_dir_retention: "auto"
|
||||||
|
total_llm_concurrency: 2
|
||||||
|
proposal_llm_concurrency: 1
|
||||||
|
validation_model: "openrouter/google/gemma-4-31b-it"
|
||||||
|
validation_llm_concurrency: 1
|
||||||
|
report: true
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
workspace:
|
workspace:
|
||||||
root: ./tmp/narratio-workspace
|
root: ./tmp/narratio-workspace
|
||||||
|
cleanup_after_archive: false
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
backend: local
|
backend: s3
|
||||||
s3:
|
s3:
|
||||||
bucket: "my-dnd-archive"
|
bucket: "my-dnd-archive"
|
||||||
root_prefix: "dnd"
|
root_prefix: "dnd"
|
||||||
region: "us-east-1"
|
region: "us-east-1"
|
||||||
endpoint: ""
|
|
||||||
force_path_style: false
|
|
||||||
|
|
||||||
spool:
|
spool:
|
||||||
root: "/var/spool/narratio"
|
root: "/var/spool/narratio"
|
||||||
@@ -17,106 +16,33 @@ spool:
|
|||||||
archive:
|
archive:
|
||||||
enabled: true
|
enabled: true
|
||||||
upload_run: true
|
upload_run: true
|
||||||
promote_artifacts:
|
|
||||||
- from: "transcripts/trimmed.json"
|
|
||||||
to: "transcripts/trimmed.json"
|
|
||||||
required: true
|
|
||||||
- from: "artifacts/session_recap.md"
|
|
||||||
to: "artifacts/session_recap.md"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
secrets:
|
|
||||||
# Optional: load environment variables from files in this directory.
|
|
||||||
# File name = env var name; file contents = env var value.
|
|
||||||
env_dir: /var/local/narratio/secrets
|
|
||||||
|
|
||||||
whisperx:
|
whisperx:
|
||||||
transcribe_url: "https://transcription.example.com/transcribe"
|
transcribe_url: "https://transcription.example.com/transcribe"
|
||||||
language: "en"
|
|
||||||
timeout: "30m"
|
|
||||||
retries: 3
|
|
||||||
retry_delay: "2s"
|
|
||||||
concurrency: 2
|
|
||||||
|
|
||||||
seriatim:
|
# Optional. When omitted entirely, Narratio defaults to seriatim binary + runtime defaults.
|
||||||
binary: "seriatim"
|
seriatim: {}
|
||||||
timeout: "10m"
|
|
||||||
output_schema: "seriatim-intermediate"
|
|
||||||
coalesce_gap: 3.0
|
|
||||||
report: true
|
|
||||||
env:
|
|
||||||
overlap_word_run_gap: 1.0
|
|
||||||
overlap_word_run_reorder_window: 1.0
|
|
||||||
backchannel_max_duration: 2.0
|
|
||||||
filler_max_duration: 1.25
|
|
||||||
|
|
||||||
|
# Optional runtime overrides. Model/provider can be owned by Audita runtime config.
|
||||||
audita:
|
audita:
|
||||||
binary: "audita"
|
config_path: "/usr/local/etc/audita/config.yml"
|
||||||
timeout: "3h"
|
|
||||||
llm_api_key_env: "AUDITA_LLM_API_KEY"
|
llm_api_key_env: "AUDITA_LLM_API_KEY"
|
||||||
# Optional: pass only when overriding Audita's default module sequence.
|
|
||||||
modules: []
|
|
||||||
base_url: "https://openrouter.ai/api/v1"
|
|
||||||
model: "openrouter/google/gemma-4-31b-it"
|
|
||||||
transcript_description: ""
|
|
||||||
config_path: ""
|
|
||||||
output_schema: "audita-v1"
|
|
||||||
work_dir_retention: "auto"
|
|
||||||
total_llm_concurrency: 1
|
|
||||||
proposal_llm_concurrency: 1
|
|
||||||
validation_model: ""
|
|
||||||
validation_llm_concurrency: 1
|
|
||||||
report: true
|
|
||||||
|
|
||||||
normalize:
|
|
||||||
# Session-workdir-relative when not absolute.
|
|
||||||
output_path: "transcripts/normalized.json"
|
|
||||||
output_schema: "seriatim-intermediate"
|
|
||||||
report: true
|
|
||||||
|
|
||||||
trim:
|
|
||||||
enabled: true
|
|
||||||
# Session-workdir-relative when not absolute.
|
|
||||||
output_path: "transcripts/trimmed.json"
|
|
||||||
bounds:
|
|
||||||
prompt_id: "dnd_session.bounds"
|
|
||||||
# Empty means use prompt default profile.
|
|
||||||
profile_id: ""
|
|
||||||
transcript_input_name: "transcript"
|
|
||||||
output_path: "artifacts/session_bounds.json"
|
|
||||||
timeout: "10m"
|
|
||||||
render_debug: false
|
|
||||||
render_output_path: "artifacts/session_bounds.render.json"
|
|
||||||
seriatim:
|
|
||||||
report: false
|
|
||||||
|
|
||||||
|
# Optional Scriptorium integration for analyze artifacts.
|
||||||
scriptorium:
|
scriptorium:
|
||||||
binary: "scriptorium"
|
config_path: "/usr/local/etc/scriptorium/config.yml"
|
||||||
config_path: "/etc/scriptorium/config.yml"
|
|
||||||
timeout: "10m"
|
|
||||||
render_debug: false
|
|
||||||
artifacts:
|
artifacts:
|
||||||
session_recap:
|
session_recap:
|
||||||
enabled: true
|
enabled: true
|
||||||
prompt_id: "dnd.session_recap"
|
prompt_id: "dnd.session_recap"
|
||||||
profile_id: "local-quality"
|
|
||||||
output_path: "artifacts/session_recap.md"
|
output_path: "artifacts/session_recap.md"
|
||||||
timeout: "10m"
|
|
||||||
# Optional per-artifact override of global scriptorium.render_debug.
|
|
||||||
# render_debug: true
|
|
||||||
inputs:
|
inputs:
|
||||||
transcript:
|
transcript:
|
||||||
# Available transcript sources:
|
|
||||||
# - trimmed_transcript (recommended for session_recap)
|
|
||||||
# - normalized_transcript (recommended for future full-session analysis)
|
|
||||||
# - processed_transcript (raw Audita-polished output)
|
|
||||||
source: "trimmed_transcript"
|
source: "trimmed_transcript"
|
||||||
required: true
|
required: true
|
||||||
previous_recap:
|
previous_recap:
|
||||||
source: "previous_session_artifact"
|
source: "previous_session_artifact"
|
||||||
artifact: "session_recap"
|
artifact: "session_recap"
|
||||||
# Optional: set when previous recap is available.
|
|
||||||
path: ""
|
|
||||||
required: false
|
required: false
|
||||||
vars:
|
vars:
|
||||||
session_id: true
|
session_id: true
|
||||||
@@ -124,11 +50,3 @@ scriptorium:
|
|||||||
campaign_name: true
|
campaign_name: true
|
||||||
previous_session_id: true
|
previous_session_id: true
|
||||||
output_kind: "session_recap"
|
output_kind: "session_recap"
|
||||||
|
|
||||||
analyzer:
|
|
||||||
timeout: 20m
|
|
||||||
artifacts:
|
|
||||||
output_dir: artifacts
|
|
||||||
|
|
||||||
notification:
|
|
||||||
timeout: 10s
|
|
||||||
|
|||||||
12
examples/session.template.yml
Normal file
12
examples/session.template.yml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
session_id: "{{ session_id }}"
|
||||||
|
campaign: sample-campaign
|
||||||
|
date: ""
|
||||||
|
title: ""
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
# Optional S3 input alternative. Do not configure with audio_dir/audio_files.
|
||||||
|
# audio_s3:
|
||||||
|
# prefix: "audio/{{ session_id }}/"
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
@@ -108,18 +108,14 @@ func NewSubprocessRunner(cfg SubprocessRunnerConfig) (*SubprocessRunner, error)
|
|||||||
return nil, fmt.Errorf("audita module at index %d is empty", i)
|
return nil, fmt.Errorf("audita module at index %d is empty", i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.BaseURL) == "" {
|
if strings.TrimSpace(cfg.BaseURL) != "" {
|
||||||
return nil, fmt.Errorf("audita base url is required")
|
u, err := url.Parse(cfg.BaseURL)
|
||||||
}
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
u, err := url.Parse(cfg.BaseURL)
|
if err != nil {
|
||||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
return nil, fmt.Errorf("audita base url %q is invalid: %w", cfg.BaseURL, err)
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("audita base url %q is invalid: %w", cfg.BaseURL, err)
|
return nil, fmt.Errorf("audita base url %q is invalid", cfg.BaseURL)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("audita base url %q is invalid", cfg.BaseURL)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cfg.Model) == "" {
|
|
||||||
return nil, fmt.Errorf("audita model is required")
|
|
||||||
}
|
}
|
||||||
if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 {
|
if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 {
|
||||||
return nil, fmt.Errorf("audita total llm concurrency must be > 0 when provided")
|
return nil, fmt.Errorf("audita total llm concurrency must be > 0 when provided")
|
||||||
@@ -307,10 +303,14 @@ func (r *SubprocessRunner) buildArgs(req PolishRequest, modules []string) []stri
|
|||||||
req.MergedTranscriptPath,
|
req.MergedTranscriptPath,
|
||||||
"--glossary", req.GlossaryPath,
|
"--glossary", req.GlossaryPath,
|
||||||
"--output", req.OutputProcessedPath,
|
"--output", req.OutputProcessedPath,
|
||||||
"--base-url", r.baseURL,
|
|
||||||
"--model", r.model,
|
|
||||||
"--work-dir", req.WorkDir,
|
"--work-dir", req.WorkDir,
|
||||||
}
|
}
|
||||||
|
if r.baseURL != "" {
|
||||||
|
args = append(args, "--base-url", r.baseURL)
|
||||||
|
}
|
||||||
|
if r.model != "" {
|
||||||
|
args = append(args, "--model", r.model)
|
||||||
|
}
|
||||||
if len(modules) > 0 {
|
if len(modules) > 0 {
|
||||||
args = append(args, "--modules", strings.Join(modules, ","))
|
args = append(args, "--modules", strings.Join(modules, ","))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,9 +99,9 @@ func TestSubprocessRunnerSuccessArgsEnvAndValidation(t *testing.T) {
|
|||||||
"process", req.MergedTranscriptPath,
|
"process", req.MergedTranscriptPath,
|
||||||
"--glossary", req.GlossaryPath,
|
"--glossary", req.GlossaryPath,
|
||||||
"--output", req.OutputProcessedPath,
|
"--output", req.OutputProcessedPath,
|
||||||
|
"--work-dir", req.WorkDir,
|
||||||
"--base-url", "https://openrouter.ai/api/v1",
|
"--base-url", "https://openrouter.ai/api/v1",
|
||||||
"--model", "openrouter/google/gemma-4-31b-it",
|
"--model", "openrouter/google/gemma-4-31b-it",
|
||||||
"--work-dir", req.WorkDir,
|
|
||||||
"--modules", "glossary,homophones,glossary",
|
"--modules", "glossary,homophones,glossary",
|
||||||
"--report-json", req.ReportPath,
|
"--report-json", req.ReportPath,
|
||||||
"--transcript-description", "Campaign Session 42",
|
"--transcript-description", "Campaign Session 42",
|
||||||
@@ -248,6 +248,36 @@ func TestSubprocessRunnerOmitsModulesFlagWhenNotConfigured(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSubprocessRunnerOmitsBaseURLAndModelFlagsWhenNotConfigured(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
}
|
||||||
|
t.Setenv("GO_WANT_AUDITA_HELPER", "1")
|
||||||
|
t.Setenv("AUDITA_HELPER_MODE", "success")
|
||||||
|
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||||
|
t.Setenv("AUDITA_HELPER_RECORD_PATH", recordPath)
|
||||||
|
|
||||||
|
runner := mustAuditaRunner(t, SubprocessRunnerConfig{
|
||||||
|
Binary: writeAuditaHelperWrapper(t),
|
||||||
|
Timeout: mustParseAuditaDuration(t, "2s"),
|
||||||
|
LLMAPIKeyEnv: "",
|
||||||
|
Report: false,
|
||||||
|
})
|
||||||
|
req := auditaReqForTest(t, false)
|
||||||
|
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
rec := readAuditaHelperRecord(t, recordPath)
|
||||||
|
for i := 0; i < len(rec.Args); i++ {
|
||||||
|
if rec.Args[i] == "--base-url" {
|
||||||
|
t.Fatalf("args contained --base-url unexpectedly: %#v", rec.Args)
|
||||||
|
}
|
||||||
|
if rec.Args[i] == "--model" {
|
||||||
|
t.Fatalf("args contained --model unexpectedly: %#v", rec.Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
|
func TestSubprocessRunnerSubprocessFailure(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
t.Skip("helper wrapper script uses /bin/sh")
|
t.Skip("helper wrapper script uses /bin/sh")
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
|||||||
args []string
|
args []string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "run missing flags", args: []string{"run"}, want: "run: --session is required"},
|
{name: "run missing flags", args: []string{"run"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||||
{name: "plan missing flags", args: []string{"plan"}, want: "plan: --session is required"},
|
{name: "plan missing flags", args: []string{"plan"}, want: "plan: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||||
{name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"},
|
{name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"},
|
||||||
{name: "resume missing flags", args: []string{"resume"}, want: "resume: --session is required"},
|
{name: "resume missing flags", args: []string{"resume"}, want: "resume: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||||
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected exactly one stage name"},
|
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected exactly one stage name"},
|
||||||
{name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: --session is required"},
|
{name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||||
{name: "run missing config uses defaults", args: []string{"run", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
{name: "run missing config uses defaults", args: []string{"run", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
|
var sessionID 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(&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.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 {
|
||||||
@@ -32,16 +34,18 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if fs.NArg() != 0 {
|
if fs.NArg() != 0 {
|
||||||
return fmt.Errorf("plan: unexpected positional arguments")
|
return fmt.Errorf("plan: unexpected positional arguments")
|
||||||
}
|
}
|
||||||
if sessionPath == "" {
|
|
||||||
return fmt.Errorf("plan: --session is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
|
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||||
|
SessionID: sessionID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
208
internal/app/post_archive_cleanup.go
Normal file
208
internal/app/post_archive_cleanup.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runPostArchiveCleanup(ctx context.Context, env *Env, manifestPath string, m *manifest.Manifest, executed []string) error {
|
||||||
|
if env == nil || env.Config == nil || env.Config.Pipeline == nil || m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
spoolRequested := env.Config.Pipeline.Spool.DeleteAudioAfterArchive
|
||||||
|
workRequested := env.Config.Pipeline.Workspace.CleanupAfterArchive
|
||||||
|
if !spoolRequested && !workRequested {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sr := archiveStageRecordForCleanup(m, executed)
|
||||||
|
if sr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if sr.Metadata == nil {
|
||||||
|
sr.Metadata = map[string]any{}
|
||||||
|
}
|
||||||
|
sr.Metadata["spool_cleanup_requested"] = spoolRequested
|
||||||
|
sr.Metadata["workdir_cleanup_requested"] = workRequested
|
||||||
|
|
||||||
|
eligible, reason := archiveCleanupEligible(env.Config, sr)
|
||||||
|
if !eligible {
|
||||||
|
sr.Metadata["cleanup_skipped"] = true
|
||||||
|
sr.Metadata["cleanup_skipped_reason"] = reason
|
||||||
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
|
return fmt.Errorf("save manifest cleanup skip metadata %q: %w", manifestPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
spoolDir := strings.TrimSpace(m.LocalSpoolDir)
|
||||||
|
if spoolDir == "" {
|
||||||
|
spoolDir = artifacts.SessionSpoolAudioDir(
|
||||||
|
env.Config.Pipeline.Spool.Root,
|
||||||
|
strings.TrimSpace(env.Config.Session.Campaign),
|
||||||
|
strings.TrimSpace(env.Config.Session.SessionID),
|
||||||
|
strings.TrimSpace(m.RunID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
workDir := strings.TrimSpace(m.LocalWorkDir)
|
||||||
|
if workDir == "" {
|
||||||
|
workDir = artifacts.SessionRunWorkDir(
|
||||||
|
env.Config.Pipeline.Workspace.Root,
|
||||||
|
strings.TrimSpace(env.Config.Session.Campaign),
|
||||||
|
strings.TrimSpace(env.Config.Session.SessionID),
|
||||||
|
strings.TrimSpace(m.RunID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if spoolRequested {
|
||||||
|
if err := removeRunScopedDir(strings.TrimSpace(env.Config.Pipeline.Spool.Root), spoolDir, "pipeline.spool.delete_audio_after_archive"); err != nil {
|
||||||
|
sr.Metadata["cleanup_failed"] = true
|
||||||
|
sr.Metadata["cleanup_failed_policy"] = "pipeline.spool.delete_audio_after_archive"
|
||||||
|
sr.Metadata["cleanup_failed_path"] = spoolDir
|
||||||
|
_ = env.ManifestStore.Save(ctx, manifestPath, m)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sr.Metadata["spool_cleanup_deleted"] = filepath.Clean(spoolDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !workRequested {
|
||||||
|
sr.Metadata["cleanup_completed"] = true
|
||||||
|
sr.Metadata["cleanup_skipped"] = false
|
||||||
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
|
return fmt.Errorf("save manifest cleanup metadata %q: %w", manifestPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := removeRunScopedDir(strings.TrimSpace(env.Config.Pipeline.Workspace.Root), workDir, "pipeline.workspace.cleanup_after_archive"); err != nil {
|
||||||
|
sr.Metadata["cleanup_failed"] = true
|
||||||
|
sr.Metadata["cleanup_failed_policy"] = "pipeline.workspace.cleanup_after_archive"
|
||||||
|
sr.Metadata["cleanup_failed_path"] = workDir
|
||||||
|
_ = env.ManifestStore.Save(ctx, manifestPath, m)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sr.Metadata["workdir_cleanup_deleted"] = filepath.Clean(workDir)
|
||||||
|
sr.Metadata["cleanup_completed"] = true
|
||||||
|
sr.Metadata["cleanup_skipped"] = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveStageRecordForCleanup(m *manifest.Manifest, executed []string) *manifest.StageRecord {
|
||||||
|
if m == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
archiveRan := false
|
||||||
|
for _, name := range executed {
|
||||||
|
if name == "archive" {
|
||||||
|
archiveRan = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !archiveRan {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sr := m.Stages["archive"]
|
||||||
|
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sr
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveCleanupEligible(cfg *config.Config, sr *manifest.StageRecord) (bool, string) {
|
||||||
|
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Archive == nil {
|
||||||
|
return false, "archive configuration is missing"
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
if cfg.Pipeline.Archive.Enabled != nil {
|
||||||
|
enabled = *cfg.Pipeline.Archive.Enabled
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
return false, "archive.enabled is false"
|
||||||
|
}
|
||||||
|
uploadRun := true
|
||||||
|
if cfg.Pipeline.Archive.UploadRun != nil {
|
||||||
|
uploadRun = *cfg.Pipeline.Archive.UploadRun
|
||||||
|
}
|
||||||
|
if !uploadRun {
|
||||||
|
return false, "archive.upload_run is false"
|
||||||
|
}
|
||||||
|
if sr == nil || sr.Metadata == nil {
|
||||||
|
return false, "archive metadata is missing"
|
||||||
|
}
|
||||||
|
if skipped, _ := sr.Metadata["skipped"].(bool); skipped {
|
||||||
|
return false, "archive stage was skipped"
|
||||||
|
}
|
||||||
|
if uploaded, _ := sr.Metadata["uploaded"].(bool); !uploaded {
|
||||||
|
return false, "archive did not upload run record"
|
||||||
|
}
|
||||||
|
if pointer, _ := sr.Metadata["current_pointer_written"].(bool); !pointer {
|
||||||
|
return false, "archive did not write current pointer"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(asString(sr.Metadata["current_run_id_key"])) == "" {
|
||||||
|
return false, "archive current run pointer key is missing"
|
||||||
|
}
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeRunScopedDir(root, target, policy string) error {
|
||||||
|
cleanRoot := strings.TrimSpace(root)
|
||||||
|
cleanTarget := strings.TrimSpace(target)
|
||||||
|
if cleanRoot == "" {
|
||||||
|
return fmt.Errorf("cleanup policy %s: root path is required", policy)
|
||||||
|
}
|
||||||
|
if cleanTarget == "" {
|
||||||
|
return fmt.Errorf("cleanup policy %s: target path is required", policy)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootAbs, err := filepath.Abs(cleanRoot)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cleanup policy %s: resolve root %q: %w", policy, cleanRoot, err)
|
||||||
|
}
|
||||||
|
targetAbs, err := filepath.Abs(cleanTarget)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cleanup policy %s: resolve target %q: %w", policy, cleanTarget, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rel, err := filepath.Rel(rootAbs, targetAbs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cleanup policy %s: relative path from %q to %q: %w", policy, rootAbs, targetAbs, err)
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return fmt.Errorf("cleanup policy %s: refusing to delete root directory %q", policy, rootAbs)
|
||||||
|
}
|
||||||
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
|
return fmt.Errorf("cleanup policy %s: refusing to delete path outside root: root=%q target=%q", policy, rootAbs, targetAbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Lstat(targetAbs)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cleanup policy %s: stat target %q: %w", policy, targetAbs, err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return fmt.Errorf("cleanup policy %s: refusing to delete symlink path %q", policy, targetAbs)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return fmt.Errorf("cleanup policy %s: target %q is not a directory", policy, targetAbs)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(targetAbs); err != nil {
|
||||||
|
return fmt.Errorf("cleanup policy %s: remove %q: %w", policy, targetAbs, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func asString(v any) string {
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
396
internal/app/post_archive_cleanup_test.go
Normal file
396
internal/app/post_archive_cleanup_test.go
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type archiveSuccessStage struct {
|
||||||
|
metadata map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (archiveSuccessStage) Name() string { return "archive" }
|
||||||
|
func (archiveSuccessStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||||
|
func (s archiveSuccessStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
md := map[string]any{
|
||||||
|
"stage": "archive",
|
||||||
|
"uploaded": true,
|
||||||
|
"current_pointer_written": true,
|
||||||
|
"current_run_id_key": "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/run_id.txt",
|
||||||
|
}
|
||||||
|
for k, v := range s.metadata {
|
||||||
|
md[k] = v
|
||||||
|
}
|
||||||
|
return &stage.StageResult{Metadata: md}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type notifyFailStage struct{}
|
||||||
|
|
||||||
|
func (notifyFailStage) Name() string { return "notify" }
|
||||||
|
func (notifyFailStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||||
|
func (notifyFailStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
return nil, errors.New("notify failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupDisabledKeepsLocalDirs(t *testing.T) {
|
||||||
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = false
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = false
|
||||||
|
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
assertExists(t, seed.localSourceAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupSpoolOnly(t *testing.T) {
|
||||||
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = false
|
||||||
|
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertMissing(t, seed.spoolAudioDir)
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
assertExists(t, seed.localSourceAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupWorkdirOnly(t *testing.T) {
|
||||||
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = false
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
||||||
|
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExists(t, cfg.Pipeline.Workspace.Root)
|
||||||
|
assertExists(t, seed.otherRunDir)
|
||||||
|
assertMissing(t, seed.runWorkDir)
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupBothPolicies(t *testing.T) {
|
||||||
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
||||||
|
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertMissing(t, seed.spoolAudioDir)
|
||||||
|
assertMissing(t, seed.runWorkDir)
|
||||||
|
assertExists(t, seed.otherRunDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupNotRunWhenArchiveFails(t *testing.T) {
|
||||||
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
||||||
|
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{failingStage{name: "archive", err: errors.New("archive failed")}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "stage \"archive\" failed") {
|
||||||
|
t.Fatalf("executeStages() error = %v, want archive failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupNotRunWhenArchiveSkipped(t *testing.T) {
|
||||||
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupNotRunWhenCurrentPointerMissing(t *testing.T) {
|
||||||
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = 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 {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupNotRunWhenArchiveUploadDisabled(t *testing.T) {
|
||||||
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
||||||
|
cfg.Pipeline.Archive.UploadRun = boolPtr(false)
|
||||||
|
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupWaitsUntilAllStagesSucceed(t *testing.T) {
|
||||||
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
||||||
|
|
||||||
|
_, 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") {
|
||||||
|
t.Fatalf("executeStages() error = %v, want notify failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupFailsOnUnsafePath(t *testing.T) {
|
||||||
|
cfg, _ := cleanupFixtureConfig(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = false
|
||||||
|
|
||||||
|
manifestPath := manifestPathFor(cfg)
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
m, err := store.Load(context.Background(), manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
m.LocalSpoolDir = filepath.Join(filepath.Dir(cfg.Pipeline.Spool.Root), "outside-spool")
|
||||||
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
|
t.Fatalf("Save() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveSuccessStage{}}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") {
|
||||||
|
t.Fatalf("executeStages() error = %v, want safe-path failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupNotRunWhenPromotionIsMissing(t *testing.T) {
|
||||||
|
cfg, seed, runID := archiveStageCleanupFixture(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
||||||
|
cfg.Pipeline.Archive.PromoteArtifacts = []config.ArchivePromotionRule{
|
||||||
|
{From: "artifacts/missing.md", To: "artifacts/missing.md", Required: boolPtr(true)},
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveStageImpl, err := stage.Select("archive")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Select(archive) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "required promotion source missing") {
|
||||||
|
t.Fatalf("executeStages() error = %v, want promotion-missing failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
assertExists(t, filepath.Join(seed.runWorkDir, "manifest.json"))
|
||||||
|
assertExists(t, artifacts.SessionRunWorkDir(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupNotRunWhenCurrentManifestUploadFails(t *testing.T) {
|
||||||
|
cfg, seed, _ := archiveStageCleanupFixture(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
||||||
|
failKey := seed.sessionPrefix + "current/manifest.json"
|
||||||
|
|
||||||
|
archiveStageImpl, err := stage.Select("archive")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Select(archive) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{
|
||||||
|
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "current manifest") {
|
||||||
|
t.Fatalf("executeStages() error = %v, want current-manifest failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostArchiveCleanupNotRunWhenCurrentPointerUploadFails(t *testing.T) {
|
||||||
|
cfg, seed, _ := archiveStageCleanupFixture(t)
|
||||||
|
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
|
||||||
|
cfg.Pipeline.Workspace.CleanupAfterArchive = true
|
||||||
|
failKey := seed.sessionPrefix + "current/run_id.txt"
|
||||||
|
|
||||||
|
archiveStageImpl, err := stage.Select("archive")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Select(archive) error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{
|
||||||
|
Env: &Env{ObjectStore: &failKeyStore{delegate: &storage.FakeBackend{}, failKey: failKey}},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "current run pointer") {
|
||||||
|
t.Fatalf("executeStages() error = %v, want current-run-pointer failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
type cleanupSeed struct {
|
||||||
|
runWorkDir string
|
||||||
|
otherRunDir string
|
||||||
|
spoolAudioDir string
|
||||||
|
localSourceAudio string
|
||||||
|
sessionPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := testConfig(t)
|
||||||
|
cfg.Pipeline.Archive = &config.ArchiveConfig{Enabled: boolPtr(true), UploadRun: boolPtr(true)}
|
||||||
|
cfg.Pipeline.Spool.Root = filepath.Join(t.TempDir(), "spool")
|
||||||
|
|
||||||
|
runID := "20260516T010203Z-1a2b3c4d"
|
||||||
|
runWorkDir := artifacts.SessionRunWorkDir(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)
|
||||||
|
otherRunDir := artifacts.SessionRunWorkDir(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, "20260516T010204Z-5e6f7a8b")
|
||||||
|
spoolAudioDir := artifacts.SessionSpoolAudioDir(cfg.Pipeline.Spool.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(runWorkDir, "manifest.json"), "{}\n")
|
||||||
|
mustWriteFile(t, filepath.Join(runWorkDir, "logs", "stage.log"), "log\n")
|
||||||
|
mustWriteFile(t, filepath.Join(otherRunDir, "logs", "stage.log"), "other\n")
|
||||||
|
mustWriteFile(t, filepath.Join(spoolAudioDir, "speaker.flac"), "flac\n")
|
||||||
|
|
||||||
|
localSourceAudio := filepath.Join(filepath.Dir(cfg.SessionPath), "audio", "alice.flac")
|
||||||
|
mustWriteFile(t, localSourceAudio, "source\n")
|
||||||
|
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
seed.Campaign = cfg.Session.Campaign
|
||||||
|
seed.RunID = runID
|
||||||
|
seed.LocalWorkDir = runWorkDir
|
||||||
|
seed.LocalSpoolDir = spoolAudioDir
|
||||||
|
seed.S3Bucket = "my-dnd-archive"
|
||||||
|
seed.S3SessionPrefix = "dnd/campaigns/sample-campaign/sessions/2026-05-03/"
|
||||||
|
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
|
||||||
|
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(manifestPathFor(cfg)), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
|
t.Fatalf("seed manifest save error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, cleanupSeed{
|
||||||
|
runWorkDir: runWorkDir,
|
||||||
|
otherRunDir: otherRunDir,
|
||||||
|
spoolAudioDir: spoolAudioDir,
|
||||||
|
localSourceAudio: localSourceAudio,
|
||||||
|
sessionPrefix: seed.S3SessionPrefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg, seed := cleanupFixtureConfig(t)
|
||||||
|
runID := "20260516T010203Z-1a2b3c4d"
|
||||||
|
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{
|
||||||
|
Bucket: "my-dnd-archive",
|
||||||
|
RootPrefix: "dnd",
|
||||||
|
}
|
||||||
|
cfg.Pipeline.Archive = &config.ArchiveConfig{
|
||||||
|
Enabled: boolPtr(true),
|
||||||
|
UploadRun: boolPtr(true),
|
||||||
|
PromoteArtifacts: []config.ArchivePromotionRule{
|
||||||
|
{From: "transcripts/trimmed.json", To: "transcripts/trimmed.json", Required: boolPtr(true)},
|
||||||
|
{From: "artifacts/session_recap.md", To: "artifacts/session_recap.md", Required: boolPtr(true)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
writeArchiveFixtureRunFiles(t, seed.runWorkDir)
|
||||||
|
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
seedManifest, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
||||||
|
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
||||||
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
||||||
|
t.Fatalf("Save() error = %v", err)
|
||||||
|
}
|
||||||
|
seed.sessionPrefix = seedManifest.S3SessionPrefix
|
||||||
|
return cfg, seed, runID
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeArchiveFixtureRunFiles(t *testing.T, runWorkDir string) {
|
||||||
|
t.Helper()
|
||||||
|
mustWriteFile(t, filepath.Join(runWorkDir, "inputs", "session.yml"), "session_id: 2026-05-03\n")
|
||||||
|
mustWriteFile(t, filepath.Join(runWorkDir, "transcripts", "raw", "speaker.json"), "{}\n")
|
||||||
|
mustWriteFile(t, filepath.Join(runWorkDir, "transcripts", "trimmed.json"), "{}\n")
|
||||||
|
mustWriteFile(t, filepath.Join(runWorkDir, "artifacts", "session_recap.md"), "# recap\n")
|
||||||
|
mustWriteFile(t, filepath.Join(runWorkDir, "reports", "audita.report.json"), "{}\n")
|
||||||
|
mustWriteFile(t, filepath.Join(runWorkDir, "config", "audita.generated.yml"), "key: value\n")
|
||||||
|
mustWriteFile(t, filepath.Join(runWorkDir, "logs", "audita.stderr.log"), "stderr\n")
|
||||||
|
mustWriteFile(t, filepath.Join(runWorkDir, "manifest.json"), "{}\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
type failKeyStore struct {
|
||||||
|
delegate *storage.FakeBackend
|
||||||
|
failKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failKeyStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
|
||||||
|
return s.delegate.List(ctx, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failKeyStore) Download(ctx context.Context, key, localPath string) error {
|
||||||
|
return s.delegate.Download(ctx, key, localPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failKeyStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
|
||||||
|
if strings.TrimSpace(key) == strings.TrimSpace(s.failKey) {
|
||||||
|
return storage.ObjectInfo{}, errors.New("forced upload failure")
|
||||||
|
}
|
||||||
|
return s.delegate.Upload(ctx, localPath, key, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *failKeyStore) Exists(ctx context.Context, key string) (bool, error) {
|
||||||
|
return s.delegate.Exists(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertExists(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("expected path to exist %q: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMissing(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("expected path to be removed %q, stat err=%v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,9 +17,11 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
|
var sessionID 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(&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.BoolVar(&force, "force", false, "force stage execution")
|
fs.BoolVar(&force, "force", false, "force stage execution")
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
@@ -28,16 +30,18 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if fs.NArg() != 0 {
|
if fs.NArg() != 0 {
|
||||||
return fmt.Errorf("resume: unexpected positional arguments")
|
return fmt.Errorf("resume: unexpected positional arguments")
|
||||||
}
|
}
|
||||||
if sessionPath == "" {
|
|
||||||
return fmt.Errorf("resume: --session is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resume: %w", err)
|
return fmt.Errorf("resume: %w", err)
|
||||||
}
|
}
|
||||||
|
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resume: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||||
|
SessionID: sessionID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resume: %w", err)
|
return fmt.Errorf("resume: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
|
var sessionID 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(&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.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 {
|
||||||
@@ -27,16 +29,18 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if fs.NArg() != 0 {
|
if fs.NArg() != 0 {
|
||||||
return fmt.Errorf("run: unexpected positional arguments")
|
return fmt.Errorf("run: unexpected positional arguments")
|
||||||
}
|
}
|
||||||
if sessionPath == "" {
|
|
||||||
return fmt.Errorf("run: --session is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
|
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||||
|
SessionID: sessionID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
|
var sessionID 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(&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.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 {
|
||||||
@@ -27,10 +29,6 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if fs.NArg() != 1 {
|
if fs.NArg() != 1 {
|
||||||
return fmt.Errorf("run-stage: expected exactly one stage name")
|
return fmt.Errorf("run-stage: expected exactly one stage name")
|
||||||
}
|
}
|
||||||
if sessionPath == "" {
|
|
||||||
return fmt.Errorf("run-stage: --session is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
stageName := fs.Arg(0)
|
stageName := fs.Arg(0)
|
||||||
stages, err := BuildSingleStagePlan(stageName)
|
stages, err := BuildSingleStagePlan(stageName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,8 +39,14 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run-stage: %w", err)
|
return fmt.Errorf("run-stage: %w", err)
|
||||||
}
|
}
|
||||||
|
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("run-stage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||||
|
SessionID: sessionID,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run-stage: %w", err)
|
return fmt.Errorf("run-stage: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
env.Logger.Info("stage succeeded", "stage", s.Name())
|
env.Logger.Info("stage succeeded", "stage", s.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := runPostArchiveCleanup(ctx, env, manifestPath, m, executed); err != nil {
|
||||||
|
return nil, fmt.Errorf("post-archive cleanup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &RunSummary{
|
return &RunSummary{
|
||||||
SessionID: cfg.Session.SessionID,
|
SessionID: cfg.Session.SessionID,
|
||||||
ManifestPath: manifestPath,
|
ManifestPath: manifestPath,
|
||||||
@@ -246,7 +250,7 @@ func buildDefaultAuditaRunner(cfg *config.Config) (audita.Runner, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a := cfg.Pipeline.Audita
|
a := cfg.Pipeline.Audita
|
||||||
if strings.TrimSpace(a.Binary) == "" || strings.TrimSpace(a.Timeout) == "" || strings.TrimSpace(a.BaseURL) == "" || strings.TrimSpace(a.Model) == "" {
|
if strings.TrimSpace(a.Binary) == "" || strings.TrimSpace(a.Timeout) == "" {
|
||||||
// Compatibility fallback for tests or internal call paths that bypass config validation/defaults.
|
// Compatibility fallback for tests or internal call paths that bypass config validation/defaults.
|
||||||
return &audita.NoopRunner{}, nil
|
return &audita.NoopRunner{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -497,6 +497,55 @@ func testConfig(t *testing.T) *config.Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildDefaultRunnersWithOmittedToolSections(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||||
|
sessionPath := filepath.Join(dir, "session.yml")
|
||||||
|
pipelineYAML := `workspace:
|
||||||
|
root: ` + t.TempDir() + `
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://example.com/transcribe
|
||||||
|
analyzer:
|
||||||
|
timeout: 20m
|
||||||
|
notification:
|
||||||
|
timeout: 10s
|
||||||
|
`
|
||||||
|
sessionYAML := `session_id: 2026-05-03
|
||||||
|
campaign: sample-campaign
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`
|
||||||
|
mustWriteFile(t, pipelinePath, pipelineYAML)
|
||||||
|
mustWriteFile(t, sessionPath, sessionYAML)
|
||||||
|
|
||||||
|
cfg, err := config.Load(pipelinePath, sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := config.Validate(cfg); err != nil {
|
||||||
|
t.Fatalf("Validate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serRunner, err := buildDefaultSeriatimRunner(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildDefaultSeriatimRunner() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := serRunner.(*seriatim.SubprocessRunner); !ok {
|
||||||
|
t.Fatalf("seriatim runner type = %T, want *seriatim.SubprocessRunner", serRunner)
|
||||||
|
}
|
||||||
|
|
||||||
|
audRunner, err := buildDefaultAuditaRunner(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildDefaultAuditaRunner() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := audRunner.(*audita.SubprocessRunner); !ok {
|
||||||
|
t.Fatalf("audita runner type = %T, want *audita.SubprocessRunner", audRunner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustWriteFile(t *testing.T, path, contents string) {
|
func mustWriteFile(t *testing.T, path, contents string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
|||||||
86
internal/app/session_cli_test.go
Normal file
86
internal/app/session_cli_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanUsesDiscoveredSessionTemplateWithSessionID(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
|
sessionTemplate := `session_id: "{{ session_id }}"
|
||||||
|
campaign: sample-campaign
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil {
|
||||||
|
t.Fatalf("write session template: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cwd := filepath.Dir(sessionPath)
|
||||||
|
originalWD, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Getwd(): %v", err)
|
||||||
|
}
|
||||||
|
if err := os.Chdir(cwd); err != nil {
|
||||||
|
t.Fatalf("Chdir(%q): %v", cwd, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.Chdir(originalWD) })
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := Plan(context.Background(), []string{"--config", pipelinePath, "--session-id", "2026-04-04"}, &out); err != nil {
|
||||||
|
t.Fatalf("Plan() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "narratio plan: workdir prepared") {
|
||||||
|
t.Fatalf("output = %q, want plan output", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := Plan(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--session-id", "2026-04-04"}, &out)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "session_id mismatch") {
|
||||||
|
t.Fatalf("error = %q, want mismatch context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--session-id", "2026-05-03", "prepare"}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RunStage() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "stage=prepare") {
|
||||||
|
t.Fatalf("output = %q, want stage output", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSessionConfigPathErrorIncludesSearchedPaths(t *testing.T) {
|
||||||
|
_, err := resolveSessionConfigPathWithCandidates("", []string{"./session.yml", "/usr/local/etc/narratio/session.yml", "/etc/narratio/session.yml"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "searched") {
|
||||||
|
t.Fatalf("error = %q, want searched paths", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "pass --session") {
|
||||||
|
t.Fatalf("error = %q, want explicit-session guidance", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
49
internal/app/session_config_path.go
Normal file
49
internal/app/session_config_path.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resolveSessionConfigPath(flagValue string) (string, error) {
|
||||||
|
return resolveSessionConfigPathWithCandidates(flagValue, config.DefaultSessionConfigSearchPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveSessionConfigPathWithCandidates(flagValue string, candidates []string) (string, error) {
|
||||||
|
if explicit := strings.TrimSpace(flagValue); explicit != "" {
|
||||||
|
return explicit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]string, 0, len(candidates))
|
||||||
|
for _, raw := range candidates {
|
||||||
|
path := strings.TrimSpace(raw)
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ordered = append(ordered, path)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
if info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return filepath.Clean(path), nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("check default session config %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ordered) == 0 {
|
||||||
|
return "", fmt.Errorf("no session config path provided and no default locations configured")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"no session config path provided and no default session config found; searched: %s; pass --session to use an explicit path",
|
||||||
|
strings.Join(ordered, ", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
68
internal/app/session_config_path_test.go
Normal file
68
internal/app/session_config_path_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveSessionConfigPathWithCandidatesExplicitWins(t *testing.T) {
|
||||||
|
got, err := resolveSessionConfigPathWithCandidates(" ./custom/session.yml ", []string{"./session.yml", "/a", "/b"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveSessionConfigPathWithCandidates() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != "./custom/session.yml" {
|
||||||
|
t.Fatalf("resolved path = %q, want explicit path", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSessionConfigPathWithCandidatesUsesFirstExisting(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
first := filepath.Join(dir, "first.yml")
|
||||||
|
second := filepath.Join(dir, "second.yml")
|
||||||
|
if err := os.WriteFile(second, []byte("session_id: 2026-05-03\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write second default: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := resolveSessionConfigPathWithCandidates("", []string{first, second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveSessionConfigPathWithCandidates() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != filepath.Clean(second) {
|
||||||
|
t.Fatalf("resolved path = %q, want %q", got, filepath.Clean(second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSessionConfigPathWithCandidatesPrecedence(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
first := filepath.Join(dir, "first.yml")
|
||||||
|
second := filepath.Join(dir, "second.yml")
|
||||||
|
if err := os.WriteFile(first, []byte("session_id: 2026-05-03\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write first default: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(second, []byte("session_id: 2026-05-03\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write second default: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := resolveSessionConfigPathWithCandidates("", []string{first, second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveSessionConfigPathWithCandidates() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != filepath.Clean(first) {
|
||||||
|
t.Fatalf("resolved path = %q, want first candidate %q", got, filepath.Clean(first))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSessionConfigPathWithCandidatesMissing(t *testing.T) {
|
||||||
|
_, err := resolveSessionConfigPathWithCandidates("", []string{"/does/not/exist/one.yml", "/does/not/exist/two.yml"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no default session config found") {
|
||||||
|
t.Fatalf("error = %q, want missing-defaults context", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "pass --session") {
|
||||||
|
t.Fatalf("error = %q, want explicit-path guidance", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,8 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretsConfig configures optional local filesystem secret loading.
|
// SecretsConfig configures optional local filesystem secret loading.
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ package config
|
|||||||
const (
|
const (
|
||||||
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
|
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
|
||||||
DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml"
|
DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml"
|
||||||
|
DefaultSessionConfigPathLocal = "./session.yml"
|
||||||
|
DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml"
|
||||||
|
DefaultSessionConfigPathEtc = "/etc/narratio/session.yml"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultPipelineConfigSearchPaths defines the default search order for
|
// DefaultPipelineConfigSearchPaths defines the default search order for
|
||||||
@@ -16,3 +19,14 @@ var DefaultPipelineConfigSearchPaths = []string{
|
|||||||
DefaultPipelineConfigPathUsrLocal,
|
DefaultPipelineConfigPathUsrLocal,
|
||||||
DefaultPipelineConfigPathEtc,
|
DefaultPipelineConfigPathEtc,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultSessionConfigSearchPaths defines the default search order for
|
||||||
|
// session.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 DefaultSessionConfigSearchPaths = []string{
|
||||||
|
DefaultSessionConfigPathLocal,
|
||||||
|
DefaultSessionConfigPathUsrLocal,
|
||||||
|
DefaultSessionConfigPathEtc,
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -21,21 +23,56 @@ func LoadPipeline(path string) (*PipelineConfig, error) {
|
|||||||
|
|
||||||
// LoadSession loads session configuration from a YAML file with strict field checking.
|
// LoadSession loads session configuration from a YAML file with strict field checking.
|
||||||
func LoadSession(path string) (*SessionConfig, error) {
|
func LoadSession(path string) (*SessionConfig, error) {
|
||||||
var cfg SessionConfig
|
return LoadSessionWithOptions(path, SessionLoadOptions{})
|
||||||
if err := decodeStrictYAML("session", path, &cfg); err != nil {
|
}
|
||||||
|
|
||||||
|
// SessionLoadOptions configures session template rendering behavior.
|
||||||
|
type SessionLoadOptions struct {
|
||||||
|
SessionID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSessionWithOptions loads session configuration from a YAML file with
|
||||||
|
// strict field checking after template rendering.
|
||||||
|
func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) {
|
||||||
|
sessionBytes, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load session config: session file %q: open: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered, err := renderSessionTemplate(string(sessionBytes), opts)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("load session config: %w", err)
|
return nil, fmt.Errorf("load session config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cfg SessionConfig
|
||||||
|
if err := decodeStrictYAMLFromReader("session", path, strings.NewReader(rendered), &cfg); err != nil {
|
||||||
|
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) {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"load session config: session file %q: session_id mismatch: --session-id %q does not match rendered session_id %q",
|
||||||
|
path,
|
||||||
|
strings.TrimSpace(opts.SessionID),
|
||||||
|
strings.TrimSpace(cfg.SessionID),
|
||||||
|
)
|
||||||
|
}
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load loads and resolves combined pipeline and session configuration.
|
// Load loads and resolves combined pipeline and session configuration.
|
||||||
func Load(pipelinePath, sessionPath string) (*Config, error) {
|
func Load(pipelinePath, sessionPath string) (*Config, error) {
|
||||||
|
return LoadWithSessionOptions(pipelinePath, sessionPath, SessionLoadOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadWithSessionOptions loads and resolves combined pipeline and session
|
||||||
|
// configuration with session template options.
|
||||||
|
func LoadWithSessionOptions(pipelinePath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
|
||||||
pipelineCfg, err := LoadPipeline(pipelinePath)
|
pipelineCfg, err := LoadPipeline(pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionCfg, err := LoadSession(sessionPath)
|
sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -55,7 +92,11 @@ func decodeStrictYAML(kind, path string, out any) error {
|
|||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
dec := yaml.NewDecoder(f)
|
return decodeStrictYAMLFromReader(kind, path, f, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStrictYAMLFromReader(kind, path string, r io.Reader, out any) error {
|
||||||
|
dec := yaml.NewDecoder(r)
|
||||||
dec.KnownFields(true)
|
dec.KnownFields(true)
|
||||||
if err := dec.Decode(out); err != nil {
|
if err := dec.Decode(out); err != nil {
|
||||||
return fmt.Errorf("%s file %q: strict decode failed: %w", kind, path, err)
|
return fmt.Errorf("%s file %q: strict decode failed: %w", kind, path, err)
|
||||||
@@ -69,6 +110,36 @@ func decodeStrictYAML(kind, path string, out any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sessionTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
|
||||||
|
|
||||||
|
func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) {
|
||||||
|
sessionID := strings.TrimSpace(opts.SessionID)
|
||||||
|
rendered := content
|
||||||
|
if sessionID != "" {
|
||||||
|
rendered = strings.ReplaceAll(rendered, "{{session_id}}", sessionID)
|
||||||
|
rendered = strings.ReplaceAll(rendered, "{{ session_id }}", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1)
|
||||||
|
if len(unresolved) > 0 {
|
||||||
|
vars := make([]string, 0, len(unresolved))
|
||||||
|
for _, m := range unresolved {
|
||||||
|
if len(m) > 1 {
|
||||||
|
vars = append(vars, m[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(vars) > 0 {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"session file template rendering failed: unresolved template variable(s): %s; pass --session-id when using {{ session_id }}",
|
||||||
|
strings.Join(vars, ", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("session file template rendering failed: unresolved template placeholders remain")
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
func shortName(path, fallback string) string {
|
func shortName(path, fallback string) string {
|
||||||
base := filepath.Base(path)
|
base := filepath.Base(path)
|
||||||
if base == "." || base == string(filepath.Separator) {
|
if base == "." || base == string(filepath.Separator) {
|
||||||
@@ -173,6 +244,9 @@ func applySeriatimDefaults(cfg *SeriatimConfig) {
|
|||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cfg.Binary == "" {
|
||||||
|
cfg.Binary = "seriatim"
|
||||||
|
}
|
||||||
if cfg.Timeout == "" {
|
if cfg.Timeout == "" {
|
||||||
cfg.Timeout = "10m"
|
cfg.Timeout = "10m"
|
||||||
}
|
}
|
||||||
@@ -191,15 +265,12 @@ func applyAuditaDefaults(cfg *AuditaConfig) {
|
|||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cfg.Binary == "" {
|
||||||
|
cfg.Binary = "audita"
|
||||||
|
}
|
||||||
if cfg.Timeout == "" {
|
if cfg.Timeout == "" {
|
||||||
cfg.Timeout = "3h"
|
cfg.Timeout = "3h"
|
||||||
}
|
}
|
||||||
if cfg.BaseURL == "" {
|
|
||||||
cfg.BaseURL = "https://openrouter.ai/api/v1"
|
|
||||||
}
|
|
||||||
if cfg.Model == "" {
|
|
||||||
cfg.Model = "openrouter/google/gemma-4-31b-it"
|
|
||||||
}
|
|
||||||
if cfg.Report == nil {
|
if cfg.Report == nil {
|
||||||
cfg.Report = boolPtr(true)
|
cfg.Report = boolPtr(true)
|
||||||
}
|
}
|
||||||
@@ -209,6 +280,9 @@ func applyScriptoriumDefaults(cfg *ScriptoriumConfig) {
|
|||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if cfg.Binary == "" {
|
||||||
|
cfg.Binary = "scriptorium"
|
||||||
|
}
|
||||||
if cfg.Timeout == "" {
|
if cfg.Timeout == "" {
|
||||||
cfg.Timeout = "10m"
|
cfg.Timeout = "10m"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,26 @@ 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
|
||||||
|
`,
|
||||||
|
checkDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "seriatim and audita sections can be omitted",
|
||||||
|
pipelineYAML: `workspace:
|
||||||
|
root: /tmp/narratio
|
||||||
|
whisperx:
|
||||||
|
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
|
||||||
|
analyzer:
|
||||||
|
timeout: 20m
|
||||||
|
notification:
|
||||||
|
timeout: 15s
|
||||||
|
`,
|
||||||
|
sessionYAML: `session_id: 2026-05-03
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
`,
|
`,
|
||||||
checkDefault: true,
|
checkDefault: true,
|
||||||
},
|
},
|
||||||
@@ -296,7 +316,7 @@ inputs:
|
|||||||
wantLoadErr: "strict decode failed",
|
wantLoadErr: "strict decode failed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing seriatim binary fails",
|
name: "missing seriatim binary uses default",
|
||||||
pipelineYAML: `workspace:
|
pipelineYAML: `workspace:
|
||||||
root: /tmp/narratio
|
root: /tmp/narratio
|
||||||
whisperx:
|
whisperx:
|
||||||
@@ -311,7 +331,6 @@ inputs:
|
|||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.binary is required",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid seriatim timeout fails",
|
name: "invalid seriatim timeout fails",
|
||||||
@@ -412,7 +431,7 @@ inputs:
|
|||||||
wantLoadErr: "strict decode failed",
|
wantLoadErr: "strict decode failed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing audita binary fails",
|
name: "missing audita binary uses default",
|
||||||
pipelineYAML: `workspace:
|
pipelineYAML: `workspace:
|
||||||
root: /tmp/narratio
|
root: /tmp/narratio
|
||||||
whisperx:
|
whisperx:
|
||||||
@@ -429,7 +448,6 @@ inputs:
|
|||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
`,
|
`,
|
||||||
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.binary is required",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid audita timeout fails",
|
name: "invalid audita timeout fails",
|
||||||
@@ -713,6 +731,9 @@ inputs:
|
|||||||
if cfg.Pipeline.Seriatim.Timeout != "10m" {
|
if cfg.Pipeline.Seriatim.Timeout != "10m" {
|
||||||
t.Fatalf("seriatim.timeout = %q, want %q", cfg.Pipeline.Seriatim.Timeout, "10m")
|
t.Fatalf("seriatim.timeout = %q, want %q", cfg.Pipeline.Seriatim.Timeout, "10m")
|
||||||
}
|
}
|
||||||
|
if cfg.Pipeline.Seriatim.Binary != "seriatim" {
|
||||||
|
t.Fatalf("seriatim.binary = %q, want %q", cfg.Pipeline.Seriatim.Binary, "seriatim")
|
||||||
|
}
|
||||||
if cfg.Pipeline.Seriatim.OutputSchema != "seriatim-intermediate" {
|
if cfg.Pipeline.Seriatim.OutputSchema != "seriatim-intermediate" {
|
||||||
t.Fatalf("seriatim.output_schema = %q, want %q", cfg.Pipeline.Seriatim.OutputSchema, "seriatim-intermediate")
|
t.Fatalf("seriatim.output_schema = %q, want %q", cfg.Pipeline.Seriatim.OutputSchema, "seriatim-intermediate")
|
||||||
}
|
}
|
||||||
@@ -725,17 +746,20 @@ inputs:
|
|||||||
if cfg.Pipeline.Audita.Timeout != "3h" {
|
if cfg.Pipeline.Audita.Timeout != "3h" {
|
||||||
t.Fatalf("audita.timeout = %q, want %q", cfg.Pipeline.Audita.Timeout, "3h")
|
t.Fatalf("audita.timeout = %q, want %q", cfg.Pipeline.Audita.Timeout, "3h")
|
||||||
}
|
}
|
||||||
|
if cfg.Pipeline.Audita.Binary != "audita" {
|
||||||
|
t.Fatalf("audita.binary = %q, want %q", cfg.Pipeline.Audita.Binary, "audita")
|
||||||
|
}
|
||||||
if cfg.Pipeline.Audita.LLMAPIKeyEnv != "" {
|
if cfg.Pipeline.Audita.LLMAPIKeyEnv != "" {
|
||||||
t.Fatalf("audita.llm_api_key_env = %q, want empty by default", cfg.Pipeline.Audita.LLMAPIKeyEnv)
|
t.Fatalf("audita.llm_api_key_env = %q, want empty by default", cfg.Pipeline.Audita.LLMAPIKeyEnv)
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Audita.Modules != nil {
|
if cfg.Pipeline.Audita.Modules != nil {
|
||||||
t.Fatalf("audita.modules = %#v, want nil default (optional override)", cfg.Pipeline.Audita.Modules)
|
t.Fatalf("audita.modules = %#v, want nil default (optional override)", cfg.Pipeline.Audita.Modules)
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Audita.BaseURL != "https://openrouter.ai/api/v1" {
|
if cfg.Pipeline.Audita.BaseURL != "" {
|
||||||
t.Fatalf("audita.base_url = %q, want %q", cfg.Pipeline.Audita.BaseURL, "https://openrouter.ai/api/v1")
|
t.Fatalf("audita.base_url = %q, want empty default", cfg.Pipeline.Audita.BaseURL)
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Audita.Model != "openrouter/google/gemma-4-31b-it" {
|
if cfg.Pipeline.Audita.Model != "" {
|
||||||
t.Fatalf("audita.model = %q, want %q", cfg.Pipeline.Audita.Model, "openrouter/google/gemma-4-31b-it")
|
t.Fatalf("audita.model = %q, want empty default", cfg.Pipeline.Audita.Model)
|
||||||
}
|
}
|
||||||
if cfg.Pipeline.Audita.ValidationModel != "" {
|
if cfg.Pipeline.Audita.ValidationModel != "" {
|
||||||
t.Fatalf("audita.validation_model = %q, want empty default", cfg.Pipeline.Audita.ValidationModel)
|
t.Fatalf("audita.validation_model = %q, want empty default", cfg.Pipeline.Audita.ValidationModel)
|
||||||
|
|||||||
@@ -49,11 +49,19 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
wantLoadErr: "strict decode failed",
|
wantLoadErr: "strict decode failed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing binary fails when section present",
|
name: "missing binary defaults when section present",
|
||||||
scriptoriumYAML: `scriptorium:
|
scriptoriumYAML: `scriptorium:
|
||||||
timeout: 10m
|
timeout: 10m
|
||||||
`,
|
`,
|
||||||
wantValidateErr: "pipeline.scriptorium.binary is required",
|
assert: func(t *testing.T, cfg *Config) {
|
||||||
|
t.Helper()
|
||||||
|
if cfg.Pipeline.Scriptorium == nil {
|
||||||
|
t.Fatal("scriptorium config should be present")
|
||||||
|
}
|
||||||
|
if cfg.Pipeline.Scriptorium.Binary != "scriptorium" {
|
||||||
|
t.Fatalf("scriptorium.binary = %q, want scriptorium", cfg.Pipeline.Scriptorium.Binary)
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "enabled artifact missing prompt id fails",
|
name: "enabled artifact missing prompt id fails",
|
||||||
|
|||||||
156
internal/config/session_template_test.go
Normal file
156
internal/config/session_template_test.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadSessionWithOptionsRendersCompactPlaceholder(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sessionPath := filepath.Join(dir, "session.yml")
|
||||||
|
sessionYAML := `session_id: "{{session_id}}"
|
||||||
|
campaign: sample-campaign
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
|
t.Fatalf("write session.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSessionWithOptions() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.SessionID != "2026-04-04" {
|
||||||
|
t.Fatalf("SessionID = %q, want 2026-04-04", cfg.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSessionWithOptionsRendersSpacedPlaceholder(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sessionPath := filepath.Join(dir, "session.yml")
|
||||||
|
sessionYAML := `session_id: "{{ session_id }}"
|
||||||
|
campaign: sample-campaign
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
|
t.Fatalf("write session.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSessionWithOptions() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.SessionID != "2026-04-04" {
|
||||||
|
t.Fatalf("SessionID = %q, want 2026-04-04", cfg.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSessionWithOptionsUnresolvedPlaceholderFails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sessionPath := filepath.Join(dir, "session.yml")
|
||||||
|
sessionYAML := `session_id: "{{ session_id }}"
|
||||||
|
campaign: sample-campaign
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
|
t.Fatalf("write session.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unresolved template variable") {
|
||||||
|
t.Fatalf("error = %q, want unresolved-variable context", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "session_id") {
|
||||||
|
t.Fatalf("error = %q, want session_id variable", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSessionWithOptionsMismatchFails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sessionPath := filepath.Join(dir, "session.yml")
|
||||||
|
sessionYAML := `session_id: 2026-05-03
|
||||||
|
campaign: sample-campaign
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
|
t.Fatalf("write session.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "session_id mismatch") {
|
||||||
|
t.Fatalf("error = %q, want mismatch context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSessionWithOptionsUnknownFieldStillRejectedAfterRendering(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sessionPath := filepath.Join(dir, "session.yml")
|
||||||
|
sessionYAML := `session_id: "{{ session_id }}"
|
||||||
|
campaign: sample-campaign
|
||||||
|
unknown_field: true
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
|
t.Fatalf("write session.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "strict decode failed") {
|
||||||
|
t.Fatalf("error = %q, want strict-decode context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSessionWithOptionsConcreteSessionStillLoads(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sessionPath := filepath.Join(dir, "session.yml")
|
||||||
|
sessionYAML := `session_id: 2026-05-03
|
||||||
|
campaign: sample-campaign
|
||||||
|
inputs:
|
||||||
|
audio_dir: ./audio
|
||||||
|
speakers_file: ./speakers.yml
|
||||||
|
autocorrect_file: ./autocorrect.yml
|
||||||
|
glossary_file: ./glossary.yml
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
|
t.Fatalf("write session.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadSessionWithOptions() error = %v", err)
|
||||||
|
}
|
||||||
|
if cfg.SessionID != "2026-05-03" {
|
||||||
|
t.Fatalf("SessionID = %q, want 2026-05-03", cfg.SessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,9 @@ func TestSpoolAndArchiveDefaults(t *testing.T) {
|
|||||||
if cfg.Pipeline.Spool.DeleteAudioAfterArchive {
|
if cfg.Pipeline.Spool.DeleteAudioAfterArchive {
|
||||||
t.Fatalf("spool.delete_audio_after_archive = true, want false")
|
t.Fatalf("spool.delete_audio_after_archive = true, want false")
|
||||||
}
|
}
|
||||||
|
if cfg.Pipeline.Workspace.CleanupAfterArchive {
|
||||||
|
t.Fatalf("workspace.cleanup_after_archive = true, want false")
|
||||||
|
}
|
||||||
if cfg.Pipeline.Archive == nil {
|
if cfg.Pipeline.Archive == nil {
|
||||||
t.Fatal("archive should be initialized by defaults")
|
t.Fatal("archive should be initialized by defaults")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,9 +276,6 @@ func validateAudita(cfg AuditaConfig) error {
|
|||||||
return fmt.Errorf("pipeline.audita.base_url must be a valid URL")
|
return fmt.Errorf("pipeline.audita.base_url must be a valid URL")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.Model) == "" {
|
|
||||||
return fmt.Errorf("pipeline.audita.model is required")
|
|
||||||
}
|
|
||||||
if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 {
|
if cfg.TotalLLMConcurrency != nil && *cfg.TotalLLMConcurrency <= 0 {
|
||||||
return fmt.Errorf("pipeline.audita.total_llm_concurrency must be > 0")
|
return fmt.Errorf("pipeline.audita.total_llm_concurrency must be > 0")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user