16 Commits

Author SHA1 Message Date
717451512a Implemented new campaign/session stable inputs and corresponding input source references
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-05-27 09:34:05 -05:00
3ddb3a947b Update pipeline defaults so trim is enabled when omitted 2026-05-27 08:35:02 -05:00
c6632d5576 Bugfix in the seriatim adapter
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-05-27 08:09:22 -05:00
ffc07922c7 Cleanup following the render stage implementation and remove the completed roadmap
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-05-25 08:35:18 -05:00
f3310d4d16 Finalize render documentation across operations, integrations, troubleshooting, and roadmap status 2026-05-25 00:48:15 +00:00
88cee96d8d Finish render rollout with markdown publish defaults, analyze guidance, and docs updates 2026-05-25 00:46:27 +00:00
2fece10215 Implement render stage runtime and integrate it into pipeline execution 2026-05-25 00:40:06 +00:00
0658f2f642 Add render artifact model, config, and Seriatim adapter contracts 2026-05-25 00:28:01 +00:00
a51228c803 Add a documentation roadmap for the upcoming render stage feature 2026-05-24 19:15:42 -05:00
4491fb5ccd Final documentation cleanup for v1.0.0 release
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-05-23 11:28:03 -05:00
30b905765c Remove the deprecated narratio resume command 2026-05-23 11:25:08 -05:00
03eac70881 Mark cleanup roadmap stages as implemented 2026-05-23 16:05:21 +00:00
0f7e6b979f Deduplicate locks add/remove session-id and source parsing 2026-05-23 16:03:20 +00:00
c366912586 Extract shared read-only session inspection checks 2026-05-23 16:00:31 +00:00
9fe44cd00d Centralize Scriptorium input source policy across config, analyze, and previous-cache 2026-05-23 15:50:29 +00:00
094b0d2532 Centralize path-safe root joins and atomic file operations 2026-05-23 15:45:31 +00:00
96 changed files with 3736 additions and 1452 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -2,7 +2,7 @@
Narratio is a stage-driven Go orchestrator for turning D&D session audio into polished transcripts and generated artifacts.
It runs a deterministic workflow across `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `analyze`, and `publish`, with manifest-driven resume and restore support.
It runs a deterministic workflow across `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `render`, `analyze`, and `publish`, with manifest-driven continuation and restore support.
```bash
narratio run 2026-04-04

View File

@@ -13,7 +13,6 @@ This runs the canonical full pipeline for session `2026-04-04`.
Top-level commands:
- `run <session_id>`: run full stage order.
- `resume <session_id>`: continue from first non-succeeded stage.
- `run-stage <stage> <session_id>`: run one stage.
- `analyze <session_id>`: force-run analyze.
- `publish <session_id>`: force-run publish.
@@ -77,19 +76,9 @@ Behavior:
- evaluates full stage order;
- skips already-succeeded stages unless `--force` is set;
- continues interrupted or partially completed sessions by running non-succeeded stages;
- writes session and run manifests.
### `resume`
```bash
narratio resume <session_id> [--force] [--artifacts <name[,name...]>] [...common config flags]
```
Behavior:
- when not forced, starts at first non-succeeded stage in manifest order;
- with `--force`, reevaluates the selected stage list as runnable.
### `run-stage`
```bash
@@ -104,6 +93,7 @@ Valid stage names:
- `polish`
- `normalize`
- `trim`
- `render`
- `analyze`
- `publish`
- `notify`
@@ -253,7 +243,7 @@ Behavior:
## `--artifacts` Selection Rules
- accepted on `run`, `resume`, `run-stage`, `analyze`, and `publish`;
- accepted on `run`, `run-stage`, `analyze`, and `publish`;
- names must exist in `pipeline.scriptorium.artifacts`;
- empty entries are invalid;
- repeated names are deduplicated.

View File

@@ -44,7 +44,7 @@ using configured object storage.
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
- Pipeline defaults are applied before validation.
- Campaign and session identities must agree.
- Stable files (`speakers_file`, `autocorrect_file`, `glossary_file`) resolve from session overrides when provided, otherwise from campaign defaults.
- Stable files (`speakers_file`, `autocorrect_file`, `glossary_file`, `players_file`, `party_file`) resolve from session overrides when provided, otherwise from campaign defaults.
- Exactly one audio mode must be configured in session input:
- local (`audio_dir` or `audio_files`), or
- S3 (`audio_s3.prefix`).
@@ -69,6 +69,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
```
`session.yml` (local audio)
@@ -98,6 +100,12 @@ publish:
- source: narratio.transcript.final_trimmed
dest: transcripts/final.trimmed.json
required: true
- source: narratio.transcript.final_markdown
dest: transcripts/final.md
required: true
- source: narratio.transcript.final_trimmed_markdown
dest: transcripts/final.trimmed.md
required: true
- source: narratio.artifact.session_recap
dest: artifacts/session_recap.md
required: true
@@ -138,7 +146,7 @@ Rules:
| `pipeline.cache.s3_audio` | bool | No | `true` |
| `pipeline.publish.enabled` | bool | No | `true` |
| `pipeline.publish.upload_run` | bool | No | `true` |
| `pipeline.publish.outputs[]` | list | No | defaults to final trimmed transcript output |
| `pipeline.publish.outputs[]` | list | No | defaults to final trimmed JSON plus final and final-trimmed Markdown outputs |
| `pipeline.publish.outputs[].source` | string | Yes (per rule) | must reference built-in or configured artifact source |
| `pipeline.publish.outputs[].dest` | string | Conditional | derived if omitted and source supports derivation |
| `pipeline.publish.outputs[].required` | bool | No | `true` |
@@ -178,16 +186,22 @@ Rules:
| `pipeline.normalize.output_path` | string | No | `transcripts/final.json` |
| `pipeline.normalize.output_schema` | string | No | `seriatim-intermediate` |
| `pipeline.normalize.report` | bool | No | `true` |
| `pipeline.trim.enabled` | bool | No | `false` |
| `pipeline.trim.output_path` | string | Conditional | required when trim enabled |
| `pipeline.trim.bounds.prompt_id` | string | Conditional | required when trim enabled |
| `pipeline.trim.enabled` | bool | No | `true` |
| `pipeline.trim.output_path` | string | No | `transcripts/final.trimmed.json` |
| `pipeline.trim.bounds.prompt_id` | string | No | `dnd.session_bounds` |
| `pipeline.trim.bounds.profile_id` | string | No | empty |
| `pipeline.trim.bounds.transcript_input_name` | string | Conditional | required when trim enabled |
| `pipeline.trim.bounds.output_path` | string | Conditional | required when trim enabled |
| `pipeline.trim.bounds.transcript_input_name` | string | No | `transcript` |
| `pipeline.trim.bounds.output_path` | string | No | `artifacts/session_bounds.json` |
| `pipeline.trim.bounds.timeout` | duration | No | `10m` |
| `pipeline.trim.bounds.render_debug` | bool | No | `false` |
| `pipeline.trim.bounds.render_output_path` | string | Conditional | required when `render_debug` is true |
| `pipeline.trim.seriatim.report` | bool | No | `false` |
| `pipeline.render.enabled` | bool | No | `true` |
| `pipeline.render.format` | string | No | `markdown` (only supported value) |
| `pipeline.render.title` | string | No | empty (falls back to `session.title` when set) |
| `pipeline.render.include_timestamps` | bool | No | `true` |
| `pipeline.render.include_segment_ids` | bool | No | `true` |
| `pipeline.render.include_metadata` | bool | No | `false` |
| `pipeline.scriptorium.binary` | string | No | `scriptorium` |
| `pipeline.scriptorium.config_path` | string | No | empty |
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
@@ -217,7 +231,7 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
| Field | Type | Required | Rule |
| --- | --- | --- | --- |
| `source` | string | Yes | built-in runtime source, `narratio.artifact.<name>`, or `narratio.previous_session.artifact.<name>` |
| `source` | string | Yes | built-in runtime source, prepared input source, `narratio.artifact.<name>`, or `narratio.previous_session.artifact.<name>` |
| `artifact` | string | No | optional passthrough adapter field |
| `path` | string | No | optional passthrough adapter field |
| `required` | bool | No | optional input requirement |
@@ -231,6 +245,8 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
| `inputs.speakers_file` | string | Yes | stable input default |
| `inputs.autocorrect_file` | string | Yes | stable input default |
| `inputs.glossary_file` | string | Yes | stable input default |
| `inputs.players_file` | string | Yes | stable input default |
| `inputs.party_file` | string | Yes | stable input default |
### Session
@@ -244,6 +260,8 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
| `inputs.speakers_file` | string | No | overrides campaign stable input |
| `inputs.autocorrect_file` | string | No | overrides campaign stable input |
| `inputs.glossary_file` | string | No | overrides campaign stable input |
| `inputs.players_file` | string | No | overrides campaign stable input |
| `inputs.party_file` | string | No | overrides campaign stable input |
| `inputs.audio_dir` | string | Conditional | local audio mode |
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
| `inputs.audio_s3.prefix` | string | Conditional | S3 audio mode |

View File

@@ -10,7 +10,7 @@ These docs cover what Narratio expects from external tools and what each adapter
## Integration Contracts
- `audita.md`: transcript polishing adapter (`audita process`).
- `seriatim.md`: merge/normalize/trim adapter (`seriatim`).
- `seriatim.md`: merge/normalize/trim/render adapter (`seriatim`).
- `scriptorium.md`: artifact run/render adapter (`scriptorium run|render`).
## Related Canonical Docs

View File

@@ -1,7 +1,7 @@
# Integration: Seriatim
## Purpose
Define the Seriatim adapter contract used by `merge`, `normalize`, and `trim`.
Define the Seriatim adapter contract used by `merge`, `normalize`, `trim`, and `render`.
## Adapter Boundary
Interface:
@@ -10,6 +10,7 @@ Interface:
- `Run(ctx, MergeRequest)`
- `Normalize(ctx, NormalizeRequest)`
- `Trim(ctx, TrimRequest)`
- `Render(ctx, RenderRequest)`
Primary implementation:
- `internal/adapters/seriatim/SubprocessRunner`
@@ -18,11 +19,13 @@ Execution modes:
- `seriatim merge`
- `seriatim normalize`
- `seriatim trim`
- `seriatim render`
## Request/Result Contracts
- `MergeRequest`/`MergeResult`: multi-input merge to base transcript, optional report.
- `NormalizeRequest`/`NormalizeResult`: transcript normalization with explicit schema.
- `TrimRequest`/`TrimResult`: transcript trimming with required keep selector.
- `RenderRequest`/`RenderResult`: transcript-to-markdown rendering with explicit format and render booleans.
Results include output/log/config paths, timing, exit code, and metadata.
@@ -36,9 +39,11 @@ Runner construction validates:
Invocation fails on:
- missing required request paths/inputs;
- invalid normalize schema override;
- unsupported render format;
- subprocess failure;
- invalid JSON outputs;
- missing `segments` array for normalize/trim transcript outputs.
- invalid JSON outputs for merge/normalize/trim;
- missing `segments` array for normalize/trim transcript outputs;
- empty render output files.
When report paths are provided/enabled, report files must parse as JSON.
@@ -49,7 +54,7 @@ When report paths are provided/enabled, report files must parse as JSON.
- adapter does not write manifests or choose stage inputs.
## Config Mapping
Config fields consumed through runner/stage wiring are under `pipeline.seriatim.*`.
Config fields consumed through runner/stage wiring are under `pipeline.seriatim.*` and `pipeline.render.*`.
Maintained examples with Seriatim config:
- `examples/pipeline.full.annotated.yml`

View File

@@ -20,9 +20,10 @@ Canonical stage order from `internal/stage.All()`:
4. `polish`
5. `normalize`
6. `trim`
7. `analyze`
8. `publish`
9. `notify` (placeholder)
7. `render`
8. `analyze`
9. `publish`
10. `notify` (placeholder)
`notify` is currently a placeholder stage with optional notifier call behavior; it has no persisted pipeline outputs.
@@ -39,5 +40,6 @@ Canonical stage order from `internal/stage.All()`:
- `stage-polish.md`
- `stage-normalize.md`
- `stage-trim.md`
- `stage-render.md`
- `stage-analyze.md`
- `stage-publish.md`

View File

@@ -9,6 +9,8 @@ Define canonical artifact IDs, runtime catalog behavior, source resolution rules
- `narratio.transcript.polished` -> `transcripts/polished.json` (`polish`)
- `narratio.transcript.final` -> `transcripts/final.json` (`normalize`)
- `narratio.transcript.final_trimmed` -> `transcripts/final.trimmed.json` (`trim`)
- `narratio.transcript.final_markdown` -> `transcripts/final.md` (`render`)
- `narratio.transcript.final_trimmed_markdown` -> `transcripts/final.trimmed.md` (`render`)
- `narratio.bounds.session` -> `artifacts/session_bounds.json` (`trim`)
## Configured and Previous-Session Sources
@@ -53,7 +55,8 @@ Previous-session sources (`narratio.previous_session.artifact.*`):
Validation by content type:
- transcript built-ins: JSON with top-level `segments` array;
- transcript JSON built-ins: JSON with top-level `segments` array;
- transcript Markdown built-ins: non-empty text file;
- bounds built-in: valid JSON;
- configured/previous-session artifact files: non-empty text file.

View File

@@ -10,6 +10,7 @@ Execute selected configured Scriptorium artifacts in dependency order and materi
Supported source families:
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`
- prepared stable inputs: `narratio.input.players`, `narratio.input.party`, `narratio.input.glossary`
- configured artifacts: `narratio.artifact.<key>`
- previous-session cache: `narratio.previous_session.artifact.<key>`
@@ -23,13 +24,16 @@ Supported source families:
- marks non-executable configured artifacts as reusable when output files already exist.
- validates selected artifact dependency order (cycle-safe topo ordering).
- resolves required/optional inputs per artifact source definition.
- resolves prepared stable input sources from `inputs/*.yml` materialized by `prepare`.
- resolves previous-session sources from local `previous/` cache only.
- runs optional render-debug, then artifact execution.
- validates non-empty output files and materializes canonical outputs.
## Failure Semantics
- required missing configured/previous-session inputs fail.
- missing required prepared stable input source includes prepare rerun guidance.
- missing required previous-session source includes prepare rerun guidance.
- missing required `narratio.transcript.final_markdown` or `narratio.transcript.final_trimmed_markdown` inputs includes render rerun guidance.
- dependency cycles or unavailable required dependencies fail.
- adapter validation failures fail stage.

View File

@@ -5,7 +5,7 @@ Materialize canonical current-session inputs before processing stages.
## Inputs
- resolved `campaign.yml`, `session.yml`, and pipeline config
- stable input files (`speakers`, `autocorrect`, `glossary`)
- stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
- audio source:
- local `audio_dir`/`audio_files`, or
- S3 `audio_s3.prefix`
@@ -18,6 +18,8 @@ Materialize canonical current-session inputs before processing stages.
- `inputs/speakers.yml`
- `inputs/autocorrect.yml`
- `inputs/glossary.yml`
- `inputs/players.yml`
- `inputs/party.yml`
- `audio/*.flac`
- optional `previous/manifest.json`
- optional `previous/artifacts/**`

View File

@@ -4,7 +4,7 @@
Upload run/session outputs to object storage and atomically advance remote current state.
## Inputs
- successful prerequisite stages: `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `analyze`
- successful prerequisite stages: `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `render`, `analyze`
- run root `runs/{run_id}/**`
- publish output rules (`pipeline.publish.outputs`)
- effective publish locks (static + remote merged lock set)

View File

@@ -0,0 +1,29 @@
# Stage: render
## Purpose
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
## Inputs
- `narratio.transcript.final` (`transcripts/final.json`)
- `narratio.transcript.final_trimmed` (`transcripts/final.trimmed.json`)
## Outputs
- `narratio.transcript.final_markdown` -> `transcripts/final.md`
- `narratio.transcript.final_trimmed_markdown` -> `transcripts/final.trimmed.md`
## Key Behavior
- uses `pipeline.render` settings (enabled/format/title/booleans).
- resolves inputs manifest-first, then canonical fallback.
- writes run-local outputs first, then materializes canonical session outputs.
- records input provenance, output paths, adapter metadata, logs, and generated config refs.
- skips with stage metadata when `pipeline.render.enabled=false`.
## Failure Semantics
- missing normalized input fails with normalize rerun guidance.
- missing trimmed input fails with trim rerun guidance.
- adapter/subprocess failure fails stage.
- empty render output files fail validation.
## Invariants
- only `format: markdown` is supported.
- render stage owns production of built-in Markdown transcript sources.

View File

@@ -1,7 +1,7 @@
# Stage: trim
## Purpose
Produce a final-trimmed transcript; optionally generate bounds-driven trim.
Produce a final-trimmed transcript. By default, the stage generates bounds and applies a bounds-driven trim.
## Inputs
- `transcripts/final.json`
@@ -11,9 +11,6 @@ Produce a final-trimmed transcript; optionally generate bounds-driven trim.
- when trim enabled: `artifacts/session_bounds.json`
## Key Behavior
When `trim.enabled=false`:
- copies normalized transcript to trimmed output.
When `trim.enabled=true`:
- runs Scriptorium bounds artifact generation;
- optionally runs render-debug output generation;
@@ -22,6 +19,9 @@ When `trim.enabled=true`:
- either copies unchanged transcript or runs Seriatim trim;
- validates trimmed transcript and materializes bounds output.
When `trim.enabled=false`:
- copies normalized transcript to trimmed output.
## Invariants
- normalized transcript is required input.
- bounds output exists only in enabled trim path.

View File

@@ -4,33 +4,6 @@ Operator workflow for running, recovering, and publishing Narratio sessions.
For command syntax, see [docs/cli.md](./cli.md). For field-level config, see [docs/config.md](./config.md).
## Standard Session Workflow
1. Select pipeline/campaign/session config.
2. Validate session readiness:
```bash
narratio session validate 2026-04-04
```
3. (Optional) inspect stage decisions:
```bash
narratio session plan 2026-04-04
```
4. Run the pipeline:
```bash
narratio run 2026-04-04
```
5. Check state:
```bash
narratio session status 2026-04-04
```
## Campaign and Session Selection
Campaign selection priority:
@@ -63,7 +36,36 @@ narratio session init 2026-04-04 --remote --force
If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
## Stage Execution and Resume Behavior
Campaigns must provide stable input files for speakers, autocorrect, glossary, players, and party. Session files may override those paths for one session. The `prepare` stage materializes them under `inputs/`; configured Scriptorium artifacts can reference prepared `players`, `party`, and `glossary` files with `narratio.input.players`, `narratio.input.party`, and `narratio.input.glossary`.
## Standard Session Workflow
1. Select pipeline/campaign/session config.
2. Validate session readiness:
```bash
narratio session validate 2026-04-04
```
3. (Optional) inspect stage decisions:
```bash
narratio session plan 2026-04-04
```
4. Run the pipeline:
```bash
narratio run 2026-04-04
```
5. Check state:
```bash
narratio session status 2026-04-04
```
## Stage Execution and Continuation Behavior
Canonical stage order:
@@ -73,14 +75,15 @@ Canonical stage order:
4. `polish`
5. `normalize`
6. `trim`
7. `analyze`
8. `publish`
9. `notify`
7. `render`
8. `analyze`
9. `publish`
10. `notify`
Execution rules:
- succeeded stages are skipped unless `--force` is set;
- `resume` starts at first non-succeeded stage;
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
- force rerunning a succeeded upstream stage marks succeeded downstream stages as `stale`.
Single-stage execution:
@@ -91,7 +94,7 @@ narratio run-stage normalize 2026-04-04 --force
## Artifact Selection
`--artifacts` can be used on `run`, `resume`, `run-stage`, `analyze`, and `publish`.
`--artifacts` can be used on `run`, `run-stage`, `analyze`, and `publish`.
Selection behavior:
@@ -244,6 +247,7 @@ Rules:
## Operational Caveats
- Local and S3 audio modes are mutually exclusive.
- Publish requires prerequisite stages through analyze to be succeeded.
- Publish requires prerequisite stages through `render` and `analyze` to be succeeded.
- Markdown publish defaults require render outputs (`transcripts/final.md` and `transcripts/final.trimmed.md`).
- Restore requires configured object storage and committed remote current state.
- Storage-backed commands load filesystem secrets before object-store initialization.

View File

@@ -6,7 +6,7 @@ Canonical contributor workflow and engineering conventions for implemented Narra
## Repository layout
- `cmd/narratio/`: CLI entrypoint.
- `internal/app/`: command handlers, plan/run/resume orchestration, cleanup gates, secrets loading.
- `internal/app/`: command handlers, run/stage orchestration, cleanup gates, secrets loading.
- `internal/config/`: strict YAML loading, defaults, and validation.
- `internal/stage/`: stage implementations and stage registry/order.
- `internal/adapters/`: external boundary adapters (WhisperX, Seriatim, Audita, Scriptorium, storage, notify).

View File

@@ -1,465 +0,0 @@
# Roadmap: Code Quality and Deduplication Audit
Status: Draft audit report
This is a pre-1.0 implementation audit. It identifies remaining high-confidence opportunities to simplify, centralize, or clarify Narratio before release. It is report-only: no code refactors are included here.
The current repository keeps architecture and development policy under `docs/policy/`, not at `docs/architecture.md` or `docs/development.md`. This audit used `docs/policy/architecture.md`, `docs/policy/development.md`, `docs/policy/documentation.md`, the current user/operator docs, the internal docs, code, examples, and tests.
## 1. Executive Summary
Overall code quality is strong. Narratio now has clear package boundaries in the important places:
- configuration loading is strict and centralized enough for current commands;
- object-store construction is app-owned and secret-aware;
- storage adapters do not infer campaign/session/run semantics;
- S3 key construction and local workspace paths are mostly centralized in `internal/artifacts`;
- artifact source vocabulary has a dedicated `internal/artifactpolicy` package;
- remote current-state loading is centralized in `internal/artifacts`;
- cleanup path safety and temp object downloads have shared helpers;
- publish terminology has replaced the old archive/promote surface in live code.
There is no major architectural risk and no reason to delay 1.0 for a broad rewrite. The remaining opportunities are targeted cleanup items where a future bug fix could otherwise need changes in several files.
Top three refactoring targets before 1.0:
1. Finish consolidating artifact source policy for Scriptorium inputs and previous-session candidates.
2. Centralize session-relative path conversion and atomic file install/copy mechanics.
3. Extract a small read-only inspection/preflight layer used by `session validate` and `session status`.
The codebase is ready for a limited cleanup pass. Avoid speculative abstractions.
## 2. High-Confidence Deduplication Opportunities
### Complete Artifact Source Policy for Scriptorium Inputs
Affected files/packages:
- `internal/artifactpolicy`
- `internal/config/validate.go`
- `internal/stage/analyze.go`
- `internal/artifacts/artifact_resolver.go`
- `internal/previouscache/previouscache.go`
- tests under `internal/config`, `internal/stage`, `internal/artifacts`, and `internal/previouscache`
Duplicated or near-duplicated behavior:
- `artifactpolicy.ClassifySource` knows built-in, configured, and previous-session source families.
- `config.validateScriptoriumInputSource` still implements Scriptorium-input-specific validation, including previous-session source parsing, configured artifact lookups, and static built-in checks.
- `stage.resolveScriptoriumInput` classifies sources, then maps missing configured/previous/built-in sources to stage-specific required/optional behavior.
- `previouscache.artifactRelativePathCandidates` reconstructs previous-session artifact candidates from manifest outputs, published paths, and configured output paths.
- `artifacts.ResolveSessionArtifactWithCatalog` and `ResolvePreviousSessionArtifactWithCatalog` own runtime resolution, but caller-side policy still reaches into source details.
Why it matters:
Artifact source IDs are public configuration. They are used by Scriptorium inputs, previous-session inputs, publish outputs, locks, status, artifacts listing, restore, and validation. `internal/artifactpolicy` solved much of this, but Scriptorium input validation and previous-session candidate derivation still contain source-vocabulary logic outside the policy layer.
Recommended refactor:
Extend `internal/artifactpolicy` with narrow Scriptorium-input helpers, not a generic artifact engine:
- `ValidateScriptoriumInputSource(source, configuredKeys)` returning a classified source plus any referenced configured artifact key.
- a shared helper for "is this source a built-in runtime input source?"
- a small previous-session source descriptor used by config validation, previous-cache planning, and analyze resolution.
Keep missing/required behavior at call sites. For example, `analyze` should still decide whether a missing optional input is skipped or a missing required input fails with prepare guidance.
Suggested tests:
- `internal/artifactpolicy`: valid/invalid Scriptorium input sources, previous-session source format, unknown configured references.
- `internal/config`: Scriptorium input validation still reports field-specific errors.
- `internal/stage`: analyze required/optional source behavior unchanged.
- `internal/previouscache`: previous-session requirements and candidate ordering unchanged.
Risk level: Medium. This touches public config validation, but the existing tests are good and the policy surface can remain small.
### Centralize Session-Relative Path Conversion and Safe Local Install Mechanics
Affected files/packages:
- `internal/app/restore_plan.go`
- `internal/app/restore_execute.go`
- `internal/stage/run_local.go`
- `internal/stage/prepare.go`
- `internal/previouscache/previouscache.go`
- `internal/artifacts/local.go`
- `internal/audio/s3_audio.go`
- `internal/manifest/store.go`
- `internal/pathsafe`
Duplicated or near-duplicated behavior:
- `restore_plan.joinWithinSessionRoot` validates session-relative restore targets.
- `previouscache.relativeToSession`, `deriveManifestRelativePath`, and `manifestSessionRoot` convert local manifest paths back into session-relative paths.
- `stage.runLocalPathForCanonical` validates that canonical outputs stay within a session root and skips `previous/**`.
- `artifacts.resolveInRoot` validates relative paths under an artifact root.
- Atomic write/copy/install flows appear in `artifacts.LocalStore`, `audio.MaterializeS3Audio`, `restore_execute`, `manifest.LocalStore`, and `prepare` helper functions.
Why it matters:
These helpers are individually careful, but they all express the same safety policy: relative paths must not escape a scoped root, writes should use temp files plus rename, and cleanup should not leave partial outputs. This is exactly the kind of code where drift is costly.
Recommended refactor:
Add narrow helpers without moving application semantics:
- in `internal/pathsafe`, add session/root helpers such as `JoinWithinRoot(root, rel)` and `RelativeWithinRoot(root, absoluteOrRelative)`;
- add a tiny file operation helper package, or extend `artifacts.LocalStore` carefully, for atomic install/copy/write with optional checksum;
- keep restore scope classification in `restore_plan.go`, because the include/exclude roots are command-specific.
Suggested tests:
- `internal/pathsafe`: root escape, absolute path rejection, Windows-style separators, empty paths, valid session-relative joins.
- `internal/app`: restore conflict/force behavior unchanged.
- `internal/stage`: run-local materialization paths unchanged.
- `internal/audio`: cache hit/miss behavior unchanged.
- `internal/manifest`: atomic manifest save behavior unchanged.
Risk level: Low to Medium. The mechanics are well-contained, but file writes are sensitive and should be protected by focused tests.
### Extract Shared Read-Only Session Inspection Checks
Affected files/packages:
- `internal/app/operator_findings.go`
- `internal/app/operator_session_validate.go`
- `internal/app/operator_status.go`
- `internal/stage/prepare.go`
- `internal/previouscache/previouscache.go`
- `internal/audio`
- `internal/artifacts/current_state.go`
Duplicated or near-duplicated behavior:
- `session validate` resolves stable input paths and checks files in a way that mirrors prepare.
- `session validate` checks local audio or remote audio by listing S3 audio objects, while prepare has the materialization path and restore has audio cache materialization.
- `session validate` checks previous-session current state, while previous-cache planning performs a deeper check and restore uses `previouscache.BuildPlan`.
- `status` independently loads local manifest state, remote current state, effective locks, and published output state.
Why it matters:
These commands are read-only, so divergence does not corrupt state. But operator trust depends on them matching runtime behavior. If validate says a session is ready while prepare would fail, or status reports a remote condition differently from restore, operators get confusing guidance.
Recommended refactor:
Create a small app-level inspection layer, not a workflow engine:
- one helper for resolved stable-input file checks;
- one helper for audio presence checks that mirrors prepare's selection rules without downloading bodies;
- one helper that can run previous-cache planning in a "metadata only / no body download" mode where possible;
- one helper result type that status and validate can render differently.
Do not move output formatting into this layer. Keep command-specific text in `internal/app`.
Suggested tests:
- `internal/app`: validate/status agree on local vs remote audio state.
- `internal/app`: previous-session missing current pointer/manifest is rendered consistently.
- `internal/previouscache`: optional and required previous inputs keep existing behavior.
- `internal/stage`: prepare behavior remains the authority for actual materialization.
Risk level: Medium. The semantic differences between status, validate, prepare, and restore are intentional in places, so the helper should centralize checks, not command policy.
### Atomic File Operation Helpers Are Repeated
Affected files/packages:
- `internal/artifacts/local.go`
- `internal/audio/s3_audio.go`
- `internal/app/restore_execute.go`
- `internal/manifest/store.go`
- `internal/stage/prepare.go`
Duplicated or near-duplicated behavior:
- create parent directory;
- create sibling temp file;
- copy/write bytes;
- sync/close;
- chmod;
- rename;
- remove temp file on failure;
- sometimes compute SHA-256 while copying.
Why it matters:
The duplication is not large, but these are correctness-sensitive operations. A future hardening change, such as more consistent `fsync`, permissions, or Windows behavior, would need repeated edits.
Recommended refactor:
Add a small dependency-light file helper, for example `internal/fileops`, with:
- `WriteAtomic(path, data, perm, pattern)`;
- `CopyAtomic(src, dst, perm)`;
- `CopyAtomicWithSHA256(src, dst, perm)`;
- `InstallDownloadedTemp(tmp, dst, perm)`.
Keep manifest JSON marshaling, audio cache policy, restore plan policy, and artifact store semantics in their existing packages.
Suggested tests:
- temp file removed on write/copy failure;
- destination parent creation;
- checksum from copy matches final file;
- restore and audio cache tests continue to pass.
Risk level: Low. This is mechanical but should be done in a small commit.
## 3. Medium-Confidence Opportunities
### CLI Parsing Is Mostly Centralized, With Intentional Special Cases
Affected files/packages:
- `internal/app/session_args.go`
- `internal/app/operator_helpers.go`
- `internal/app/run.go`
- `internal/app/resume.go`
- `internal/app/run_stage.go`
- `internal/app/operator_locks.go`
- `internal/app/restore.go`
Current state:
Common config flags and session ID matching are centralized enough for 1.0. `run-stage`, `locks add`, and `locks remove` still do command-specific positional parsing because they have extra positional arguments.
Recommended refactor:
Do not introduce a CLI framework. If another session command is added, consider a small parser helper for "session id plus one additional positional argument" so `locks add/remove` and any future similar commands share the same mismatch behavior.
Suggested tests:
- existing session-oriented CLI tests;
- positional session ID plus `--session-id` mismatch;
- missing extra positional source for locks add/remove.
Risk level: Low.
### Restore Scope Mapping Should Probably Stay Local
Affected files/packages:
- `internal/app/restore_plan.go`
- `docs/internal/command-restore.md`
Current state:
Restore maps remote keys back to local paths and intentionally includes only `manifest.json`, `transcripts/**`, `artifacts/**`, and optionally `audio/**`. It explicitly excludes current pointers, run history, logs, reports, configs, inputs, and current-session `previous/**`.
Why it is not a high-priority refactor:
This policy is restore-specific. Generalizing it too much would obscure the command contract.
Recommended refactor:
Leave it local unless restore gains another caller. If it grows, extract only the classifier into a table-driven helper with tests.
Risk level: Low.
### Command Output Formatting Is Intentionally Text-Local
Affected files/packages:
- `internal/app/operator_findings.go`
- `internal/app/operator_artifact_rendering.go`
- `internal/app/operator_locks.go`
- `internal/app/restore_report.go`
- `internal/app/clean.go`
- `internal/app/plan.go`
Current state:
Each command renders plain text directly. Validation-style findings share a small renderer. Artifact list/status share artifact rendering.
Recommended refactor:
Do not add a generic renderer before 1.0. Add focused helper functions only if a user-facing inconsistency is found.
Risk level: Low.
### Manifest Transition Logic Is Dense but Correctly Centralized
Affected files/packages:
- `internal/app/runner.go`
- `internal/manifest`
Current state:
The runner owns manifest/run-manifest lifecycle, stage transitions, downstream stale marking, and post-publish cleanup gating. The function is long, but it is one explicit orchestration path.
Recommended refactor:
Avoid a broad manifest abstraction. If desired after 1.0, extract tiny helpers for repeated save/error wrapping inside the runner only.
Risk level: Low.
## 4. Boundary and Responsibility Concerns
Healthy boundaries:
- `internal/adapters/storage` exposes only `ObjectStore`; AWS SDK types stay inside the S3 backend.
- App code owns secret loading and object-store initialization.
- `internal/artifacts` owns local path and S3 key helpers.
- `internal/artifactpolicy` owns most public source vocabulary.
- `internal/audio` owns S3 audio cache materialization.
- Stage code remains explicit and adapter-facing.
Boundary concerns:
- Scriptorium input source validation still lives partly in `internal/config` instead of fully using source policy.
- Previous-cache candidate selection mixes manifest interpretation, publish metadata, and configured artifact output paths in `internal/previouscache`; this is defensible but should share source descriptors with artifact policy.
- Read-only operator checks mirror stage behavior in `internal/app`; if they grow, they should move into a small inspection layer rather than further expanding command handlers.
- File operation mechanics are repeated across packages; a narrow helper would clarify that write safety is shared mechanics, not stage policy.
Recommended homes:
- source vocabulary and validation: `internal/artifactpolicy`;
- concrete runtime artifact lookup: `internal/artifacts`;
- remote current-state mechanics: `internal/artifacts`;
- command orchestration and rendering: `internal/app`;
- file write mechanics: a small dependency-light helper such as `internal/fileops`, or carefully scoped methods on `artifacts.LocalStore`;
- stage execution policy: `internal/stage`.
## 5. Path and Remote Key Construction Review
Remote key construction is centralized enough for 1.0:
- session, run, audio, session config, locks, current-state, published output, and run-relative keys are in `internal/artifacts/s3_keys.go`;
- storage adapters normalize object keys but do not infer Narratio semantics;
- publish identity helpers resolve bucket/session/run/current-state identities without moving layout policy into storage.
Local path construction is mostly centralized:
- session roots, run roots, previous cache paths, spool paths, and S3 audio cache paths are in `internal/artifacts/paths.go`;
- cleanup target validation is in `internal/app/cleanup_targets.go`;
- relative publish destinations use `internal/pathsafe`.
Areas worth cleanup:
- session-relative conversion helpers are repeated in restore, previous-cache, run-local stage code, and artifacts local-store helpers;
- `restore_plan.go` has local remote-key normalization, which is acceptable for restore but should not spread;
- tests still build some S3 keys through string concatenation when fixture readability would not suffer from using helpers.
Recommended action:
Add narrow `pathsafe` helpers for "join/rel within root" and use them where they reduce escape-check duplication. Do not move restore's include/exclude scope policy out of restore unless it gains another caller.
## 6. Artifact/Catalog/Source Resolution Review
Current state is good:
- transcript source IDs live in `internal/artifactmodel` and are exposed through `internal/artifacts`;
- configured and previous-session source formats live in `internal/artifactpolicy`;
- publish output destination derivation is centralized in `artifactpolicy.ResolvePublishedDestination`;
- runtime catalog behavior is in `internal/artifacts`;
- previous-session requirements are collected by `artifacts.CollectPreviousArtifactRequirements`.
Remaining gap:
`artifactpolicy` does not yet cover the full Scriptorium-input validation contract. `internal/config/validate.go` still knows too much about source parsing and built-in source support. `stage/analyze.go` still has source-family-specific missing input messages, which is appropriate, but it should be consuming a richer policy classification rather than re-checking source strings.
Recommendation:
Extend the policy layer one step further, but keep runtime existence checks in `internal/artifacts` and command/stage missing behavior at the call sites.
## 7. Config and Command-Loading Review
Config loading is consistent:
- `loadCommandConfig` handles pipeline, campaign, local session discovery, remote session fallback, and session identity checks;
- `loadPipelineCampaignConfig` correctly supports `session init`, which cannot load an existing session;
- `newCommandObjectStore` loads secret files before constructing storage;
- concrete-only session loading is enforced in `internal/config`.
Intentional differences:
- `session init` loads pipeline and campaign only.
- `clean --all` is pipeline-scoped, not session-scoped.
- `status`, `validate`, `artifacts`, and `locks` render command-specific output after shared loading.
- `restore` has richer help/output behavior and therefore does slightly more local parsing setup.
Likely accidental drift remaining:
- small positional parsing patterns for commands with extra arguments are repeated;
- `session validate` and `status` each decide which remote checks are warnings, errors, or status lines;
- `loadHelperContext` is useful but not used by every helper because some commands need partial failure reporting.
Recommendation:
No broad loader rewrite is needed. Future command work should reuse `commonConfigFlags`, `parseSessionAwareFlags`, `loadCommandConfig`, `loadPipelineCampaignConfig`, and `newCommandObjectStore`.
## 8. Refactors to Avoid Before 1.0
Avoid:
- a generic workflow engine or DAG abstraction;
- a generic CLI framework;
- broad manifest query or transition abstractions;
- moving secret loading into storage adapters;
- making storage adapters infer campaign/session/root-prefix semantics;
- centralizing all command text output into a generic renderer;
- merging audio cache materialization with generic restore downloads;
- compatibility aliases for retired archive/promote, legacy campaign, or legacy transcript names;
- moving restore's command-specific scope policy into storage or artifacts.
These would add risk without solving current release problems.
## 9. Recommended Implementation Sequence
1. Add root-scoped path helpers and optional atomic file helpers.
- Scope: `internal/pathsafe` plus a small file helper if chosen.
- Tests: `internal/pathsafe`, `internal/audio`, `internal/app -run Restore`, `internal/stage -run Prepare`.
2. Extend artifact source policy for Scriptorium inputs.
- Scope: `internal/artifactpolicy`, `internal/config/validate.go`, `internal/stage/analyze.go`, `internal/previouscache`.
- Tests: `internal/artifactpolicy`, `internal/config`, `internal/stage -run Analyze`, `internal/previouscache`.
3. Extract read-only session inspection checks.
- Scope: stable inputs, audio presence, previous-session readiness, locks/current-state checks.
- Tests: `internal/app -run 'SessionValidate|Status'`, `internal/previouscache`, `internal/stage -run Prepare`.
4. Trim command parsing edge duplication only if needed.
- Scope: commands with session ID plus one extra positional argument.
- Tests: `internal/app -run 'Session|Locks|RunStage'`.
5. Final sweep.
- Run focused searches for retired terminology and old source names.
- Run the focused package tests listed below and then `go test ./...`.
## 10. Test Strategy
Focused checks for any cleanup work:
- `go test ./internal/artifactpolicy -v`
- `go test ./internal/artifacts -v`
- `go test ./internal/config -v`
- `go test ./internal/stage -run 'Analyze|Prepare|Publish' -v`
- `go test ./internal/app -run 'SessionValidate|Status|Restore|Locks|RunStage' -v`
- `go test ./internal/previouscache -v`
- `go test ./internal/audio -v`
- `go test ./internal/pathsafe -v`
- `go test ./internal/adapters/storage -v`
- `go test ./internal/manifest -v`
Full validation after each implementation prompt:
- `go test ./...`
Useful final searches:
- `rg -n "archive|promote|promoted|promotion" internal docs examples cmd`
- `rg -n "narratio.transcript.merged|narratio.transcript.full|narratio.transcript.trimmed" internal docs examples`
- `rg -n "previous_session_artifact|promote_artifacts|pipeline.archive" internal docs examples`
- `rg -n "session_id is required|unexpected positional|--artifacts" internal/app`
- `rg -n "CreateTemp|Rename|copyFileAtomic|WriteFileAtomic|DownloadObjectToTemp" internal`
## 11. Appendix: Findings Not Worth Acting On
- Restore's include/exclude scope logic should remain restore-local. It is command policy, not general path policy.
- Direct text rendering in command handlers is acceptable. The output is text-only and command-specific by design.
- Stage-local path joins under run-local directories are acceptable when they use established session/run roots.
- `run-stage`, `locks add`, and `locks remove` deserve explicit positional parsing because their syntax is not identical to simple session commands.
- The runner is long, but it is the right place for explicit stage orchestration and manifest transitions.
- `session init` template rendering should remain separate from ordinary concrete session loading.
- S3 audio cache materialization is intentionally special and should not be folded into generic object download logic.

View File

@@ -1,206 +0,0 @@
# Roadmap: Pre-1.0 Code Cleanup
Status: Planned
This roadmap turns the remaining findings in `docs/roadmap/audit.md` into decision-complete implementation stages. It follows the policy documents under `docs/policy/` and preserves current public CLI, config, storage layout, manifest, and stage behavior unless a stage explicitly says otherwise.
## Goals
- Reduce repeated path, file, source-policy, and read-only inspection logic before 1.0.
- Keep Narratio explicit, stage-driven, and easy to review.
- Keep storage adapters free of campaign/session/run/root-prefix semantics.
- Keep command output text-only and command-specific.
- Keep implementation changes small enough for focused prompts and focused tests.
## Non-Goals
- Do not introduce a generic workflow engine or DAG abstraction.
- Do not introduce a generic CLI framework.
- Do not introduce a broad manifest abstraction.
- Do not move secret loading into storage adapters.
- Do not make storage adapters infer Narratio path or key semantics.
- Do not add compatibility aliases for retired archive/promote, campaign, transcript, or previous-session source names.
- Do not move restore's remote-key include/exclude policy out of restore unless another caller is added.
## Stage 1: Path and File Mechanics
Add shared mechanics for root-scoped path safety and atomic file operations.
Implementation decisions:
- Add root-scoped helpers in `internal/pathsafe`:
- join a slash-style relative path safely under a root;
- derive a safe slash-style relative path from a path under a root;
- reject empty paths, absolute paths, traversal, and paths outside the root.
- Add a small dependency-light `internal/fileops` package for shared file installation mechanics:
- atomic byte write;
- atomic file copy;
- atomic file copy with SHA-256 checksum;
- install an already-downloaded temp file with permissions.
- Prefer `internal/fileops` over extending `artifacts.LocalStore`, because the same mechanics are used by app, stage, audio, artifacts, and manifest code.
- Keep higher-level semantics local:
- restore decides which remote keys are in scope;
- audio decides cache hit/miss behavior;
- manifest decides JSON marshaling and validation;
- stages decide canonical output materialization.
Implementation targets:
- Replace duplicate root-escape checks in restore planning, run-local output handling, previous-cache path conversion, and artifact local path helpers where doing so keeps behavior identical.
- Replace duplicate temp-write/copy/rename mechanics in artifact local store, audio cache materialization, restore execution, and prepare helpers where the call site can keep its current error context.
- Leave manifest save behavior unchanged if sharing it would obscure manifest-specific validation or error text.
Tests:
- `go test ./internal/pathsafe -v`
- `go test ./internal/audio -v`
- `go test ./internal/app -run Restore -v`
- `go test ./internal/stage -run Prepare -v`
- `go test ./...`
Acceptance criteria:
- Root escape, absolute path, empty path, Windows separator, and valid relative path cases are covered by path-safe tests.
- Atomic helper tests prove temp files are cleaned up on failure and checksums match final file contents.
- Restore, prepare, and audio cache behavior remain unchanged.
## Stage 2: Artifact Source Policy
Finish centralizing artifact source vocabulary and validation in `internal/artifactpolicy`.
Implementation decisions:
- Extend `internal/artifactpolicy` with Scriptorium-input policy helpers:
- classify and validate built-in, configured, and previous-session source IDs;
- validate referenced configured artifact keys against the configured artifact set;
- expose a previous-session source descriptor for callers that need the configured artifact key.
- Keep runtime artifact lookup in `internal/artifacts`.
- Keep missing, required, optional, and operator-guidance behavior at call sites:
- config validation still produces field-specific errors;
- analyze still decides whether missing inputs fail or skip;
- previous-cache planning still decides required vs optional behavior.
- Do not make `artifactpolicy` inspect manifests, files, object storage, or runtime catalogs.
Implementation targets:
- Replace source parsing and static built-in checks in config validation with artifactpolicy helpers.
- Update analyze input resolution to consume the shared classification/descriptors while preserving current error messages and required/optional behavior.
- Update previous-cache planning to use shared previous-session source descriptors where source vocabulary is involved.
- Keep publish output destination derivation through `artifactpolicy.ResolvePublishedDestination`.
Tests:
- `go test ./internal/artifactpolicy -v`
- `go test ./internal/config -v`
- `go test ./internal/stage -run Analyze -v`
- `go test ./internal/previouscache -v`
- `go test ./...`
Acceptance criteria:
- Valid Scriptorium input source cases pass through one shared policy path.
- Invalid source format and unknown configured artifact references keep clear config-field errors.
- Analyze behavior for required/optional built-in, configured, and previous-session sources is unchanged.
- Previous-cache candidate ordering and required/optional behavior are unchanged.
## Stage 3: Read-Only Inspection Layer
Extract shared read-only session inspection checks for `session validate` and `session status`.
Implementation decisions:
- Keep the new inspection helpers in `internal/app`; they are command orchestration helpers, not stage or storage adapter behavior.
- Create small result types for checks, but do not create a generic reporting framework.
- Keep command-specific rendering local:
- `session validate` renders findings and fails on `ERROR`;
- `session status` renders state and does not fail for missing local/remote state unless config loading fails.
- Do not download artifact bodies for inspection unless current behavior already does so.
Implementation targets:
- Extract stable input checks from `operator_findings.go` into a reusable inspection helper.
- Extract local and remote audio presence checks that mirror prepare's selection rules without materializing audio.
- Extract previous-session readiness checks using existing current-state and previous-cache planning mechanics where possible.
- Extract effective lock and remote current-state inspection into reusable command helpers.
- Keep artifact catalog rendering separate from these checks.
Tests:
- `go test ./internal/app -run 'SessionValidate|Status' -v`
- `go test ./internal/previouscache -v`
- `go test ./internal/stage -run Prepare -v`
- `go test ./...`
Acceptance criteria:
- `session validate` and `session status` agree on local/remote audio and previous-session readiness facts.
- Missing current run pointer/manifest behavior remains command-appropriate: validation reports an error; status reports unavailable state.
- Existing lock and remote current-state behavior is unchanged.
## Stage 4: Optional CLI Edge Cleanup
Only implement this stage if Stage 1-3 leave meaningful repeated parser code.
Implementation decisions:
- Add at most one small parser helper for commands with `session_id` plus one additional positional argument.
- Use it for `session locks add` and `session locks remove` if it reduces duplication without obscuring syntax.
- Keep command handlers explicit.
- Do not change public syntax, flag names, help text meaning, or error semantics.
Tests:
- `go test ./internal/app -run 'Session|Locks|RunStage' -v`
- `go test ./...`
Acceptance criteria:
- Positional `session_id` and `--session-id` mismatch errors remain unchanged.
- Missing source arguments for lock add/remove remain clear.
- No new command aliases are introduced.
## Stage 5: Final Sweep
Run final validation after the implementation stages.
Required searches:
- `rg -n "archive|promote|promoted|promotion" internal docs examples cmd`
- `rg -n "narratio.transcript.merged|narratio.transcript.full|narratio.transcript.trimmed" internal docs examples`
- `rg -n "previous_session_artifact|promote_artifacts|pipeline.archive" internal docs examples`
- `rg -n "CreateTemp|Rename|copyFileAtomic|WriteFileAtomic|DownloadObjectToTemp" internal`
Expected search results:
- Retired terminology should remain only where intentionally historical or where fixture names make it unrelated to current behavior.
- Old transcript and old config/source names should not appear in runtime code, tests, examples, or current-behavior docs.
- File-operation searches should show centralized helpers plus acceptable direct uses where package-specific behavior remains intentional.
Required tests:
- `go test ./internal/artifactpolicy -v`
- `go test ./internal/artifacts -v`
- `go test ./internal/config -v`
- `go test ./internal/stage -run 'Analyze|Prepare|Publish' -v`
- `go test ./internal/app -run 'SessionValidate|Status|Restore|Locks|RunStage' -v`
- `go test ./internal/previouscache -v`
- `go test ./internal/audio -v`
- `go test ./internal/pathsafe -v`
- `go test ./internal/adapters/storage -v`
- `go test ./internal/manifest -v`
- `go test ./...`
Roadmap cleanup:
- Mark each completed stage as implemented only after its code, tests, and any internal docs are updated.
- Keep this file as planned work until the implementation stages land.
- Do not update canonical user/operator docs unless implementation changes visible behavior, which this roadmap does not intend.
## Assumptions
- Public CLI and config behavior remains unchanged throughout this cleanup.
- `internal/fileops` is the preferred home for shared atomic file mechanics.
- `internal/artifactpolicy` remains the source-policy home.
- `internal/artifacts` remains the path/key/current-state home.
- `internal/app` remains the command-loading, inspection, and rendering home.
- Stage packages keep stage execution policy and adapter interaction.

View File

@@ -217,6 +217,33 @@ Safe fix:
- correct publish source/destination rules;
- retry after storage failure is resolved.
## Render markdown source missing
Symptom:
- analyze or publish fails because `narratio.transcript.final_markdown` or `narratio.transcript.final_trimmed_markdown` is unavailable.
Likely causes:
- render stage was not executed after transcript changes;
- render stage failed before producing canonical markdown outputs.
Diagnostics:
```bash
narratio session status 2026-04-04
narratio run-stage render 2026-04-04 --force
```
Safe fix:
- rerun render and then retry downstream stage(s):
```bash
narratio run-stage render 2026-04-04 --force
narratio run-stage analyze 2026-04-04 --force
```
## Secrets or storage credential failure
Symptom:

View File

@@ -4,3 +4,5 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml

View File

@@ -0,0 +1,2 @@
- name: Example Hero
type: pc

View File

@@ -0,0 +1,2 @@
- name: Example Player
role: player

View File

@@ -48,6 +48,12 @@ publish:
- source: narratio.transcript.final_trimmed
dest: transcripts/final.trimmed.json
required: true
- source: narratio.transcript.final_markdown
dest: transcripts/final.md
required: true
- source: narratio.transcript.final_trimmed_markdown
dest: transcripts/final.trimmed.md
required: true
- source: narratio.artifact.session_recap
dest: artifacts/session_recap.md
required: true
@@ -104,17 +110,16 @@ normalize:
report: true
trim:
# Keep disabled unless bounds prompt integration is configured.
enabled: false
# Optional; defaults shown explicitly.
enabled: true
output_path: transcripts/final.trimmed.json
bounds:
prompt_id: dnd.session_bounds
profile_id: local-fast
profile_id: ""
transcript_input_name: transcript
output_path: reports/session_bounds.json
output_path: artifacts/session_bounds.json
timeout: 10m
render_debug: false
render_output_path: reports/session_bounds.render.json
seriatim:
report: false
@@ -138,6 +143,15 @@ scriptorium:
previous_recap:
source: narratio.previous_session.artifact.session_recap
required: false
players:
source: narratio.input.players
required: true
party:
source: narratio.input.party
required: true
glossary:
source: narratio.input.glossary
required: false
vars:
session_id: true
session_date: true

View File

@@ -26,6 +26,12 @@ publish:
- source: narratio.transcript.final_trimmed
dest: transcripts/final.trimmed.json
required: true
- source: narratio.transcript.final_markdown
dest: transcripts/final.md
required: true
- source: narratio.transcript.final_trimmed_markdown
dest: transcripts/final.trimmed.md
required: true
- source: narratio.artifact.session_recap
dest: artifacts/session_recap.md
required: true
@@ -65,9 +71,6 @@ normalize:
output_schema: seriatim-intermediate
report: true
trim:
enabled: false
scriptorium:
binary: scriptorium
config_path: /usr/local/etc/scriptorium/config.yml
@@ -87,6 +90,15 @@ scriptorium:
previous_recap:
source: narratio.previous_session.artifact.session_recap
required: false
players:
source: narratio.input.players
required: true
party:
source: narratio.input.party
required: true
glossary:
source: narratio.input.glossary
required: false
vars:
session_id: true
session_date: true

View File

@@ -68,6 +68,26 @@ func (n *NoopRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
}, nil
}
// Render returns the requested output path with placeholder metadata.
func (n *NoopRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
if err := ctx.Err(); err != nil {
return RenderResult{}, err
}
if err := materializeRenderPlaceholders(req); err != nil {
return RenderResult{}, err
}
return RenderResult{
OutputRenderedPath: req.OutputRenderedPath,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath,
InvokedBinary: "noop",
Format: req.Format,
Title: req.Title,
Metadata: map[string]any{"placeholder": true},
}, nil
}
// FakeRunner captures merge requests and returns deterministic responses.
type FakeRunner struct {
Requests []MergeRequest
@@ -79,6 +99,9 @@ type FakeRunner struct {
TrimRequests []TrimRequest
TrimErr error
TrimResult TrimResult
RenderRequests []RenderRequest
RenderErr error
RenderResult RenderResult
}
// Run records request and returns configured response.
@@ -195,6 +218,46 @@ func (f *FakeRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
return res, nil
}
// Render records request and returns configured response.
func (f *FakeRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
if err := ctx.Err(); err != nil {
return RenderResult{}, err
}
f.RenderRequests = append(f.RenderRequests, req)
if f.RenderErr != nil {
return RenderResult{}, f.RenderErr
}
if err := materializeRenderPlaceholders(req); err != nil {
return RenderResult{}, err
}
res := f.RenderResult
if res.OutputRenderedPath == "" {
res.OutputRenderedPath = req.OutputRenderedPath
}
if res.StdoutLogPath == "" {
res.StdoutLogPath = req.StdoutLogPath
}
if res.StderrLogPath == "" {
res.StderrLogPath = req.StderrLogPath
}
if res.GeneratedConfigPath == "" {
res.GeneratedConfigPath = req.GeneratedConfigPath
}
if res.InvokedBinary == "" {
res.InvokedBinary = "fake"
}
if res.Format == "" {
res.Format = req.Format
}
if res.Title == "" {
res.Title = req.Title
}
if res.Metadata == nil {
res.Metadata = map[string]any{"fake": true}
}
return res, nil
}
func materializePlaceholders(req MergeRequest) error {
if req.OutputMergedTranscriptPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil {
@@ -301,3 +364,39 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error {
}
return nil
}
func materializeRenderPlaceholders(req RenderRequest) error {
if req.OutputRenderedPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), 0o644); err != nil {
return fmt.Errorf("write rendered transcript %q: %w", req.OutputRenderedPath, err)
}
}
if req.GeneratedConfigPath != "" {
payload := map[string]any{
"schema": "seriatim.generated.v1",
"placeholder": true,
"command": "render",
"input_path": req.InputTranscriptPath,
"output_path": req.OutputRenderedPath,
"format": req.Format,
"title": req.Title,
"include_timestamps": req.IncludeTimestamps,
"include_segment_ids": req.IncludeSegmentIDs,
"include_metadata": req.IncludeMetadata,
}
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
}
}
if req.StdoutLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), 0o644); err != nil {
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
}
}
if req.StderrLogPath != "" {
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), 0o644); err != nil {
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
}
}
return nil
}

View File

@@ -148,3 +148,58 @@ func TestFakeRunnerNormalizeError(t *testing.T) {
t.Fatal("expected error, got nil")
}
}
func TestFakeRunnerRenderCapturesRequestAndReturnsPath(t *testing.T) {
fake := &FakeRunner{}
dir := t.TempDir()
req := RenderRequest{
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.render.yml"),
InputTranscriptPath: filepath.Join(dir, "transcripts", "final.trimmed.json"),
OutputRenderedPath: filepath.Join(dir, "transcripts", "final.trimmed.md"),
Format: "markdown",
Title: "Session render",
IncludeTimestamps: true,
IncludeSegmentIDs: false,
IncludeMetadata: true,
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.render.stdout.log"),
StderrLogPath: filepath.Join(dir, "logs", "seriatim.render.stderr.log"),
}
res, err := fake.Render(context.Background(), req)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
if len(fake.RenderRequests) != 1 || fake.RenderRequests[0].GeneratedConfigPath == "" {
t.Fatalf("render requests = %#v, want captured request", fake.RenderRequests)
}
if res.OutputRenderedPath != req.OutputRenderedPath {
t.Fatalf("rendered path = %q, want %q", res.OutputRenderedPath, req.OutputRenderedPath)
}
if res.Format != req.Format {
t.Fatalf("format = %q, want %q", res.Format, req.Format)
}
if res.Title != req.Title {
t.Fatalf("title = %q, want %q", res.Title, req.Title)
}
cfgData, err := os.ReadFile(req.GeneratedConfigPath)
if err != nil {
t.Fatalf("read generated config: %v", err)
}
if !strings.Contains(string(cfgData), "command: render") {
t.Fatalf("generated config = %q, want render command marker", string(cfgData))
}
for _, path := range []string{req.StdoutLogPath, req.StderrLogPath, req.OutputRenderedPath} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected file %q to exist: %v", path, err)
}
}
}
func TestFakeRunnerRenderError(t *testing.T) {
fake := &FakeRunner{RenderErr: errors.New("boom")}
_, err := fake.Render(context.Background(), RenderRequest{})
if err == nil {
t.Fatal("expected error, got nil")
}
}

View File

@@ -1,4 +1,4 @@
// Package seriatim declares the adapter contract for transcript merge/normalize/trim execution.
// Package seriatim declares the adapter contract for transcript merge/normalize/trim/render execution.
package seriatim
import (
@@ -6,11 +6,12 @@ import (
"time"
)
// Runner is the adapter boundary for seriatim merge/normalize/trim invocations.
// Runner is the adapter boundary for seriatim merge/normalize/trim/render invocations.
type Runner interface {
Run(ctx context.Context, req MergeRequest) (MergeResult, error)
Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error)
Trim(ctx context.Context, req TrimRequest) (TrimResult, error)
Render(ctx context.Context, req RenderRequest) (RenderResult, error)
}
// MergeRequest describes a seriatim merge invocation.
@@ -90,3 +91,33 @@ type TrimResult struct {
KeepSelector string
Metadata map[string]any
}
// RenderRequest describes a seriatim render invocation.
type RenderRequest struct {
Binary string
InputTranscriptPath string
OutputRenderedPath string
Format string
Title string
IncludeTimestamps bool
IncludeSegmentIDs bool
IncludeMetadata bool
StdoutLogPath string
StderrLogPath string
GeneratedConfigPath string
Timeout time.Duration
}
// RenderResult describes a render output.
type RenderResult struct {
OutputRenderedPath string
StdoutLogPath string
StderrLogPath string
GeneratedConfigPath string
ExitCode int
Duration time.Duration
InvokedBinary string
Format string
Title string
Metadata map[string]any
}

View File

@@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
)
@@ -384,6 +385,96 @@ func (r *SubprocessRunner) Normalize(ctx context.Context, req NormalizeRequest)
}, nil
}
// Render executes Seriatim render with deterministic flags and validates non-empty text output.
func (r *SubprocessRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
if r == nil {
return RenderResult{}, fmt.Errorf("seriatim subprocess runner is nil")
}
if strings.TrimSpace(req.InputTranscriptPath) == "" {
return RenderResult{}, fmt.Errorf("seriatim render input path is required")
}
if strings.TrimSpace(req.OutputRenderedPath) == "" {
return RenderResult{}, fmt.Errorf("seriatim render output path is required")
}
format := strings.TrimSpace(req.Format)
if format == "" {
format = "markdown"
}
if format != "markdown" {
return RenderResult{}, fmt.Errorf("seriatim render format %q is unsupported", req.Format)
}
binary := r.binary
if strings.TrimSpace(req.Binary) != "" {
binary = strings.TrimSpace(req.Binary)
}
timeout := r.timeout
if req.Timeout < 0 {
return RenderResult{}, fmt.Errorf("seriatim render timeout must be >= 0")
}
if req.Timeout > 0 {
timeout = req.Timeout
}
args := buildRenderArgs(req, format)
if req.GeneratedConfigPath != "" {
if err := writeRenderInvocationConfig(req, args, binary, timeout, format); err != nil {
return RenderResult{}, fmt.Errorf("write seriatim render invocation config %q: %w", req.GeneratedConfigPath, err)
}
}
runRes, err := subprocess.Run(ctx, subprocess.RunRequest{
Executable: binary,
Args: args,
Timeout: timeout,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
})
if err != nil {
return RenderResult{
OutputRenderedPath: req.OutputRenderedPath,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath,
ExitCode: runRes.ExitCode,
Duration: runRes.Duration,
InvokedBinary: binary,
Format: format,
Title: req.Title,
}, fmt.Errorf("run seriatim render (binary=%q): %w", binary, err)
}
if err := validateNonEmptyTextFile(req.OutputRenderedPath); err != nil {
return RenderResult{
OutputRenderedPath: req.OutputRenderedPath,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath,
ExitCode: runRes.ExitCode,
Duration: runRes.Duration,
InvokedBinary: binary,
Format: format,
Title: req.Title,
}, fmt.Errorf("validate seriatim rendered output %q: %w", req.OutputRenderedPath, err)
}
return RenderResult{
OutputRenderedPath: req.OutputRenderedPath,
StdoutLogPath: req.StdoutLogPath,
StderrLogPath: req.StderrLogPath,
GeneratedConfigPath: req.GeneratedConfigPath,
ExitCode: runRes.ExitCode,
Duration: runRes.Duration,
InvokedBinary: binary,
Format: format,
Title: req.Title,
Metadata: map[string]any{
"adapter": "seriatim_subprocess",
},
}, nil
}
func (r *SubprocessRunner) buildMergeArgs(req MergeRequest) []string {
args := []string{"merge"}
@@ -480,6 +571,22 @@ func buildNormalizeArgs(req NormalizeRequest, outputSchema string) []string {
return args
}
func buildRenderArgs(req RenderRequest, format string) []string {
args := []string{
"render",
"--input-file", req.InputTranscriptPath,
"--output-file", req.OutputRenderedPath,
"--format", format,
"--include-timestamps=" + strconv.FormatBool(req.IncludeTimestamps),
"--include-segment-ids=" + strconv.FormatBool(req.IncludeSegmentIDs),
"--include-metadata=" + strconv.FormatBool(req.IncludeMetadata),
}
if strings.TrimSpace(req.Title) != "" {
args = append(args, "--title", req.Title)
}
return args
}
func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, timeout time.Duration) error {
payload := map[string]any{
"schema": "seriatim.generated.v1",
@@ -509,6 +616,24 @@ func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
}
func writeRenderInvocationConfig(req RenderRequest, args []string, binary string, timeout time.Duration, format string) error {
payload := map[string]any{
"schema": "seriatim.generated.v1",
"command": "render",
"binary": binary,
"args": args,
"timeout": timeout.String(),
"input_path": req.InputTranscriptPath,
"output_path": req.OutputRenderedPath,
"format": format,
"title": req.Title,
"include_timestamps": req.IncludeTimestamps,
"include_segment_ids": req.IncludeSegmentIDs,
"include_metadata": req.IncludeMetadata,
}
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
}
func validateJSONFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
@@ -541,3 +666,20 @@ func validateJSONFileWithSegments(path string) error {
}
return nil
}
func validateNonEmptyTextFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read file: %w", err)
}
if len(data) == 0 {
return fmt.Errorf("file is empty")
}
if !utf8.Valid(data) {
return fmt.Errorf("file is not valid utf-8 text")
}
if strings.TrimSpace(string(data)) == "" {
return fmt.Errorf("file has no non-whitespace content")
}
return nil
}

View File

@@ -569,6 +569,156 @@ func TestSubprocessRunnerNormalizeInvalidReportJSONFails(t *testing.T) {
}
}
func TestSubprocessRunnerRenderSuccessInvocationAndProvenance(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath)
wrapper := writeHelperWrapper(t)
runner := mustRunner(t, wrapper, false)
req := renderReqForTest(t)
res, err := runner.Render(context.Background(), req)
if err != nil {
t.Fatalf("Render() error = %v", err)
}
if res.OutputRenderedPath != req.OutputRenderedPath {
t.Fatalf("OutputRenderedPath = %q, want %q", res.OutputRenderedPath, req.OutputRenderedPath)
}
if res.Format != req.Format {
t.Fatalf("Format = %q, want %q", res.Format, req.Format)
}
if res.Title != req.Title {
t.Fatalf("Title = %q, want %q", res.Title, req.Title)
}
if res.InvokedBinary != wrapper {
t.Fatalf("InvokedBinary = %q, want %q", res.InvokedBinary, wrapper)
}
if res.ExitCode != 0 {
t.Fatalf("ExitCode = %d, want 0", res.ExitCode)
}
if res.Duration <= 0 {
t.Fatalf("Duration = %s, want >0", res.Duration)
}
if res.Metadata == nil || res.Metadata["adapter"] != "seriatim_subprocess" {
t.Fatalf("Metadata = %#v, want adapter marker", res.Metadata)
}
if _, err := os.Stat(req.OutputRenderedPath); err != nil {
t.Fatalf("rendered output missing: %v", err)
}
if _, err := os.Stat(req.StdoutLogPath); err != nil {
t.Fatalf("stdout log missing: %v", err)
}
if _, err := os.Stat(req.StderrLogPath); err != nil {
t.Fatalf("stderr log missing: %v", err)
}
if _, err := os.Stat(req.GeneratedConfigPath); err != nil {
t.Fatalf("generated config missing: %v", err)
}
rec := readHelperRecord(t, recordPath)
wantArgs := []string{
"render",
"--input-file", req.InputTranscriptPath,
"--output-file", req.OutputRenderedPath,
"--format", req.Format,
"--include-timestamps=true",
"--include-segment-ids=true",
"--include-metadata=false",
"--title", req.Title,
}
if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") {
t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs)
}
}
func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
recordPath := filepath.Join(t.TempDir(), "record.json")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath)
runner := mustRunner(t, writeHelperWrapper(t), false)
req := renderReqForTest(t)
req.Title = ""
if _, err := runner.Render(context.Background(), req); err != nil {
t.Fatalf("Render() error = %v", err)
}
rec := readHelperRecord(t, recordPath)
for i := 0; i < len(rec.Args); i++ {
if rec.Args[i] == "--title" {
t.Fatalf("args = %#v, did not expect --title", rec.Args)
}
}
}
func TestSubprocessRunnerRenderSubprocessFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "fail")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustRunner(t, writeHelperWrapper(t), false)
req := renderReqForTest(t)
_, err := runner.Render(context.Background(), req)
if err == nil {
t.Fatal("Render() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), "run seriatim render") {
t.Fatalf("error = %q, want subprocess context", err.Error())
}
}
func TestSubprocessRunnerRenderMissingOutputFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustRunner(t, writeHelperWrapper(t), false)
req := renderReqForTest(t)
_, err := runner.Render(context.Background(), req)
if err == nil {
t.Fatal("Render() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), "validate seriatim rendered output") {
t.Fatalf("error = %q, want output validation context", err.Error())
}
}
func TestSubprocessRunnerRenderEmptyOutputFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("helper wrapper script uses /bin/sh")
}
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
t.Setenv("SERIATIM_HELPER_MODE", "render_empty_output")
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
runner := mustRunner(t, writeHelperWrapper(t), false)
req := renderReqForTest(t)
_, err := runner.Render(context.Background(), req)
if err == nil {
t.Fatal("Render() error = nil, want non-nil")
}
if !strings.Contains(err.Error(), "file is empty") {
t.Fatalf("error = %q, want empty-file validation", err.Error())
}
}
func TestSubprocessRunnerConstructorValidation(t *testing.T) {
_, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{})
if err == nil {
@@ -702,6 +852,14 @@ func TestSeriatimSubprocessHelper(t *testing.T) {
case "normalize_report_missing":
writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
os.Exit(0)
case "render_success":
writeSeriatimHelperFile(outputPath, "# Rendered transcript\n\nHello.\n")
_, _ = os.Stdout.WriteString("seriatim helper render stdout\n")
_, _ = os.Stderr.WriteString("seriatim helper render stderr\n")
os.Exit(0)
case "render_empty_output":
writeSeriatimHelperFile(outputPath, "")
os.Exit(0)
default:
_, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode))
os.Exit(2)
@@ -777,6 +935,25 @@ func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest {
return req
}
func renderReqForTest(t *testing.T) RenderRequest {
t.Helper()
dir := t.TempDir()
input := filepath.Join(dir, "final.trimmed.json")
writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
return RenderRequest{
InputTranscriptPath: input,
OutputRenderedPath: filepath.Join(dir, "final.trimmed.md"),
Format: "markdown",
Title: "Session 42",
IncludeTimestamps: true,
IncludeSegmentIDs: true,
IncludeMetadata: false,
GeneratedConfigPath: filepath.Join(dir, "seriatim.render.generated.yml"),
StdoutLogPath: filepath.Join(dir, "seriatim.render.stdout.log"),
StderrLogPath: filepath.Join(dir, "seriatim.render.stderr.log"),
}
}
func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner {
t.Helper()
coalesce := 3.0

View File

@@ -120,14 +120,14 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
}
}
func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{}
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
}
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
@@ -135,16 +135,16 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
}
var out bytes.Buffer
err := Resume(
err := Run(
context.Background(),
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&out,
)
if err != nil {
t.Fatalf("Resume() error = %v", err)
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "has no remaining stages") {
t.Fatalf("output = %q, want no remaining stages", out.String())
if !strings.Contains(out.String(), "executed=0 skipped=10") {
t.Fatalf("output = %q, want all stages skipped", out.String())
}
}

View File

@@ -7,7 +7,7 @@ import (
"strings"
)
var supportedCommands = []string{"run", "run-stage", "resume", "analyze", "publish", "clean", "session"}
var supportedCommands = []string{"run", "run-stage", "analyze", "publish", "clean", "session"}
// Execute dispatches CLI commands and returns a process exit code.
func Execute(args []string, stdout, stderr io.Writer) int {
@@ -24,8 +24,6 @@ func Execute(args []string, stdout, stderr io.Writer) int {
switch cmd {
case "run":
err = Run(ctx, cmdArgs, stdout)
case "resume":
err = Resume(ctx, cmdArgs, stdout)
case "run-stage":
err = RunStage(ctx, cmdArgs, stdout)
case "analyze":

View File

@@ -31,10 +31,9 @@ func TestExecuteValidCommands(t *testing.T) {
args []string
wantOut string
}{
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=10 skipped=0; manifest="},
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nrender: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
{name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
}
@@ -66,7 +65,7 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
{name: "run missing session", args: []string{"run"}, want: "run: session_id is required"},
{name: "plan old top-level removed", args: []string{"plan"}, want: `unknown command: "plan"`},
{name: "status old top-level removed", args: []string{"status"}, want: `unknown command: "status"`},
{name: "resume missing session", args: []string{"resume"}, want: "resume: session_id is required"},
{name: "resume removed", args: []string{"resume"}, want: `unknown command: "resume"`},
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected stage name and session_id"},
{name: "run-stage missing session", args: []string{"run-stage", "polish"}, want: "run-stage: expected stage name and session_id"},
{name: "run missing config uses defaults", args: []string{"run", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
@@ -228,6 +227,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err)
@@ -291,6 +292,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err)
@@ -332,7 +335,7 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=9 skipped=0; manifest=") {
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=10 skipped=0; manifest=") {
t.Fatalf("stdout = %q, want successful run output", stdout.String())
}
}
@@ -386,10 +389,14 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`)
mustWriteTestFile(t, filepath.Join(otherDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "players.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(otherDir, "party.yml"), "[]\n")
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -467,10 +474,13 @@ func writeValidConfigFiles(t *testing.T, workspaceRoot string, transcribeURL ...
url = transcribeURL[0]
}
seriatimBinary := writeSeriatimAppTestWrapper(t)
scriptoriumBinary := writeScriptoriumAppTestWrapper(t)
auditaBinary := writeAuditaAppTestWrapper(t)
t.Setenv("GO_WANT_APP_SERIATIM_HELPER", "1")
t.Setenv("GO_WANT_APP_SCRIPTORIUM_HELPER", "1")
t.Setenv("GO_WANT_APP_AUDITA_HELPER", "1")
t.Setenv("AUDITA_LLM_API_KEY", "test-audita-key")
t.Setenv("PATH", filepath.Dir(scriptoriumBinary)+string(os.PathListSeparator)+os.Getenv("PATH"))
pipelineYAML := `workspace:
root: ` + workspaceRoot + `
@@ -515,6 +525,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
@@ -533,6 +545,8 @@ inputs:
mustWriteTestFile(t, filepath.Join(campaignDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(campaignDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(campaignDir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(campaignDir, "players.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, campaignPath, sessionPath
@@ -546,6 +560,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err)
@@ -592,6 +608,60 @@ func writeSeriatimAppTestWrapper(t *testing.T) string {
return path
}
func writeScriptoriumAppTestWrapper(t *testing.T) string {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatalf("os.Executable() error = %v", err)
}
path := filepath.Join(t.TempDir(), "scriptorium")
content := "#!/bin/sh\nexec \"" + exe + "\" -test.run=TestScriptoriumAppHelper -- \"$@\"\n"
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatalf("WriteFile(%q): %v", path, err)
}
return path
}
func TestScriptoriumAppHelper(t *testing.T) {
if os.Getenv("GO_WANT_APP_SCRIPTORIUM_HELPER") != "1" {
return
}
args := os.Args
start := -1
for i := range args {
if args[i] == "--" {
start = i + 1
break
}
}
if start < 0 || start >= len(args) {
_, _ = os.Stderr.WriteString("missing -- args separator\n")
os.Exit(2)
}
runArgs := args[start:]
outputPath := appSeriatimFlagValue(runArgs, "--out")
if strings.TrimSpace(outputPath) == "" {
outputPath = appSeriatimFlagValue(runArgs, "--output")
}
if strings.TrimSpace(outputPath) == "" {
_, _ = os.Stderr.WriteString("missing output flag\n")
os.Exit(2)
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
_, _ = os.Stderr.WriteString(fmt.Sprintf("mkdir output dir: %v\n", err))
os.Exit(2)
}
if err := os.WriteFile(outputPath, []byte(`{"trim_action":"copy","warnings":[]}`), 0o644); err != nil {
_, _ = os.Stderr.WriteString(fmt.Sprintf("write output: %v\n", err))
os.Exit(2)
}
_, _ = os.Stdout.WriteString("scriptorium helper stdout\n")
_, _ = os.Stderr.WriteString("scriptorium helper stderr\n")
os.Exit(0)
}
func TestSeriatimAppHelper(t *testing.T) {
if os.Getenv("GO_WANT_APP_SERIATIM_HELPER") != "1" {
return

View File

@@ -32,15 +32,10 @@ func buildHelperArtifactCatalog(cfg *config.Config) (*artifacts.ArtifactCatalog,
func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.ArtifactCatalog, locks *effectiveLocks, publishedRemoteState map[string]string) {
lockSet := lockSourceSet(locks.All)
fmt.Fprintln(out, "Built-in:")
for _, id := range []string{
artifacts.ArtifactTranscriptBase,
artifacts.ArtifactTranscriptPolished,
artifacts.ArtifactTranscriptFinal,
artifacts.ArtifactTranscriptFinalTrimmed,
artifacts.ArtifactBoundsSession,
} {
writeArtifactLine(out, id, lockSet)
for _, transcript := range artifacts.RuntimeTranscriptArtifacts() {
writeArtifactLine(out, transcript.SourceID, lockSet)
}
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
fmt.Fprintln(out, "Configured:")
for _, entry := range catalog.ListConfigured() {
writeArtifactLine(out, entry.SourceID, lockSet)

View File

@@ -10,7 +10,6 @@ import (
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
@@ -64,26 +63,18 @@ func sessionSourceSummary(cfg *config.Config) string {
}
func validateStableInputFindings(cfg *config.Config) []finding {
items := []struct {
name string
in config.ResolvedInputFile
}{
{"speakers", cfg.StableInputs.SpeakersFile},
{"autocorrect", cfg.StableInputs.AutocorrectFile},
{"glossary", cfg.StableInputs.GlossaryFile},
checks := inspectStableInputs(cfg)
out := make([]finding, 0, len(checks))
for _, check := range checks {
if check.Err != nil {
msg := check.Name + ": " + check.Err.Error()
if strings.TrimSpace(check.Path) != "" {
msg = fmt.Sprintf("%s missing: %v", check.Name, check.Err)
}
out := make([]finding, 0, len(items))
for _, item := range items {
path, err := resolveHelperConfigRelativePath(item.in)
if err != nil {
out = append(out, errorFinding("inputs", item.name+": "+err.Error()))
out = append(out, errorFinding("inputs", msg))
continue
}
if _, err := os.Stat(path); err != nil {
out = append(out, errorFinding("inputs", fmt.Sprintf("%s missing: %v", item.name, err)))
} else {
out = append(out, okFinding("inputs", item.name+": "+path))
}
out = append(out, okFinding("inputs", check.Name+": "+check.Path))
}
return out
}
@@ -103,76 +94,22 @@ func resolveHelperConfigRelativePath(input config.ResolvedInputFile) (string, er
}
func validateLocalAudioFindings(cfg *config.Config) []finding {
if cfg.Session.Inputs.AudioS3 != nil {
check := inspectLocalAudioPresence(cfg)
if !check.Checked {
return nil
}
audioDir := strings.TrimSpace(cfg.Session.Inputs.AudioDir)
if audioDir == "" && len(cfg.Session.Inputs.AudioFiles) == 0 {
return []finding{errorFinding("audio", "audio_dir, audio_files, or audio_s3 is required")}
if check.Err != nil {
return []finding{errorFinding("audio", check.Err.Error())}
}
base := filepath.Dir(cfg.SessionPath)
paths := []string{}
if audioDir != "" {
dir := audioDir
if !filepath.IsAbs(dir) {
dir = filepath.Join(base, dir)
}
matches, err := filepath.Glob(filepath.Join(dir, "*.flac"))
if err != nil || len(matches) == 0 {
return []finding{errorFinding("audio", "no .flac files found in "+dir)}
}
paths = append(paths, matches...)
}
for _, file := range cfg.Session.Inputs.AudioFiles {
p := file
if !filepath.IsAbs(p) {
p = filepath.Join(base, p)
}
paths = append(paths, p)
}
for _, p := range paths {
if _, err := os.Stat(p); err != nil {
return []finding{errorFinding("audio", fmt.Sprintf("audio file missing: %v", err))}
}
}
return []finding{okFinding("audio", fmt.Sprintf("%d local audio file(s)", len(paths)))}
return []finding{okFinding("audio", fmt.Sprintf("%d local audio file(s)", len(check.Paths)))}
}
func validateRemoteAudioFinding(ctx context.Context, cfg *config.Config, store storage.ObjectStore) finding {
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, cfg.Session.Inputs.AudioS3.Prefix)
objects, err := store.List(ctx, audioPrefix)
if err != nil {
return errorFinding("audio", err.Error())
check := inspectRemoteAudioPresence(ctx, cfg, store)
if check.Err != nil {
return errorFinding("audio", check.Err.Error())
}
count := 0
for _, obj := range objects {
if strings.HasSuffix(strings.ToLower(obj.Key), ".flac") {
count++
}
}
if count == 0 {
return errorFinding("audio", "no remote .flac objects found under "+audioPrefix)
}
return okFinding("audio", fmt.Sprintf("%d remote .flac object(s)", count))
}
func validatePreviousArtifactFindings(ctx context.Context, cfg *config.Config, store storage.ObjectStore, requirements []artifacts.PreviousArtifactRequirement) []finding {
out := []finding{}
prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
_, err := artifacts.LoadCurrentState(ctx, store, prefix, artifacts.CurrentStateValidation{
ExpectedSessionID: strings.TrimSpace(cfg.Session.PreviousSessionID),
ExpectedCampaign: strings.TrimSpace(cfg.Session.Campaign),
ValidateRunID: true,
})
if err != nil {
out = append(out, errorFinding("previous", fmt.Sprintf("remote %v", err)))
return out
}
for _, req := range requirements {
out = append(out, okFinding("previous", fmt.Sprintf("%s required=%t", req.Name, req.Required)))
}
return out
return okFinding("audio", fmt.Sprintf("%d remote .flac object(s)", len(check.Keys)))
}
func loadLocalManifest(ctx context.Context, path string) (*manifest.Manifest, error) {

View File

@@ -109,12 +109,16 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`), 0o644); err != nil {
t.Fatalf("write explicit campaign: %v", err)
}
mustWriteTestFile(t, filepath.Join(explicitDir, "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "players.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(explicitDir, "party.yml"), "[]\n")
fake := &storage.FakeBackend{}
var storeInitCalls int
@@ -445,6 +449,9 @@ inputs:
if !strings.Contains(stdout.String(), "OK audio") {
t.Fatalf("stdout = %q, want OK audio", stdout.String())
}
if !strings.Contains(stdout.String(), "OK inputs players:") || !strings.Contains(stdout.String(), "OK inputs party:") {
t.Fatalf("stdout = %q, want players and party input findings", stdout.String())
}
}
func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
@@ -593,6 +600,53 @@ func TestExecuteLocksRequireSessionID(t *testing.T) {
}
}
func TestExecuteLocksMutationRejectsSessionIDMismatch(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
tests := []struct {
name string
args []string
}{
{
name: "add mismatch",
args: []string{
"session", "locks", "add", "2026-05-03", "narratio.transcript.final_trimmed",
"--session-id", "2026-05-04",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
},
},
{
name: "remove mismatch",
args: []string{
"session", "locks", "remove", "2026-05-03", "narratio.transcript.final_trimmed",
"--session-id", "2026-05-04",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(tt.args, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "does not match expected session id") {
t.Fatalf("stderr = %q, want session-id mismatch guidance", stderr.String())
}
})
}
}
func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -815,6 +869,8 @@ func TestExecuteStatusReportsRemoteArtifactCatalog(t *testing.T) {
"narratio.transcript.final_trimmed locked",
"narratio.transcript.final_trimmed locked remote=published",
"narratio.transcript.final dest=transcripts/full.json remote=published",
"Stable input players:",
"Stable input party:",
} {
if !strings.Contains(out, want) {
t.Fatalf("stdout = %q, want %q", out, want)
@@ -884,6 +940,31 @@ func TestExecuteStatusReportsMissingRemoteCurrentStateWithoutFailing(t *testing.
}
}
func TestExecuteStatusReportsPreviousStateReadinessWithoutFailing(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
replaceInFileOrFatal(t, pipelinePath, "source: narratio.artifact.session_recap", "source: narratio.previous_session.artifact.session_recap")
replaceInFileOrFatal(t, sessionPath, "session_id: 2026-05-03\n", "session_id: 2026-05-03\nprevious_session_id: 2026-04-26\n")
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "status", "2026-05-03",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Previous-session artifacts: unavailable: remote current run pointer missing") {
t.Fatalf("stdout = %q, want previous readiness unavailable line", stdout.String())
}
}
func TestExecuteSessionValidateReportsPreviousStateFindingAndReturnsFindingError(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
@@ -925,11 +1006,13 @@ func TestExecutePublishLoadsRemoteLocks(t *testing.T) {
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
// The publish stage only checks the manifest statuses and source files.
_ = stageName
}
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`)
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.md"), "# final\n")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.md"), "# final trimmed\n")
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -993,7 +1076,7 @@ func writeValidPublishRunConfigFiles(t *testing.T, workspaceRoot string) (string
m := manifest.New("2026-05-03", nowUTC())
m.Campaign = "sample-campaign"
m.RunID = "20260521T160000Z-test"
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
m.MarkStageSucceeded(name, nowUTC(), nil)
}
path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)

View File

@@ -0,0 +1,276 @@
package app
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
type stableInputCheck struct {
Name string
Path string
Err error
}
type localAudioCheck struct {
Checked bool
Paths []string
Err error
}
type remoteAudioCheck struct {
Checked bool
Prefix string
Keys []string
Err error
}
type previousArtifactReadiness struct {
Requirements []artifacts.PreviousArtifactRequirement
MissingID bool
Err error
}
type remoteCurrentStateCheck struct {
State *RemoteCurrentState
Err error
}
type effectiveLocksCheck struct {
Locks *effectiveLocks
Err error
}
func inspectStableInputs(cfg *config.Config) []stableInputCheck {
items := []struct {
name string
in config.ResolvedInputFile
}{
{name: "speakers", in: cfg.StableInputs.SpeakersFile},
{name: "autocorrect", in: cfg.StableInputs.AutocorrectFile},
{name: "glossary", in: cfg.StableInputs.GlossaryFile},
{name: "players", in: cfg.StableInputs.PlayersFile},
{name: "party", in: cfg.StableInputs.PartyFile},
}
out := make([]stableInputCheck, 0, len(items))
for _, item := range items {
path, err := resolveHelperConfigRelativePath(item.in)
if err != nil {
out = append(out, stableInputCheck{Name: item.name, Err: err})
continue
}
if _, err := os.Stat(path); err != nil {
out = append(out, stableInputCheck{Name: item.name, Path: path, Err: err})
continue
}
out = append(out, stableInputCheck{Name: item.name, Path: path})
}
return out
}
func inspectLocalAudioPresence(cfg *config.Config) localAudioCheck {
if cfg.Session.Inputs.AudioS3 != nil {
return localAudioCheck{}
}
sessionDir := filepath.Dir(cfg.SessionPath)
resolved, err := resolveLocalInspectionAudioPaths(sessionDir, cfg.Session.Inputs)
if err != nil {
return localAudioCheck{Checked: true, Err: err}
}
return localAudioCheck{
Checked: true,
Paths: resolved,
}
}
func inspectRemoteAudioPresence(ctx context.Context, cfg *config.Config, store storage.ObjectStore) remoteAudioCheck {
if cfg.Session.Inputs.AudioS3 == nil {
return remoteAudioCheck{}
}
if store == nil {
return remoteAudioCheck{Checked: true, Err: fmt.Errorf("storage backend is required for remote audio checks")}
}
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, cfg.Session.Inputs.AudioS3.Prefix)
objects, err := store.List(ctx, audioPrefix)
if err != nil {
return remoteAudioCheck{Checked: true, Prefix: audioPrefix, Err: err}
}
keys := make([]string, 0, len(objects))
seenBase := map[string]string{}
for _, obj := range objects {
key := strings.TrimSpace(obj.Key)
if key == "" || strings.HasSuffix(key, "/") || !isInspectionFlacPath(key) {
continue
}
base := path.Base(key)
if prev, exists := seenBase[base]; exists && prev != key {
return remoteAudioCheck{
Checked: true,
Prefix: audioPrefix,
Err: fmt.Errorf("duplicate s3 audio basename %q from %q and %q", base, prev, key),
}
}
seenBase[base] = key
keys = append(keys, key)
}
sort.Strings(keys)
if len(keys) == 0 {
return remoteAudioCheck{
Checked: true,
Prefix: audioPrefix,
Err: fmt.Errorf("no .flac files found under s3 audio prefix %q", audioPrefix),
}
}
return remoteAudioCheck{
Checked: true,
Prefix: audioPrefix,
Keys: keys,
}
}
func inspectPreviousArtifactReadiness(
ctx context.Context,
cfg *config.Config,
store storage.ObjectStore,
requirements []artifacts.PreviousArtifactRequirement,
) previousArtifactReadiness {
out := previousArtifactReadiness{
Requirements: append([]artifacts.PreviousArtifactRequirement(nil), requirements...),
}
if len(requirements) == 0 {
return out
}
if strings.TrimSpace(cfg.Session.PreviousSessionID) == "" {
out.MissingID = true
return out
}
if store == nil {
out.Err = fmt.Errorf("previous-session artifacts cannot be checked because storage is unavailable")
return out
}
prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
if _, err := artifacts.LoadCurrentState(ctx, store, prefix, artifacts.CurrentStateValidation{
ExpectedSessionID: strings.TrimSpace(cfg.Session.PreviousSessionID),
ExpectedCampaign: strings.TrimSpace(cfg.Session.Campaign),
ValidateRunID: true,
}); err != nil {
out.Err = fmt.Errorf("remote %v", err)
}
return out
}
func inspectRemoteCurrentState(ctx context.Context, cfg *config.Config, store storage.ObjectStore) remoteCurrentStateCheck {
if store == nil {
return remoteCurrentStateCheck{}
}
current, err := discoverRemoteCurrentStateFn(ctx, cfg, store)
if err != nil {
return remoteCurrentStateCheck{Err: err}
}
return remoteCurrentStateCheck{State: current}
}
func inspectEffectiveLocks(ctx context.Context, cfg *config.Config, store storage.ObjectStore) effectiveLocksCheck {
locks, err := loadEffectiveLocks(ctx, cfg, store)
if err != nil {
return effectiveLocksCheck{Err: err}
}
return effectiveLocksCheck{Locks: locks}
}
func resolveLocalInspectionAudioPaths(sessionDir string, inputs config.SessionInputsConfig) ([]string, error) {
if len(inputs.AudioFiles) > 0 {
out := make([]string, 0, len(inputs.AudioFiles))
seenBase := map[string]string{}
for _, item := range inputs.AudioFiles {
resolved, err := resolveInspectionPath(sessionDir, item)
if err != nil {
return nil, err
}
if !isInspectionFlacPath(resolved) {
return nil, fmt.Errorf("audio file %q must have .flac extension", resolved)
}
if err := requireInspectionFile(resolved, "audio file"); err != nil {
return nil, err
}
base := filepath.Base(resolved)
if prev, exists := seenBase[base]; exists && prev != resolved {
return nil, fmt.Errorf("duplicate audio basename %q from %q and %q", base, prev, resolved)
}
seenBase[base] = resolved
out = append(out, resolved)
}
sort.Strings(out)
return out, nil
}
audioDir, err := resolveInspectionPath(sessionDir, inputs.AudioDir)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(audioDir)
if err != nil {
return nil, fmt.Errorf("read audio directory %q: %w", audioDir, err)
}
out := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
full := filepath.Join(audioDir, entry.Name())
if !isInspectionFlacPath(full) {
continue
}
if err := requireInspectionFile(full, "audio file"); err != nil {
return nil, err
}
out = append(out, full)
}
if len(out) == 0 {
return nil, fmt.Errorf("no .flac files found in audio directory %q", audioDir)
}
sort.Strings(out)
return out, nil
}
func resolveInspectionPath(baseDir, inputPath string) (string, error) {
pathValue := strings.TrimSpace(inputPath)
if pathValue == "" {
return "", fmt.Errorf("path is required")
}
if filepath.IsAbs(pathValue) {
return filepath.Clean(pathValue), nil
}
return filepath.Clean(filepath.Join(baseDir, pathValue)), nil
}
func requireInspectionFile(path, label string) error {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("%s %q does not exist", label, path)
}
return fmt.Errorf("stat %s %q: %w", label, path, err)
}
if info.IsDir() {
return fmt.Errorf("%s %q is a directory", label, path)
}
return nil
}
func isInspectionFlacPath(path string) bool {
return strings.EqualFold(filepath.Ext(strings.TrimSpace(path)), ".flac")
}

View File

@@ -48,13 +48,6 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error {
// LocksAdd adds or updates one remote lock.
func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
var positionalSessionID string
var source string
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
positionalSessionID = strings.TrimSpace(args[0])
source = strings.TrimSpace(args[1])
args = append([]string(nil), args[2:]...)
}
fs := flag.NewFlagSet("locks add", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var flags commonConfigFlags
@@ -63,26 +56,8 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
addCommonConfigFlags(fs, &flags)
fs.StringVar(&reason, "reason", "", "lock reason")
fs.BoolVar(&force, "force", false, "update existing remote lock")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("locks add: invalid flags: %w", err)
}
if source == "" {
switch fs.NArg() {
case 2:
positionalSessionID = strings.TrimSpace(fs.Arg(0))
source = strings.TrimSpace(fs.Arg(1))
case 1:
if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks add: expected session_id and source id")
}
source = strings.TrimSpace(fs.Arg(0))
default:
return fmt.Errorf("locks add: expected session_id and source id")
}
} else if fs.NArg() != 0 {
return fmt.Errorf("locks add: unexpected positional arguments")
}
if err := applyPositionalSessionID("locks add", positionalSessionID, &flags.sessionID); err != nil {
source, err := parseSessionIDAndOnePositionalArg("locks add", "source id", fs, args, &flags.sessionID)
if err != nil {
return err
}
if strings.TrimSpace(flags.sessionID) == "" {
@@ -116,37 +91,12 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
// LocksRemove removes one remote lock.
func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
var positionalSessionID string
var source string
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
positionalSessionID = strings.TrimSpace(args[0])
source = strings.TrimSpace(args[1])
args = append([]string(nil), args[2:]...)
}
fs := flag.NewFlagSet("locks remove", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var flags commonConfigFlags
addCommonConfigFlags(fs, &flags)
if err := fs.Parse(args); err != nil {
return fmt.Errorf("locks remove: invalid flags: %w", err)
}
if source == "" {
switch fs.NArg() {
case 2:
positionalSessionID = strings.TrimSpace(fs.Arg(0))
source = strings.TrimSpace(fs.Arg(1))
case 1:
if strings.TrimSpace(flags.sessionID) == "" {
return fmt.Errorf("locks remove: expected session_id and source id")
}
source = strings.TrimSpace(fs.Arg(0))
default:
return fmt.Errorf("locks remove: expected session_id and source id")
}
} else if fs.NArg() != 0 {
return fmt.Errorf("locks remove: unexpected positional arguments")
}
if err := applyPositionalSessionID("locks remove", positionalSessionID, &flags.sessionID); err != nil {
source, err := parseSessionIDAndOnePositionalArg("locks remove", "source id", fs, args, &flags.sessionID)
if err != nil {
return err
}
if strings.TrimSpace(flags.sessionID) == "" {

View File

@@ -54,23 +54,26 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
}
requirements := artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg))
if len(requirements) == 0 {
previous := inspectPreviousArtifactReadiness(ctx, cfg, store, requirements)
if len(previous.Requirements) == 0 {
findings = append(findings, okFinding("previous", "no previous-session artifacts required"))
} else if strings.TrimSpace(cfg.Session.PreviousSessionID) == "" {
} else if previous.MissingID {
findings = append(findings, errorFinding("previous", "previous_session_id is required by configured previous-session artifacts"))
} else if storeErr != nil {
findings = append(findings, errorFinding("previous", "previous-session artifacts cannot be checked because storage is unavailable"))
} else if previous.Err != nil {
findings = append(findings, errorFinding("previous", previous.Err.Error()))
} else {
findings = append(findings, validatePreviousArtifactFindings(ctx, cfg, store, requirements)...)
for _, req := range previous.Requirements {
findings = append(findings, okFinding("previous", fmt.Sprintf("%s required=%t", req.Name, req.Required)))
}
}
locks, lockErr := loadEffectiveLocks(ctx, cfg, store)
if lockErr != nil {
findings = append(findings, errorFinding("locks", lockErr.Error()))
} else if len(locks.All) == 0 {
locks := inspectEffectiveLocks(ctx, cfg, store)
if locks.Err != nil {
findings = append(findings, errorFinding("locks", locks.Err.Error()))
} else if len(locks.Locks.All) == 0 {
findings = append(findings, okFinding("locks", "no effective publish locks"))
} else {
for _, lock := range locks.All {
for _, lock := range locks.Locks.All {
findings = append(findings, warnFinding("locks", fmt.Sprintf("%s locked: %s", lock.Source, strings.TrimSpace(lock.Reason))))
}
}

View File

@@ -5,8 +5,10 @@ import (
"flag"
"fmt"
"io"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
@@ -35,6 +37,8 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign)
fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
writeStatusStableInputs(out, inspectStableInputs(cfg))
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
if m, err := loadLocalManifest(ctx, paths.ManifestPath); err != nil {
fmt.Fprintf(out, "Local manifest: error: %v\n", err)
@@ -49,21 +53,30 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
if storeErr != nil {
fmt.Fprintf(out, "Remote publish: unavailable: %v\n", storeErr)
} else if store != nil {
current, err := discoverRemoteCurrentStateFn(ctx, cfg, store)
if err != nil {
fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", err)
current := inspectRemoteCurrentState(ctx, cfg, store)
if current.Err != nil {
fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", current.Err)
} else {
fmt.Fprintf(out, "Remote publish: current run %s\n", current.RunID)
fmt.Fprintf(out, "Remote manifest: %s\n", current.CurrentManifestKey)
fmt.Fprintf(out, "Remote publish: current run %s\n", current.State.RunID)
fmt.Fprintf(out, "Remote manifest: %s\n", current.State.CurrentManifestKey)
}
}
writeStatusRemoteAudio(ctx, out, cfg, store, storeErr)
writeStatusPreviousArtifacts(out, inspectPreviousArtifactReadiness(
ctx,
cfg,
store,
artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)),
))
locks, err := loadEffectiveLocks(ctx, cfg, store)
lockChecks := inspectEffectiveLocks(ctx, cfg, store)
locks := lockChecks.Locks
lockErr := lockChecks.Err
if catalog, catalogErr := buildHelperArtifactCatalog(cfg); catalogErr != nil {
fmt.Fprintf(out, "Remote outputs: error: %v\n", catalogErr)
} else if storeErr == nil {
catalogLocks := locks
if err != nil {
if lockErr != nil {
catalogLocks = &effectiveLocks{
Static: staticPublishLocks(cfg),
All: staticPublishLocks(cfg),
@@ -76,8 +89,8 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
fmt.Fprintln(out, "Remote outputs:")
writeArtifactList(out, cfg, catalog, catalogLocks, publishedRemoteState)
}
if err != nil {
fmt.Fprintf(out, "Publish locks: error: %v\n", err)
if lockErr != nil {
fmt.Fprintf(out, "Publish locks: error: %v\n", lockErr)
} else {
writeLocks(out, cfg, locks)
}
@@ -86,3 +99,68 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
fmt.Fprintf(out, "- narratio session restore %s --dry-run\n", cfg.Session.SessionID)
return nil
}
func writeStatusStableInputs(out io.Writer, checks []stableInputCheck) {
if len(checks) == 0 {
return
}
for _, check := range checks {
if check.Err != nil {
if strings.TrimSpace(check.Path) != "" {
fmt.Fprintf(out, "Stable input %s: unavailable: %v\n", check.Name, check.Err)
} else {
fmt.Fprintf(out, "Stable input %s: unavailable: %s\n", check.Name, check.Err.Error())
}
continue
}
fmt.Fprintf(out, "Stable input %s: %s\n", check.Name, check.Path)
}
}
func writeStatusLocalAudio(out io.Writer, check localAudioCheck) {
if !check.Checked {
return
}
if check.Err != nil {
fmt.Fprintf(out, "Local audio: unavailable: %v\n", check.Err)
return
}
fmt.Fprintf(out, "Local audio: %d file(s)\n", len(check.Paths))
}
func writeStatusRemoteAudio(ctx context.Context, out io.Writer, cfg *config.Config, store storage.ObjectStore, storeErr error) {
if cfg.Session.Inputs.AudioS3 == nil {
return
}
if storeErr != nil {
fmt.Fprintf(out, "Remote audio: unavailable: %v\n", storeErr)
return
}
check := inspectRemoteAudioPresence(ctx, cfg, store)
if check.Err != nil {
fmt.Fprintf(out, "Remote audio: unavailable: %v\n", check.Err)
return
}
fmt.Fprintf(out, "Remote audio: %d .flac object(s)\n", len(check.Keys))
}
func writeStatusPreviousArtifacts(out io.Writer, readiness previousArtifactReadiness) {
if len(readiness.Requirements) == 0 {
fmt.Fprintln(out, "Previous-session artifacts: not required")
return
}
if readiness.MissingID {
fmt.Fprintln(out, "Previous-session artifacts: unavailable: previous_session_id is required by configured previous-session artifacts")
return
}
if readiness.Err != nil {
fmt.Fprintf(out, "Previous-session artifacts: unavailable: %v\n", readiness.Err)
return
}
names := make([]string, 0, len(readiness.Requirements))
for _, req := range readiness.Requirements {
names = append(names, fmt.Sprintf("%s(required=%t)", req.Name, req.Required))
}
sort.Strings(names)
fmt.Fprintf(out, "Previous-session artifacts: ready: %s\n", strings.Join(names, ", "))
}

View File

@@ -27,12 +27,12 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
t.Fatalf("first output = %q, want workdir prepared", got)
}
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
if !strings.Contains(got, name+": run") {
t.Fatalf("first output = %q, missing stage %q", got, name)
}
}
if !strings.Contains(got, "totals: run=9 skip=0") {
if !strings.Contains(got, "totals: run=10 skip=0") {
t.Fatalf("first output = %q, want totals", got)
}
@@ -84,8 +84,8 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
if !strings.Contains(got, "trim: run") {
t.Fatalf("output = %q, want trim run", got)
}
if !strings.Contains(got, "totals: run=7 skip=2") {
t.Fatalf("output = %q, want totals run=7 skip=2", got)
if !strings.Contains(got, "totals: run=8 skip=2") {
t.Fatalf("output = %q, want totals run=8 skip=2", got)
}
}
@@ -118,6 +118,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline.yml: %v", err)

View File

@@ -4,7 +4,7 @@ import "testing"
func TestBuildFullPlanOrder(t *testing.T) {
got := BuildFullPlan()
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"}
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"}
if len(got) != len(want) {
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
}

View File

@@ -355,7 +355,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
if err != nil {
t.Fatalf("Load() error = %v", err)
}
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)

View File

@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/audio"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
@@ -114,10 +115,7 @@ func executeRestoreDownloadAction(
}
}
if err := os.Chmod(tmpPath, 0o644); err != nil {
return fmt.Errorf("set file permissions: %w", err)
}
if err := os.Rename(tmpPath, safeLocalPath); err != nil {
if err := fileops.InstallDownloadedTempFile(tmpPath, safeLocalPath, 0o644); err != nil {
return fmt.Errorf("install file atomically: %w", err)
}
removeTmp = false

View File

@@ -2,6 +2,7 @@ package app
import (
"context"
"errors"
"fmt"
"io"
"os"
@@ -13,6 +14,7 @@ import (
"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/pathsafe"
"gitea.maximumdirect.net/eric/narratio/internal/previouscache"
)
@@ -215,19 +217,17 @@ func joinWithinSessionRoot(sessionRoot, relative string) (string, error) {
if strings.TrimSpace(sessionRoot) == "" {
return "", fmt.Errorf("session root is required")
}
cleanRel := path.Clean(strings.TrimSpace(relative))
if cleanRel == "." || cleanRel == "" {
joined, err := pathsafe.JoinSlashRelativeUnderRoot(sessionRoot, filepath.ToSlash(strings.TrimSpace(relative)))
if err != nil {
if errors.Is(err, pathsafe.ErrRelativePathRequired) {
return "", fmt.Errorf("relative path is required")
}
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") || strings.HasPrefix(cleanRel, "/") {
if errors.Is(err, pathsafe.ErrRelativePathEscape) || errors.Is(err, pathsafe.ErrRelativePathAbsolute) {
return "", fmt.Errorf("relative path escapes session root")
}
abs := filepath.Clean(filepath.Join(sessionRoot, filepath.FromSlash(cleanRel)))
root := filepath.Clean(sessionRoot)
if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) {
return "", fmt.Errorf("resolved local path escapes session root")
return "", fmt.Errorf("join relative path under session root: %w", err)
}
return abs, nil
return joined, nil
}
func buildPreviousCacheRestoreActions(

View File

@@ -397,6 +397,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline config: %v", err)
@@ -407,6 +409,8 @@ inputs:
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n")
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "players.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, campaignPath, sessionPath

View File

@@ -1,102 +0,0 @@
package app
import (
"context"
"flag"
"fmt"
"io"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// Resume continues execution from the first non-succeeded stage in the manifest.
func Resume(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("resume", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var flags commonConfigFlags
var force bool
var selectedArtifacts artifactSelectionFlag
addCommonConfigFlags(fs, &flags)
fs.BoolVar(&force, "force", false, "force stage execution")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
if err := parseSessionAwareFlags("resume", fs, args, &flags.sessionID); err != nil {
return err
}
if flags.sessionID == "" {
return fmt.Errorf("resume: session_id is required")
}
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil {
return fmt.Errorf("resume: %w", err)
}
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("resume: %w", err)
}
normalizedArtifacts, err := selectedArtifacts.Normalize()
if err != nil {
return fmt.Errorf("resume: invalid --artifacts: %w", err)
}
if err := validateSelectedArtifacts(cfg, normalizedArtifacts); err != nil {
return fmt.Errorf("resume: %w", err)
}
full := BuildFullPlan()
selected := full
if !force {
m, err := loadManifestIfPresent(ctx, cfg)
if err != nil {
return fmt.Errorf("resume: %w", err)
}
if m != nil {
start := firstNonSucceededIndex(full, m)
if start >= len(full) {
_, err := fmt.Fprintf(out, "narratio resume: session %s has no remaining stages\n", cfg.Session.SessionID)
return err
}
selected = full[start:]
}
}
summary, err := executeStagesFn(ctx, cfg, selected, RunOptions{
Force: force,
SelectedArtifacts: normalizedArtifacts,
})
if err != nil {
return fmt.Errorf("resume: %w", err)
}
_, err = fmt.Fprintf(
out,
"narratio resume: session %s; executed=%d skipped=%d; manifest=%s\n",
summary.SessionID,
len(summary.Executed),
len(summary.Skipped),
summary.ManifestPath,
)
return err
}
func loadManifestIfPresent(ctx context.Context, cfg *config.Config) (*manifest.Manifest, error) {
path := artifacts.SessionManifestPathForCampaign(
cfg.Pipeline.Workspace.Root,
cfg.Session.Campaign,
cfg.Session.SessionID,
)
exists, err := fileExists(path)
if err != nil {
return nil, fmt.Errorf("check manifest %q: %w", path, err)
}
if !exists {
return nil, nil
}
store := &manifest.LocalStore{}
m, err := store.Load(ctx, path)
if err != nil {
return nil, fmt.Errorf("load manifest %q: %w", path, err)
}
return m, nil
}

View File

@@ -1,8 +1,12 @@
package app
import (
"context"
"fmt"
"time"
"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"
)
@@ -43,13 +47,25 @@ func stageSucceeded(m *manifest.Manifest, name string) bool {
return sr != nil && sr.Status == manifest.StatusSucceeded
}
func firstNonSucceededIndex(stages []stage.Stage, m *manifest.Manifest) int {
for i, s := range stages {
if !stageSucceeded(m, s.Name()) {
return i
func loadManifestIfPresent(ctx context.Context, cfg *config.Config) (*manifest.Manifest, error) {
path := artifacts.SessionManifestPathForCampaign(
cfg.Pipeline.Workspace.Root,
cfg.Session.Campaign,
cfg.Session.SessionID,
)
exists, err := fileExists(path)
if err != nil {
return nil, fmt.Errorf("check manifest %q: %w", path, err)
}
if !exists {
return nil, nil
}
return len(stages)
store := &manifest.LocalStore{}
m, err := store.Load(ctx, path)
if err != nil {
return nil, fmt.Errorf("load manifest %q: %w", path, err)
}
return m, nil
}
func canonicalStageNames() []string {

View File

@@ -8,18 +8,6 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestFirstNonSucceededIndex(t *testing.T) {
stages := BuildFullPlan()
m := manifest.New("2026-05-03", time.Now().UTC())
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
m.MarkStageSucceeded("transcribe", time.Now().UTC(), nil)
got := firstNonSucceededIndex(stages, m)
if got != 2 {
t.Fatalf("firstNonSucceededIndex() = %d, want 2", got)
}
}
func TestDecideStageActions(t *testing.T) {
stages := BuildFullPlan()[:2]
m := manifest.New("2026-05-03", time.Now().UTC())
@@ -44,7 +32,7 @@ func TestDecideStageActions(t *testing.T) {
func TestDownstreamStageNames(t *testing.T) {
got := downstreamStageNames("polish")
want := []string{"normalize", "trim", "analyze", "publish", "notify"}
want := []string{"normalize", "trim", "render", "analyze", "publish", "notify"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
}
@@ -64,12 +52,13 @@ func TestInvalidateDownstreamSucceededStages(t *testing.T) {
m.MarkStageSucceeded("polish", now, nil)
m.MarkStageSucceeded("normalize", now, nil)
m.MarkStageSucceeded("trim", now, nil)
m.MarkStageSucceeded("render", now, nil)
m.MarkStageFailed("analyze", now, "analysis failed")
m.MarkStageSucceeded("publish", now, nil)
m.MarkStageSucceeded("notify", now, nil)
got := invalidateDownstreamSucceededStages(m, "polish", now.Add(1*time.Second))
want := []string{"normalize", "trim", "publish", "notify"}
want := []string{"normalize", "trim", "render", "publish", "notify"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("invalidateDownstreamSucceededStages() = %#v, want %#v", got, want)
}

View File

@@ -13,7 +13,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestResumeStartsAfterCompletedStages(t *testing.T) {
func TestRunContinuesAfterCompletedStages(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
@@ -32,12 +32,12 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "executed=7 skipped=0") {
t.Fatalf("output = %q, want executed=7 skipped=0", out.String())
if !strings.Contains(out.String(), "executed=8 skipped=2") {
t.Fatalf("output = %q, want executed=8 skipped=2", out.String())
}
loaded, err := store.Load(context.Background(), manifestPath)
@@ -49,14 +49,14 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
}
}
func TestResumeNoRemainingStages(t *testing.T) {
func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{}
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
}
if err := store.Save(context.Background(), manifestPath, m); err != nil {
@@ -64,20 +64,20 @@ func TestResumeNoRemainingStages(t *testing.T) {
}
var out bytes.Buffer
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "has no remaining stages") {
t.Fatalf("output = %q, want no remaining stages", out.String())
if !strings.Contains(out.String(), "executed=0 skipped=10") {
t.Fatalf("output = %q, want executed=0 skipped=10", out.String())
}
}
func TestResumeForceRerunsSucceeded(t *testing.T) {
func TestRunForceRerunsSucceeded(t *testing.T) {
workspaceRoot := t.TempDir()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"source":"resume-force-test","segments":[{"speaker":"alice"}]}`))
_, _ = w.Write([]byte(`{"source":"run-force-test","segments":[{"speaker":"alice"}]}`))
}))
defer srv.Close()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
@@ -85,7 +85,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
store := &manifest.LocalStore{}
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
}
if err := store.Save(context.Background(), manifestPath, m); err != nil {
@@ -93,11 +93,11 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
}
var out bytes.Buffer
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "executed=9 skipped=0") {
if !strings.Contains(out.String(), "executed=10 skipped=0") {
t.Fatalf("output = %q, want forced full rerun", out.String())
}
}
@@ -166,7 +166,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
}
}
func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing.T) {
func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
@@ -176,7 +176,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
store := &manifest.LocalStore{}
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
}
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
@@ -196,19 +196,19 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
if err != nil {
t.Fatalf("load manifest after force: %v", err)
}
for _, name := range []string{"normalize", "trim", "analyze", "publish", "notify"} {
for _, name := range []string{"normalize", "trim", "render", "analyze", "publish", "notify"} {
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
}
}
out.Reset()
err = Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
err = Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("Resume() error = %v", err)
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "executed=5 skipped=0") {
t.Fatalf("output = %q, want resume to execute normalize..notify", out.String())
if !strings.Contains(out.String(), "executed=6 skipped=4") {
t.Fatalf("output = %q, want run to execute stale downstream stages", out.String())
}
}
@@ -236,8 +236,8 @@ func TestRunStageTrimExecutes(t *testing.T) {
if m.Stages["trim"] == nil || m.Stages["trim"].Status != manifest.StatusSucceeded {
t.Fatalf("trim stage = %#v, want succeeded", m.Stages["trim"])
}
if m.Stages["trim"].Metadata == nil || m.Stages["trim"].Metadata["trim_action"] != "copy_disabled" {
t.Fatalf("trim stage metadata = %#v, want trim_action=copy_disabled", m.Stages["trim"].Metadata)
if m.Stages["trim"].Metadata == nil || m.Stages["trim"].Metadata["trim_action"] != "copy" {
t.Fatalf("trim stage metadata = %#v, want trim_action=copy", m.Stages["trim"].Metadata)
}
}
@@ -266,3 +266,30 @@ func TestRunStageNormalizeExecutes(t *testing.T) {
t.Fatalf("normalize stage = %#v, want succeeded", m.Stages["normalize"])
}
}
func TestRunStageRenderExecutes(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1}]}`)
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[{"id":2}]}`)
var out bytes.Buffer
err := RunStage(context.Background(), []string{"render", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err != nil {
t.Fatalf("RunStage(render) error = %v", err)
}
if !strings.Contains(out.String(), "stage=render executed=1 skipped=0") {
t.Fatalf("output = %q, want stage=render executed", out.String())
}
store := &manifest.LocalStore{}
m, err := store.Load(context.Background(), manifestPath)
if err != nil {
t.Fatalf("load manifest: %v", err)
}
if m.Stages["render"] == nil || m.Stages["render"].Status != manifest.StatusSucceeded {
t.Fatalf("render stage = %#v, want succeeded", m.Stages["render"])
}
}

View File

@@ -12,6 +12,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/logging"
@@ -410,6 +411,8 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
if stageName == "analyze" {
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
kind = "scriptorium_artifact"
} else {
sourceID = sourceIDForOutputKind(kind)
}
out = append(out, manifest.ArtifactRecord{
Kind: kind,
@@ -424,6 +427,22 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
return out
}
func sourceIDForOutputKind(kind string) string {
trimmed := strings.TrimSpace(kind)
if trimmed == "" {
return ""
}
if trimmed == "session_bounds" {
return artifacts.ArtifactBoundsSession
}
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {
if spec.OutputKind == trimmed {
return spec.SourceID
}
}
return ""
}
func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *stage.StageResult) {
if m == nil || result == nil {
return

View File

@@ -273,7 +273,7 @@ func TestExecuteStagesPublishSkipsRequiredUnselectedConfiguredOutput(t *testing.
manifestPath := manifestPathFor(cfg)
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
seed.Campaign = cfg.Session.Campaign
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"} {
seed.MarkStageSucceeded(stageName, time.Now().UTC(), nil)
}
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
@@ -332,8 +332,8 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if len(summary.StageNames) != 9 || len(summary.Executed) != 9 || len(summary.Skipped) != 0 {
t.Fatalf("summary = %#v, want all 9 executed", summary)
if len(summary.StageNames) != 10 || len(summary.Executed) != 10 || len(summary.Skipped) != 0 {
t.Fatalf("summary = %#v, want all 10 executed", summary)
}
store := &manifest.LocalStore{}
@@ -342,7 +342,7 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
t.Fatalf("Load manifest error = %v", err)
}
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
sr := m.Stages[name]
if sr == nil {
t.Fatalf("missing stage record %q", name)
@@ -425,6 +425,15 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
}
continue
}
if name == "render" {
if sr.Metadata == nil || sr.Metadata["stage"] != "render" {
t.Fatalf("render metadata missing stage=render: %#v", sr.Metadata)
}
if len(sr.Outputs) == 0 {
t.Fatalf("render outputs missing")
}
continue
}
if name == "publish" {
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata)
@@ -522,7 +531,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
store := &manifest.LocalStore{}
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "publish", "notify"} {
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "publish", "notify"} {
existing.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
}
existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure")
@@ -553,7 +562,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded {
t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"])
}
for _, stageName := range []string{"normalize", "trim", "publish", "notify"} {
for _, stageName := range []string{"normalize", "trim", "render", "publish", "notify"} {
if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale {
t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName])
}
@@ -977,7 +986,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
seed.S3Bucket = "my-dnd-archive"
seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/"
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
@@ -1015,11 +1024,13 @@ func testConfig(t *testing.T) *config.Config {
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
mustWriteFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
mustWriteFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
mustWriteFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n")
mustWriteFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
mustWriteFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
mustWriteFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "audio")
return &config.Config{
@@ -1044,6 +1055,16 @@ func testConfig(t *testing.T) *config.Config {
ConfigPath: campaignPath,
Source: "campaign_config",
},
PlayersFile: config.ResolvedInputFile{
Path: "./players.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PartyFile: config.ResolvedInputFile{
Path: "./party.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
},
Session: &config.SessionConfig{
SessionID: "2026-05-03",
@@ -1053,6 +1074,8 @@ func testConfig(t *testing.T) *config.Config {
SpeakersFile: "./speakers.yml",
AutocorrectFile: "./autocorrect.yml",
GlossaryFile: "./glossary.yml",
PlayersFile: "./players.yml",
PartyFile: "./party.yml",
},
},
}
@@ -1075,6 +1098,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
sessionYAML := `session_id: 2026-05-03
campaign: sample-campaign
@@ -1083,6 +1108,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
mustWriteFile(t, pipelinePath, pipelineYAML)
mustWriteFile(t, campaignPath, campaignYAML)

View File

@@ -59,3 +59,37 @@ func parseSessionAwareFlags(command string, fs *flag.FlagSet, args []string, ses
}
return resolveParsedSessionID(command, positionalSessionID, fs, sessionID)
}
func parseSessionIDAndOnePositionalArg(command, argName string, fs *flag.FlagSet, args []string, sessionID *string) (string, error) {
var positionalSessionID string
value := ""
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
positionalSessionID = strings.TrimSpace(args[0])
value = strings.TrimSpace(args[1])
args = append([]string(nil), args[2:]...)
}
if err := fs.Parse(args); err != nil {
return "", fmt.Errorf("%s: invalid flags: %w", command, err)
}
if value == "" {
switch fs.NArg() {
case 2:
positionalSessionID = strings.TrimSpace(fs.Arg(0))
value = strings.TrimSpace(fs.Arg(1))
case 1:
if strings.TrimSpace(*sessionID) == "" {
return "", fmt.Errorf("%s: expected session_id and %s", command, argName)
}
value = strings.TrimSpace(fs.Arg(0))
default:
return "", fmt.Errorf("%s: expected session_id and %s", command, argName)
}
} else if fs.NArg() != 0 {
return "", fmt.Errorf("%s: unexpected positional arguments", command)
}
if err := applyPositionalSessionID(command, positionalSessionID, sessionID); err != nil {
return "", err
}
return value, nil
}

View File

@@ -22,6 +22,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil {
t.Fatalf("write session template: %v", err)
@@ -75,6 +77,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)

View File

@@ -162,8 +162,8 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
wantForce bool
}{
{
name: "resume",
args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
name: "run",
args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
wantStage: "prepare",
wantForce: false,
},

View File

@@ -7,6 +7,8 @@ const (
SourceTranscriptPolished = "narratio.transcript.polished"
SourceTranscriptFinal = "narratio.transcript.final"
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
SourceTranscriptFinalMarkdown = "narratio.transcript.final_markdown"
SourceTranscriptFinalTrimmedMarkdown = "narratio.transcript.final_trimmed_markdown"
)
const (
@@ -14,6 +16,8 @@ const (
TranscriptPathPolished = "transcripts/polished.json"
TranscriptPathFinal = "transcripts/final.json"
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
TranscriptPathFinalMarkdown = "transcripts/final.md"
TranscriptPathFinalTrimmedMarkdown = "transcripts/final.trimmed.md"
)
const (
@@ -21,6 +25,8 @@ const (
TranscriptOutputKindPolished = "transcript_polished"
TranscriptOutputKindFinal = "transcript_final"
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
TranscriptOutputKindFinalMarkdown = "transcript_final_markdown"
TranscriptOutputKindFinalTrimmedMarkdown = "transcript_final_trimmed_markdown"
)
// TranscriptArtifactSpec describes one built-in transcript artifact mapping.
@@ -56,6 +62,18 @@ var runtimeTranscriptArtifacts = []TranscriptArtifactSpec{
ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed,
},
{
SourceID: SourceTranscriptFinalMarkdown,
CanonicalRelPath: TranscriptPathFinalMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalMarkdown,
},
{
SourceID: SourceTranscriptFinalTrimmedMarkdown,
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
},
}
// RuntimeTranscriptArtifacts returns transcript mappings in pipeline order.

View File

@@ -0,0 +1,60 @@
package artifactmodel
import (
"reflect"
"testing"
)
func TestRuntimeTranscriptArtifactsIncludesMarkdownOutputs(t *testing.T) {
want := []TranscriptArtifactSpec{
{
SourceID: SourceTranscriptBase,
CanonicalRelPath: TranscriptPathBase,
ProducerStage: "merge",
OutputKind: TranscriptOutputKindBase,
},
{
SourceID: SourceTranscriptPolished,
CanonicalRelPath: TranscriptPathPolished,
ProducerStage: "polish",
OutputKind: TranscriptOutputKindPolished,
},
{
SourceID: SourceTranscriptFinal,
CanonicalRelPath: TranscriptPathFinal,
ProducerStage: "normalize",
OutputKind: TranscriptOutputKindFinal,
},
{
SourceID: SourceTranscriptFinalTrimmed,
CanonicalRelPath: TranscriptPathFinalTrimmed,
ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed,
},
{
SourceID: SourceTranscriptFinalMarkdown,
CanonicalRelPath: TranscriptPathFinalMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalMarkdown,
},
{
SourceID: SourceTranscriptFinalTrimmedMarkdown,
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
},
}
got := RuntimeTranscriptArtifacts()
if !reflect.DeepEqual(got, want) {
t.Fatalf("RuntimeTranscriptArtifacts() = %#v, want %#v", got, want)
}
}
func TestLookupRuntimeTranscriptArtifactFindsMarkdownOutputs(t *testing.T) {
for _, source := range []string{SourceTranscriptFinalMarkdown, SourceTranscriptFinalTrimmedMarkdown} {
if _, ok := LookupRuntimeTranscriptArtifact(source); !ok {
t.Fatalf("LookupRuntimeTranscriptArtifact(%q) ok=false, want true", source)
}
}
}

View File

@@ -1,6 +1,7 @@
package artifactpolicy
import (
"errors"
"fmt"
"regexp"
"strings"
@@ -12,6 +13,10 @@ import (
const (
SourceBoundsSession = "narratio.bounds.session"
SourceInputPlayers = "narratio.input.players"
SourceInputParty = "narratio.input.party"
SourceInputGlossary = "narratio.input.glossary"
configuredSourcePrefix = "narratio.artifact."
previousConfiguredSrcPrefix = "narratio.previous_session.artifact."
)
@@ -19,12 +24,18 @@ const (
var configuredSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
var previousSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
var (
ErrUnsupportedScriptoriumInputSource = errors.New("unsupported scriptorium input source")
ErrInvalidPreviousSessionSource = errors.New("invalid previous-session source format")
)
type SourceKind string
const (
SourceKindBuiltIn SourceKind = "built_in"
SourceKindConfiguredArtifact SourceKind = "configured_artifact"
SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact"
SourceKindStableInput SourceKind = "stable_input"
)
// Source describes one normalized artifact source identifier.
@@ -34,6 +45,28 @@ type Source struct {
ConfiguredKey string
}
// ScriptoriumInputSourceDescriptor describes one validated Scriptorium input source.
type ScriptoriumInputSourceDescriptor struct {
Source Source
PreviousSession *PreviousSessionSourceDescriptor
}
// PreviousSessionSourceDescriptor describes one canonical previous-session input source.
type PreviousSessionSourceDescriptor struct {
SourceID string
ConfiguredKey string
ConfiguredSourceID string
}
// UnknownConfiguredArtifactError reports a source that references an undefined configured artifact key.
type UnknownConfiguredArtifactError struct {
ConfiguredKey string
}
func (e *UnknownConfiguredArtifactError) Error() string {
return fmt.Sprintf("references unknown artifact %q", e.ConfiguredKey)
}
// ConfiguredSourceID converts a configured artifact key into source id form.
func ConfiguredSourceID(key string) string {
return configuredSourcePrefix + strings.TrimSpace(key)
@@ -83,6 +116,86 @@ func ClassifySource(source string) (Source, error) {
return Source{}, fmt.Errorf("unsupported artifact source %q", source)
}
// DescribeScriptoriumInputSource classifies one input source and returns descriptor
// metadata used by config validation, analyze input resolution, and previous-cache planning.
func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescriptor, error) {
trimmed := strings.TrimSpace(source)
if trimmed == "" {
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
}
if IsStableInputSource(trimmed) {
return ScriptoriumInputSourceDescriptor{
Source: Source{ID: trimmed, Kind: SourceKindStableInput},
}, nil
}
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
descriptor, err := DescribePreviousSessionSource(trimmed)
if err != nil {
return ScriptoriumInputSourceDescriptor{}, err
}
return ScriptoriumInputSourceDescriptor{
Source: Source{
ID: descriptor.SourceID,
Kind: SourceKindPreviousArtifact,
ConfiguredKey: descriptor.ConfiguredKey,
},
PreviousSession: &descriptor,
}, nil
}
classified, err := ClassifySource(trimmed)
if err != nil {
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
}
return ScriptoriumInputSourceDescriptor{Source: classified}, nil
}
// IsStableInputSource reports whether source is a prepared stable input source
// available only to Scriptorium input resolution.
func IsStableInputSource(source string) bool {
switch strings.TrimSpace(source) {
case SourceInputPlayers, SourceInputParty, SourceInputGlossary:
return true
default:
return false
}
}
// DescribePreviousSessionSource validates a canonical previous-session source id
// and returns both previous and configured-source vocabulary descriptors.
func DescribePreviousSessionSource(source string) (PreviousSessionSourceDescriptor, error) {
configuredKey, ok := ParsePreviousSessionSource(source)
if !ok {
return PreviousSessionSourceDescriptor{}, ErrInvalidPreviousSessionSource
}
return PreviousSessionSourceDescriptor{
SourceID: PreviousSessionSourceID(configuredKey),
ConfiguredKey: configuredKey,
ConfiguredSourceID: ConfiguredSourceID(configuredKey),
}, nil
}
// PreviousSessionSourceDescriptorForConfiguredKey derives a previous-session source descriptor
// from a configured artifact key.
func PreviousSessionSourceDescriptorForConfiguredKey(configuredKey string) (PreviousSessionSourceDescriptor, error) {
return DescribePreviousSessionSource(PreviousSessionSourceID(configuredKey))
}
// ValidateInputConfiguredReference checks that configured/previous-session sources
// reference configured artifacts known to the current Scriptorium config.
func ValidateInputConfiguredReference(
descriptor ScriptoriumInputSourceDescriptor,
configured map[string]struct{},
) error {
switch descriptor.Source.Kind {
case SourceKindConfiguredArtifact, SourceKindPreviousArtifact:
if _, ok := configured[descriptor.Source.ConfiguredKey]; !ok {
return &UnknownConfiguredArtifactError{ConfiguredKey: descriptor.Source.ConfiguredKey}
}
}
return nil
}
// ValidatePublishSource validates that a source is publish-compatible and references a known configured artifact.
func ValidatePublishSource(source string, configured map[string]string) (Source, error) {
classified, err := ClassifySource(source)

View File

@@ -1,6 +1,7 @@
package artifactpolicy
import (
"errors"
"strings"
"testing"
)
@@ -66,6 +67,14 @@ func TestResolvePublishedDestination(t *testing.T) {
t.Fatalf("built-in destination = %q, want transcripts/final.trimmed.json", got)
}
got, err = ResolvePublishedDestination("narratio.transcript.final_markdown", "", configured)
if err != nil {
t.Fatalf("ResolvePublishedDestination(markdown built-in) error = %v", err)
}
if got != "transcripts/final.md" {
t.Fatalf("markdown built-in destination = %q, want transcripts/final.md", got)
}
got, err = ResolvePublishedDestination("narratio.artifact.session_recap", "", configured)
if err != nil {
t.Fatalf("ResolvePublishedDestination(configured) error = %v", err)
@@ -90,3 +99,105 @@ func TestResolvePublishedDestinationRejectsTraversal(t *testing.T) {
t.Fatal("ResolvePublishedDestination() error = nil, want traversal rejection")
}
}
func TestDescribeScriptoriumInputSource(t *testing.T) {
tests := []struct {
name string
source string
wantKind SourceKind
wantKey string
wantPrev bool
wantErr error
wantErrLike string
}{
{name: "built in", source: "narratio.transcript.final_trimmed", wantKind: SourceKindBuiltIn},
{name: "built in markdown", source: "narratio.transcript.final_markdown", wantKind: SourceKindBuiltIn},
{name: "prepared players input", source: "narratio.input.players", wantKind: SourceKindStableInput},
{name: "prepared party input", source: "narratio.input.party", wantKind: SourceKindStableInput},
{name: "prepared glossary input", source: "narratio.input.glossary", wantKind: SourceKindStableInput},
{name: "configured", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"},
{name: "previous", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap", wantPrev: true},
{name: "invalid previous", source: "narratio.previous_session.artifact.", wantErr: ErrInvalidPreviousSessionSource},
{name: "unsupported", source: "narratio.unknown", wantErr: ErrUnsupportedScriptoriumInputSource},
{name: "empty", source: " ", wantErr: ErrUnsupportedScriptoriumInputSource},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := DescribeScriptoriumInputSource(tt.source)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("DescribeScriptoriumInputSource() error = %v, want %v", err, tt.wantErr)
}
return
}
if tt.wantErrLike != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErrLike) {
t.Fatalf("DescribeScriptoriumInputSource() error = %v, want like %q", err, tt.wantErrLike)
}
return
}
if err != nil {
t.Fatalf("DescribeScriptoriumInputSource() error = %v", err)
}
if got.Source.Kind != tt.wantKind {
t.Fatalf("DescribeScriptoriumInputSource().Source.Kind = %q, want %q", got.Source.Kind, tt.wantKind)
}
if got.Source.ConfiguredKey != tt.wantKey {
t.Fatalf("DescribeScriptoriumInputSource().Source.ConfiguredKey = %q, want %q", got.Source.ConfiguredKey, tt.wantKey)
}
if tt.wantPrev && got.PreviousSession == nil {
t.Fatal("DescribeScriptoriumInputSource().PreviousSession = nil, want descriptor")
}
if !tt.wantPrev && got.PreviousSession != nil {
t.Fatalf("DescribeScriptoriumInputSource().PreviousSession = %#v, want nil", got.PreviousSession)
}
})
}
}
func TestValidateInputConfiguredReference(t *testing.T) {
configured := map[string]struct{}{"session_recap": {}}
desc, err := DescribeScriptoriumInputSource("narratio.artifact.session_recap")
if err != nil {
t.Fatalf("DescribeScriptoriumInputSource(configured) error = %v", err)
}
if err := ValidateInputConfiguredReference(desc, configured); err != nil {
t.Fatalf("ValidateInputConfiguredReference(configured) error = %v", err)
}
prevDesc, err := DescribeScriptoriumInputSource("narratio.previous_session.artifact.session_recap")
if err != nil {
t.Fatalf("DescribeScriptoriumInputSource(previous) error = %v", err)
}
if err := ValidateInputConfiguredReference(prevDesc, configured); err != nil {
t.Fatalf("ValidateInputConfiguredReference(previous) error = %v", err)
}
missingDesc, err := DescribeScriptoriumInputSource("narratio.artifact.quest_log")
if err != nil {
t.Fatalf("DescribeScriptoriumInputSource(missing configured) error = %v", err)
}
err = ValidateInputConfiguredReference(missingDesc, configured)
var unknown *UnknownConfiguredArtifactError
if !errors.As(err, &unknown) || unknown.ConfiguredKey != "quest_log" {
t.Fatalf("ValidateInputConfiguredReference(missing configured) error = %v, want UnknownConfiguredArtifactError(quest_log)", err)
}
}
func TestPreviousSessionSourceDescriptorForConfiguredKey(t *testing.T) {
got, err := PreviousSessionSourceDescriptorForConfiguredKey("session_recap")
if err != nil {
t.Fatalf("PreviousSessionSourceDescriptorForConfiguredKey() error = %v", err)
}
if got.SourceID != "narratio.previous_session.artifact.session_recap" {
t.Fatalf("SourceID = %q, want narratio.previous_session.artifact.session_recap", got.SourceID)
}
if got.ConfiguredSourceID != "narratio.artifact.session_recap" {
t.Fatalf("ConfiguredSourceID = %q, want narratio.artifact.session_recap", got.ConfiguredSourceID)
}
if got.ConfiguredKey != "session_recap" {
t.Fatalf("ConfiguredKey = %q, want session_recap", got.ConfiguredKey)
}
}

View File

@@ -18,6 +18,8 @@ const (
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
ArtifactTranscriptFinalMarkdown = artifactmodel.SourceTranscriptFinalMarkdown
ArtifactTranscriptFinalTrimmedMarkdown = artifactmodel.SourceTranscriptFinalTrimmedMarkdown
ArtifactBoundsSession = "narratio.bounds.session"
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
@@ -29,6 +31,8 @@ const (
TranscriptPathPolished = artifactmodel.TranscriptPathPolished
TranscriptPathFinal = artifactmodel.TranscriptPathFinal
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
TranscriptPathFinalMarkdown = artifactmodel.TranscriptPathFinalMarkdown
TranscriptPathFinalTrimmedMarkdown = artifactmodel.TranscriptPathFinalTrimmedMarkdown
)
const (
@@ -36,6 +40,8 @@ const (
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
TranscriptOutputKindFinalMarkdown = artifactmodel.TranscriptOutputKindFinalMarkdown
TranscriptOutputKindFinalTrimmedMarkdown = artifactmodel.TranscriptOutputKindFinalTrimmedMarkdown
)
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
@@ -67,7 +73,7 @@ func buildArtifactRegistry() map[string]artifactSpec {
CanonicalRelPath: transcript.CanonicalRelPath,
ProducerStage: transcript.ProducerStage,
OutputKind: transcript.OutputKind,
ContentKind: contentTranscriptJSON,
ContentKind: transcriptContentKind(transcript),
}
}
registry[ArtifactBoundsSession] = artifactSpec{
@@ -80,6 +86,15 @@ func buildArtifactRegistry() map[string]artifactSpec {
return registry
}
func transcriptContentKind(transcript TranscriptArtifactSpec) artifactContentKind {
switch transcript.SourceID {
case ArtifactTranscriptFinalMarkdown, ArtifactTranscriptFinalTrimmedMarkdown:
return contentText
default:
return contentTranscriptJSON
}
}
// ResolvedSessionArtifact describes one session-level artifact lookup result.
type ResolvedSessionArtifact struct {
ID string

View File

@@ -211,6 +211,29 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
}
}
func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md")
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(canonicalPath, []byte("# Final transcript\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown)
if err != nil {
t.Fatalf("ResolveSessionArtifact() error = %v", err)
}
if resolved.Path != canonicalPath {
t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath)
}
if resolved.Provenance != "fallback.canonical_path" {
t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance)
}
}
func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
@@ -244,6 +267,26 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
}
}
func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.md")
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(canonicalPath, []byte{}, 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmedMarkdown)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "file is empty") {
t.Fatalf("error = %q, want empty file validation", err.Error())
}
}
func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")

View File

@@ -216,11 +216,10 @@ func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
}
func runtimeBuiltInArtifactIDs() []string {
return []string{
ArtifactTranscriptBase,
ArtifactTranscriptPolished,
ArtifactTranscriptFinal,
ArtifactTranscriptFinalTrimmed,
ArtifactBoundsSession,
ids := make([]string, 0, len(RuntimeTranscriptArtifacts())+1)
for _, transcript := range RuntimeTranscriptArtifacts() {
ids = append(ids, transcript.SourceID)
}
ids = append(ids, ArtifactBoundsSession)
return ids
}

View File

@@ -23,6 +23,26 @@ func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
}
}
func TestArtifactCatalogRegisterBuiltInsIncludesMarkdownSources(t *testing.T) {
catalog := NewArtifactCatalog()
if err := catalog.RegisterBuiltIns(); err != nil {
t.Fatalf("RegisterBuiltIns() error = %v", err)
}
for _, sourceID := range []string{
ArtifactTranscriptFinalMarkdown,
ArtifactTranscriptFinalTrimmedMarkdown,
} {
entry, ok := catalog.Lookup(sourceID)
if !ok {
t.Fatalf("Lookup(%q) ok=false, want true", sourceID)
}
if !entry.Planned {
t.Fatalf("%s planned=false, want true", sourceID)
}
}
}
func TestArtifactCatalogRegisterConfiguredArtifactsDefaultsToEnabled(t *testing.T) {
catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{

View File

@@ -9,6 +9,9 @@ import (
"strconv"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
// ErrLockConflict is returned when a session lock already exists.
@@ -101,7 +104,7 @@ func (s *LocalStore) copyInputWithPaths(paths SessionPaths, sessionID, srcPath,
return Ref{}, fmt.Errorf("copy input: %w", err)
}
if err := copyFileAtomic(srcPath, destAbs, 0o644); err != nil {
if err := fileops.CopyFileAtomic(srcPath, destAbs, 0o644); err != nil {
return Ref{}, fmt.Errorf("copy input %q -> %q: %w", srcPath, destAbs, err)
}
@@ -145,45 +148,9 @@ func (s *LocalStore) WriteFileAtomic(path string, data []byte, perm os.FileMode)
if strings.TrimSpace(path) == "" {
return fmt.Errorf("write file atomic: path is required")
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("write file atomic: create parent dir %q: %w", dir, err)
if err := fileops.WriteFileAtomic(path, data, perm); err != nil {
return fmt.Errorf("write file atomic: %w", err)
}
base := filepath.Base(path)
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
if err != nil {
return fmt.Errorf("write file atomic: create temp file: %w", err)
}
tmpName := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write file atomic: write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("write file atomic: sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("write file atomic: close temp file: %w", err)
}
if err := os.Chmod(tmpName, perm); err != nil {
return fmt.Errorf("write file atomic: chmod temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("write file atomic: rename temp file: %w", err)
}
removeTmp = false
return nil
}
@@ -256,62 +223,21 @@ func (s *LocalStore) ReleaseSessionLock(lock *LockHandle) error {
}
func resolveInRoot(root, relative string) (string, error) {
rel := filepath.Clean(relative)
if rel == "." || rel == "" {
joined, err := pathsafe.JoinSlashRelativeUnderRoot(root, filepath.ToSlash(relative))
if err != nil {
switch {
case errors.Is(err, pathsafe.ErrRelativePathRequired):
return "", fmt.Errorf("relative destination path is required")
case errors.Is(err, pathsafe.ErrRelativePathAbsolute):
return "", fmt.Errorf("relative destination must not be absolute: %q", relative)
case errors.Is(err, pathsafe.ErrRelativePathEscape):
return "", fmt.Errorf("relative destination escapes root: %q", relative)
default:
return "", fmt.Errorf("resolve destination in root: %w", err)
}
}
if strings.TrimSpace(joined) == "" {
return "", fmt.Errorf("relative destination path is required")
}
if filepath.IsAbs(rel) {
return "", fmt.Errorf("relative destination must not be absolute: %q", relative)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("relative destination escapes root: %q", relative)
}
return filepath.Join(root, rel), nil
}
func copyFileAtomic(srcPath, dstPath string, perm os.FileMode) error {
src, err := os.Open(srcPath)
if err != nil {
return err
}
defer src.Close()
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return err
}
dir := filepath.Dir(dstPath)
base := filepath.Base(dstPath)
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpName)
}
}()
if _, err := io.Copy(tmp, src); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, perm); err != nil {
return err
}
if err := os.Rename(tmpName, dstPath); err != nil {
return err
}
removeTmp = false
return nil
return joined, nil
}

View File

@@ -5,6 +5,7 @@ import (
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
@@ -36,10 +37,11 @@ func CollectPreviousArtifactRequirements(
inputNames := sortedScriptoriumInputKeys(artifactCfg.Inputs)
for _, inputName := range inputNames {
inputCfg := artifactCfg.Inputs[inputName]
previousName, ok := PreviousSessionArtifactName(inputCfg.Source)
if !ok {
descriptor, err := artifactpolicy.DescribeScriptoriumInputSource(inputCfg.Source)
if err != nil || descriptor.PreviousSession == nil {
continue
}
previousName := descriptor.PreviousSession.ConfiguredKey
location := fmt.Sprintf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source",

View File

@@ -31,6 +31,18 @@ func TestRuntimeTranscriptArtifacts(t *testing.T) {
ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed,
},
{
SourceID: ArtifactTranscriptFinalMarkdown,
CanonicalRelPath: TranscriptPathFinalMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalMarkdown,
},
{
SourceID: ArtifactTranscriptFinalTrimmedMarkdown,
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
},
}
got := RuntimeTranscriptArtifacts()
@@ -75,6 +87,18 @@ func TestPlannedTranscriptArtifacts(t *testing.T) {
ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed,
},
{
SourceID: ArtifactTranscriptFinalMarkdown,
CanonicalRelPath: TranscriptPathFinalMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalMarkdown,
},
{
SourceID: ArtifactTranscriptFinalTrimmedMarkdown,
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
ProducerStage: "render",
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
},
}
got := PlannedTranscriptArtifacts()
@@ -113,10 +137,14 @@ func TestRuntimeArtifactRegistryUsesTranscriptSpecs(t *testing.T) {
if !ok {
t.Fatalf("artifactRegistry missing %q", transcript.SourceID)
}
wantContentKind := contentTranscriptJSON
if transcript.SourceID == ArtifactTranscriptFinalMarkdown || transcript.SourceID == ArtifactTranscriptFinalTrimmedMarkdown {
wantContentKind = contentText
}
if spec.CanonicalRelPath != transcript.CanonicalRelPath ||
spec.ProducerStage != transcript.ProducerStage ||
spec.OutputKind != transcript.OutputKind ||
spec.ContentKind != contentTranscriptJSON {
spec.ContentKind != wantContentKind {
t.Fatalf("artifactRegistry[%q] = %#v, want transcript spec %#v", transcript.SourceID, spec, transcript)
}
}

View File

@@ -2,16 +2,14 @@ package audio
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
)
// S3MaterializeRequest describes one S3-backed audio materialization.
@@ -61,7 +59,7 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
if ok, err := validCachedAudio(cachePath, req.Object.Size); err != nil {
return S3MaterializeResult{}, err
} else if ok {
checksum, err := copyFileAtomicWithChecksum(cachePath, req.DestPath, 0o644)
checksum, err := fileops.CopyFileAtomicWithChecksum(cachePath, req.DestPath, 0o644)
if err != nil {
return S3MaterializeResult{}, fmt.Errorf("materialize cached audio %q: %w", cachePath, err)
}
@@ -82,7 +80,7 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
return S3MaterializeResult{}, fmt.Errorf("validate downloaded audio %q: %w", spoolPath, err)
}
checksum, err := copyFileAtomicWithChecksum(spoolPath, req.DestPath, 0o644)
checksum, err := fileops.CopyFileAtomicWithChecksum(spoolPath, req.DestPath, 0o644)
if err != nil {
return S3MaterializeResult{}, fmt.Errorf("materialize downloaded audio %q: %w", filepath.Base(req.DestPath), err)
}
@@ -91,7 +89,7 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
result.Downloaded = true
if result.CachePath != "" {
if _, err := copyFileAtomicWithChecksum(spoolPath, result.CachePath, 0o644); err != nil {
if _, err := fileops.CopyFileAtomicWithChecksum(spoolPath, result.CachePath, 0o644); err != nil {
return S3MaterializeResult{}, fmt.Errorf("populate audio cache %q: %w", result.CachePath, err)
}
}
@@ -164,60 +162,9 @@ func downloadObjectAtomic(ctx context.Context, store storage.ObjectStore, key, d
if err := store.Download(ctx, key, tmpPath); err != nil {
return err
}
if err := os.Chmod(tmpPath, 0o644); err != nil {
return fmt.Errorf("set temp file permissions: %w", err)
}
if err := os.Rename(tmpPath, destPath); err != nil {
return fmt.Errorf("install downloaded file: %w", err)
if err := fileops.InstallDownloadedTempFile(tmpPath, destPath, 0o644); err != nil {
return err
}
removeTmp = false
return nil
}
func copyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, error) {
if strings.TrimSpace(src) == "" || strings.TrimSpace(dst) == "" {
return "", fmt.Errorf("source and destination paths are required")
}
in, err := os.Open(src)
if err != nil {
return "", err
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
base := filepath.Base(dst)
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+base+".tmp-*")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpPath)
}
}()
digest := sha256.New()
if _, err := io.Copy(io.MultiWriter(tmp, digest), in); err != nil {
_ = tmp.Close()
return "", fmt.Errorf("copy file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return "", fmt.Errorf("sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return "", fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpPath, perm); err != nil {
return "", fmt.Errorf("chmod temp file: %w", err)
}
if err := os.Rename(tmpPath, dst); err != nil {
return "", fmt.Errorf("install temp file: %w", err)
}
removeTmp = false
return hex.EncodeToString(digest.Sum(nil)), nil
}

View File

@@ -37,7 +37,7 @@ notification:
func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
@@ -52,7 +52,7 @@ func TestCampaignStrictDecodeAcceptsCampaignID(t *testing.T) {
func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"campaign: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
@@ -67,7 +67,7 @@ func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"campaign_id: sample-campaign\nunknown: true\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
@@ -82,7 +82,7 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"campaign_id: sample-campaign\nsession_template_file: ./session.template.yml\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
@@ -97,7 +97,7 @@ func TestCampaignStrictDecodeAcceptsSessionTemplateFile(t *testing.T) {
func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
@@ -114,12 +114,14 @@ func TestCampaignSessionMergeFillsStableInputs(t *testing.T) {
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./campaign-speakers.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./campaign-players.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./campaign-party.yml", campaignPath, "campaign_config")
}
func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n",
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n",
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./campaign-speakers.yml\n autocorrect_file: ./campaign-autocorrect.yml\n glossary_file: ./campaign-glossary.yml\n players_file: ./campaign-players.yml\n party_file: ./campaign-party.yml\n",
"session_id: 2026-05-03\ncampaign: sample-campaign\ninputs:\n audio_dir: ./audio\n speakers_file: ./session-speakers.yml\n players_file: ./session-players.yml\n party_file: ./session-party.yml\n",
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
@@ -132,11 +134,32 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
assertResolvedStableInput(t, cfg.StableInputs.SpeakersFile, "./session-speakers.yml", sessionPath, "session_config")
assertResolvedStableInput(t, cfg.StableInputs.AutocorrectFile, "./campaign-autocorrect.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.GlossaryFile, "./campaign-glossary.yml", campaignPath, "campaign_config")
assertResolvedStableInput(t, cfg.StableInputs.PlayersFile, "./session-players.yml", sessionPath, "session_config")
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config")
}
func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
err = Validate(cfg)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "campaign.inputs.players_file is required") {
t.Fatalf("error = %q, want players_file required", err.Error())
}
}
func TestCampaignSessionMismatchFails(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ncampaign: other-campaign\ninputs:\n audio_dir: ./audio\n",
)
@@ -151,7 +174,7 @@ func TestCampaignSessionMismatchFails(t *testing.T) {
func TestLoadMissingCampaignFileFails(t *testing.T) {
pipelinePath, _, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n",
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
)
missingCampaignPath := filepath.Join(filepath.Dir(sessionPath), "missing-campaign.yml")

View File

@@ -28,6 +28,7 @@ type PipelineConfig struct {
Audita AuditaConfig `yaml:"audita"`
Normalize *NormalizeConfig `yaml:"normalize"`
Trim *TrimConfig `yaml:"trim"`
Render *RenderConfig `yaml:"render"`
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
Notification NotificationConfig `yaml:"notification"`
}
@@ -50,6 +51,8 @@ type CampaignInputsConfig struct {
SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
}
// SessionConfig contains per-session inputs and metadata.
@@ -187,7 +190,7 @@ type NormalizeConfig struct {
// TrimConfig configures trim-stage transcript boundary behavior.
type TrimConfig struct {
Enabled bool `yaml:"enabled"`
Enabled *bool `yaml:"enabled"`
OutputPath string `yaml:"output_path"`
Bounds TrimBoundsConfig `yaml:"bounds"`
Seriatim TrimSeriatimConfig `yaml:"seriatim"`
@@ -209,6 +212,16 @@ type TrimSeriatimConfig struct {
Report *bool `yaml:"report"`
}
// RenderConfig configures render-stage output formatting behavior.
type RenderConfig struct {
Enabled *bool `yaml:"enabled"`
Format string `yaml:"format"`
Title string `yaml:"title"`
IncludeTimestamps *bool `yaml:"include_timestamps"`
IncludeSegmentIDs *bool `yaml:"include_segment_ids"`
IncludeMetadata bool `yaml:"include_metadata"`
}
// ScriptoriumConfig configures Scriptorium-backed artifact generation.
type ScriptoriumConfig struct {
Binary string `yaml:"binary"`
@@ -254,6 +267,8 @@ type SessionInputsConfig struct {
SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
}
// SessionAudioS3Input configures S3 session-audio input discovery.
@@ -267,6 +282,8 @@ type ResolvedStableInputs struct {
SpeakersFile ResolvedInputFile
AutocorrectFile ResolvedInputFile
GlossaryFile ResolvedInputFile
PlayersFile ResolvedInputFile
PartyFile ResolvedInputFile
}
// ResolvedInputFile records one merged config path and its source config file.

View File

@@ -38,8 +38,19 @@ const (
DefaultScriptoriumTimeout = "10m"
DefaultScriptoriumArtifactOutputRoot = "artifacts"
DefaultTrimEnabled = true
DefaultTrimOutputPath = artifactmodel.TranscriptPathFinalTrimmed
DefaultTrimBoundsPromptID = "dnd.session_bounds"
DefaultTrimBoundsTranscriptInputName = "transcript"
DefaultTrimBoundsOutputPath = "artifacts/session_bounds.json"
DefaultTrimBoundsTimeout = "10m"
DefaultTrimSeriatimReport = false
DefaultRenderEnabled = true
DefaultRenderFormat = "markdown"
DefaultRenderTitle = ""
DefaultRenderTimestamps = true
DefaultRenderSegmentIDs = true
DefaultRenderMetadata = false
DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
DefaultNormalizeOutputSchema = "seriatim-intermediate"
@@ -80,6 +91,8 @@ const (
// Callers should copy this slice before mutating.
var DefaultPublishOutputs = []PublishOutputRule{
{Source: artifactmodel.SourceTranscriptFinalTrimmed, Dest: PathTranscriptFinalTrimmed},
{Source: artifactmodel.SourceTranscriptFinalMarkdown, Dest: artifactmodel.TranscriptPathFinalMarkdown},
{Source: artifactmodel.SourceTranscriptFinalTrimmedMarkdown, Dest: artifactmodel.TranscriptPathFinalTrimmedMarkdown},
}
// DefaultPipelineConfigSearchPaths defines the default search order for

View File

@@ -226,11 +226,25 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
campaignPath,
sessionPath,
),
PlayersFile: selectStableInput(
campaignCfg.Inputs.PlayersFile,
sessionCfg.Inputs.PlayersFile,
campaignPath,
sessionPath,
),
PartyFile: selectStableInput(
campaignCfg.Inputs.PartyFile,
sessionCfg.Inputs.PartyFile,
campaignPath,
sessionPath,
),
}
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
sessionCfg.Inputs.AutocorrectFile = stable.AutocorrectFile.Path
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path
sessionCfg.Inputs.PartyFile = stable.PartyFile.Path
return stable, nil
}
@@ -336,7 +350,14 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
cfg.Normalize = &NormalizeConfig{}
}
applyNormalizeDefaults(cfg.Normalize)
if cfg.Trim == nil {
cfg.Trim = &TrimConfig{}
}
applyTrimDefaults(cfg.Trim)
if trimEnabled(cfg.Trim) && cfg.Scriptorium == nil {
cfg.Scriptorium = &ScriptoriumConfig{}
}
applyRenderDefaults(&cfg.Render)
applyScriptoriumDefaults(cfg.Scriptorium)
}
@@ -499,6 +520,21 @@ func applyTrimDefaults(cfg *TrimConfig) {
if cfg == nil {
return
}
if cfg.Enabled == nil {
cfg.Enabled = boolPtr(DefaultTrimEnabled)
}
if strings.TrimSpace(cfg.OutputPath) == "" {
cfg.OutputPath = DefaultTrimOutputPath
}
if strings.TrimSpace(cfg.Bounds.PromptID) == "" {
cfg.Bounds.PromptID = DefaultTrimBoundsPromptID
}
if strings.TrimSpace(cfg.Bounds.TranscriptInputName) == "" {
cfg.Bounds.TranscriptInputName = DefaultTrimBoundsTranscriptInputName
}
if strings.TrimSpace(cfg.Bounds.OutputPath) == "" {
cfg.Bounds.OutputPath = DefaultTrimBoundsOutputPath
}
if cfg.Bounds.Timeout == "" {
cfg.Bounds.Timeout = DefaultTrimBoundsTimeout
}
@@ -507,6 +543,34 @@ func applyTrimDefaults(cfg *TrimConfig) {
}
}
func trimEnabled(cfg *TrimConfig) bool {
return cfg != nil && cfg.Enabled != nil && *cfg.Enabled
}
func applyRenderDefaults(cfg **RenderConfig) {
if cfg == nil {
return
}
if *cfg == nil {
*cfg = &RenderConfig{}
}
if (*cfg).Enabled == nil {
(*cfg).Enabled = boolPtr(DefaultRenderEnabled)
}
if strings.TrimSpace((*cfg).Format) == "" {
(*cfg).Format = DefaultRenderFormat
}
if strings.TrimSpace((*cfg).Title) == "" {
(*cfg).Title = DefaultRenderTitle
}
if (*cfg).IncludeTimestamps == nil {
(*cfg).IncludeTimestamps = boolPtr(DefaultRenderTimestamps)
}
if (*cfg).IncludeSegmentIDs == nil {
(*cfg).IncludeSegmentIDs = boolPtr(DefaultRenderSegmentIDs)
}
}
func applyNormalizeDefaults(cfg *NormalizeConfig) {
if cfg == nil {
return

View File

@@ -36,6 +36,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
checkDefault: true,
wantRoot: "/tmp/narratio",
@@ -55,6 +57,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
checkDefault: true,
wantRoot: "/tmp/narratio",
@@ -72,6 +76,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
checkDefault: true,
wantRoot: DefaultWorkspaceRoot,
@@ -88,6 +94,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantLoadErr: "pipeline file",
},
@@ -106,6 +114,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantLoadErr: "strict decode failed",
},
@@ -123,6 +133,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantLoadErr: "strict decode failed",
},
@@ -143,6 +155,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantLoadErr: "strict decode failed",
},
@@ -163,6 +177,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.secrets.env_dir must be non-empty when pipeline.secrets is configured",
},
@@ -177,6 +193,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
unknown_field: true
`,
wantLoadErr: "session file",
@@ -196,6 +214,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "session config \"session.yml\" invalid: session.session_id is required",
},
@@ -215,6 +235,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
},
{
@@ -233,6 +255,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "session config \"session.yml\" invalid: session.previous_session_id must not equal session.session_id",
},
@@ -250,6 +274,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url is required",
},
@@ -268,6 +294,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.transcribe_url must be a valid URL",
},
@@ -287,6 +315,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.timeout must be a valid duration",
},
@@ -306,6 +336,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retry_delay must be a valid duration",
},
@@ -325,6 +357,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.retries must be >= 0",
},
@@ -344,6 +378,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.whisperx.concurrency must be > 0",
},
@@ -363,6 +399,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantLoadErr: "strict decode failed",
},
@@ -383,6 +421,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantLoadErr: "strict decode failed",
},
@@ -401,6 +441,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
},
{
@@ -419,6 +461,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.timeout must be a valid duration",
},
@@ -438,6 +482,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.output_schema must be one of: seriatim-minimal, seriatim-intermediate, seriatim-full",
},
@@ -457,6 +503,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.coalesce_gap must be >= 0",
},
@@ -477,6 +525,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.seriatim.env.overlap_word_run_gap must be > 0 when provided",
},
@@ -498,6 +548,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantLoadErr: "strict decode failed",
},
@@ -518,6 +570,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
},
{
@@ -538,6 +592,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.timeout must be a valid duration",
},
@@ -559,6 +615,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
},
{
@@ -581,6 +639,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be non-empty",
},
@@ -604,6 +664,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.modules[1] must be one of: glossary, homophones, spoken_word, grammar",
},
@@ -625,6 +687,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.base_url must be a valid URL",
},
@@ -646,6 +710,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantLoadErr: "strict decode failed",
},
@@ -667,6 +733,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.total_llm_concurrency must be > 0",
},
@@ -688,6 +756,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.proposal_llm_concurrency must be > 0",
},
@@ -709,6 +779,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.validation_llm_concurrency must be > 0",
},
@@ -730,6 +802,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.output_schema must be one of: bare-segments, audita-v1",
},
@@ -751,6 +825,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantValidate: "pipeline config \"pipeline.yml\" invalid: pipeline.audita.work_dir_retention must be one of: always, auto, never",
},
@@ -916,7 +992,16 @@ func TestValidateMissingAudioSource(t *testing.T) {
Report: boolPtr(true),
},
},
Campaign: &CampaignConfig{CampaignID: "sample-campaign"},
Campaign: &CampaignConfig{
CampaignID: "sample-campaign",
Inputs: CampaignInputsConfig{
SpeakersFile: "speakers.yml",
AutocorrectFile: "autocorrect.yml",
GlossaryFile: "glossary.yml",
PlayersFile: "players.yml",
PartyFile: "party.yml",
},
},
Session: &SessionConfig{
SessionID: "2026-05-03",
Campaign: "sample-campaign",
@@ -924,6 +1009,8 @@ func TestValidateMissingAudioSource(t *testing.T) {
SpeakersFile: "speakers.yml",
AutocorrectFile: "autocorrect.yml",
GlossaryFile: "glossary.yml",
PlayersFile: "players.yml",
PartyFile: "party.yml",
},
},
}
@@ -1009,6 +1096,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign.yml: %v", err)

View File

@@ -0,0 +1,140 @@
package config
import (
"strings"
"testing"
)
func TestRenderLoadAndValidate(t *testing.T) {
tests := []struct {
name string
renderYAML string
wantLoadErr string
wantValidateErr string
assert func(t *testing.T, cfg *Config)
}{
{
name: "render defaults when omitted",
renderYAML: "",
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Render == nil {
t.Fatal("render config should be present via defaults")
}
if cfg.Pipeline.Render.Enabled == nil || !*cfg.Pipeline.Render.Enabled {
t.Fatalf("render.enabled = %#v, want true", cfg.Pipeline.Render.Enabled)
}
if cfg.Pipeline.Render.Format != "markdown" {
t.Fatalf("render.format = %q, want markdown", cfg.Pipeline.Render.Format)
}
if cfg.Pipeline.Render.IncludeTimestamps == nil || !*cfg.Pipeline.Render.IncludeTimestamps {
t.Fatalf("render.include_timestamps = %#v, want true", cfg.Pipeline.Render.IncludeTimestamps)
}
if cfg.Pipeline.Render.IncludeSegmentIDs == nil || !*cfg.Pipeline.Render.IncludeSegmentIDs {
t.Fatalf("render.include_segment_ids = %#v, want true", cfg.Pipeline.Render.IncludeSegmentIDs)
}
if cfg.Pipeline.Render.IncludeMetadata {
t.Fatalf("render.include_metadata = true, want false")
}
},
},
{
name: "valid explicit render config",
renderYAML: `render:
enabled: false
format: markdown
title: Session Render
include_timestamps: false
include_segment_ids: true
include_metadata: true
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Render == nil {
t.Fatal("render config should be present")
}
if cfg.Pipeline.Render.Enabled == nil || *cfg.Pipeline.Render.Enabled {
t.Fatalf("render.enabled = %#v, want false", cfg.Pipeline.Render.Enabled)
}
if cfg.Pipeline.Render.IncludeTimestamps == nil || *cfg.Pipeline.Render.IncludeTimestamps {
t.Fatalf("render.include_timestamps = %#v, want false", cfg.Pipeline.Render.IncludeTimestamps)
}
if cfg.Pipeline.Render.IncludeSegmentIDs == nil || !*cfg.Pipeline.Render.IncludeSegmentIDs {
t.Fatalf("render.include_segment_ids = %#v, want true", cfg.Pipeline.Render.IncludeSegmentIDs)
}
if !cfg.Pipeline.Render.IncludeMetadata {
t.Fatalf("render.include_metadata = false, want true")
}
},
},
{
name: "explicit segment ids false overrides default",
renderYAML: `render:
include_segment_ids: false
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Render.IncludeSegmentIDs == nil || *cfg.Pipeline.Render.IncludeSegmentIDs {
t.Fatalf("render.include_segment_ids = %#v, want false", cfg.Pipeline.Render.IncludeSegmentIDs)
}
},
},
{
name: "invalid render format fails",
renderYAML: `render:
format: html
`,
wantValidateErr: "pipeline.render.format must be markdown",
},
{
name: "unknown render field fails strict decoding",
renderYAML: `render:
format: markdown
unknown: true
`,
wantLoadErr: "strict decode failed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pipelineYAML := testPipelineBaseYAML
if tt.renderYAML != "" {
pipelineYAML += "\n" + tt.renderYAML
}
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if tt.wantLoadErr != "" {
if err == nil {
t.Fatalf("expected load error containing %q, got nil", tt.wantLoadErr)
}
if !strings.Contains(err.Error(), tt.wantLoadErr) {
t.Fatalf("load error = %q, want to contain %q", err.Error(), tt.wantLoadErr)
}
return
}
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if tt.assert != nil {
tt.assert(t, cfg)
}
err = Validate(cfg)
if tt.wantValidateErr != "" {
if err == nil {
t.Fatalf("expected validation error containing %q, got nil", tt.wantValidateErr)
}
if !strings.Contains(err.Error(), tt.wantValidateErr) {
t.Fatalf("validation error = %q, want to contain %q", err.Error(), tt.wantValidateErr)
}
return
}
if err != nil {
t.Fatalf("Validate() error = %v", err)
}
})
}
}

View File

@@ -200,6 +200,42 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
transcript:
source: narratio.transcript.final_trimmed
required: true
`,
},
{
name: "markdown built in artifact source is accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript_markdown:
source: narratio.transcript.final_markdown
required: true
`,
},
{
name: "prepared stable input sources are accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
players:
source: narratio.input.players
required: true
party:
source: narratio.input.party
required: true
glossary:
source: narratio.input.glossary
required: false
`,
},
{
@@ -560,4 +596,6 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`

View File

@@ -17,6 +17,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
@@ -40,6 +42,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
@@ -65,6 +69,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
@@ -93,6 +99,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
@@ -124,6 +132,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
@@ -148,6 +158,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)

View File

@@ -179,18 +179,29 @@ func TestSpoolAndPublishDefaults(t *testing.T) {
if cfg.Pipeline.Publish.UploadRun == nil || !*cfg.Pipeline.Publish.UploadRun {
t.Fatalf("publish.upload_run = %#v, want true", cfg.Pipeline.Publish.UploadRun)
}
if len(cfg.Pipeline.Publish.Outputs) != 1 {
t.Fatalf("publish.outputs len = %d, want 1 default", len(cfg.Pipeline.Publish.Outputs))
if len(cfg.Pipeline.Publish.Outputs) != 3 {
t.Fatalf("publish.outputs len = %d, want 3 defaults", len(cfg.Pipeline.Publish.Outputs))
}
item := cfg.Pipeline.Publish.Outputs[0]
wantBySource := map[string]string{
"narratio.transcript.final_trimmed": "transcripts/final.trimmed.json",
"narratio.transcript.final_markdown": "transcripts/final.md",
"narratio.transcript.final_trimmed_markdown": "transcripts/final.trimmed.md",
}
for i, item := range cfg.Pipeline.Publish.Outputs {
if item.Required == nil || !*item.Required {
t.Fatalf("publish.outputs[0].required = %#v, want true", item.Required)
t.Fatalf("publish.outputs[%d].required = %#v, want true", i, item.Required)
}
if item.Source != "narratio.transcript.final_trimmed" {
t.Fatalf("publish.outputs[0].source = %q, want narratio.transcript.final_trimmed", item.Source)
wantDest, ok := wantBySource[item.Source]
if !ok {
t.Fatalf("publish.outputs[%d].source = %q, want known default source", i, item.Source)
}
if item.Dest != "transcripts/final.trimmed.json" {
t.Fatalf("publish.outputs[0].dest = %q, want transcripts/final.trimmed.json", item.Dest)
if item.Dest != wantDest {
t.Fatalf("publish.outputs[%d].dest = %q, want %q", i, item.Dest, wantDest)
}
delete(wantBySource, item.Source)
}
if len(wantBySource) != 0 {
t.Fatalf("missing default publish outputs for sources: %#v", wantBySource)
}
}
@@ -227,6 +238,15 @@ func TestPublishOutputValidation(t *testing.T) {
`,
wantErr: "source \"narratio.unknown\" is unsupported",
},
{
name: "prepared input source rejected",
ruleYML: `publish:
outputs:
- source: "narratio.input.players"
dest: "inputs/players.yml"
`,
wantErr: "source \"narratio.input.players\" is unsupported",
},
{
name: "duplicate destination rejected",
ruleYML: `publish:
@@ -337,6 +357,15 @@ publish:
`,
wantDest: "artifacts/session_recap.md",
},
{
name: "markdown built in derives canonical destination",
pipelineYML: testPipelineBaseYAML + `
publish:
outputs:
- source: narratio.transcript.final_markdown
`,
wantDest: "transcripts/final.md",
},
}
for _, tt := range tests {
@@ -406,6 +435,15 @@ publish:
`,
wantErr: "pipeline.publish.locks[0].source \"narratio.unknown\" is unsupported",
},
{
name: "prepared input source rejected",
pipelineYML: testPipelineBaseYAML + `
publish:
locks:
- source: narratio.input.players
`,
wantErr: "pipeline.publish.locks[0].source \"narratio.input.players\" is unsupported",
},
{
name: "duplicate source rejected",
pipelineYML: testPipelineBaseYAML + `
@@ -559,6 +597,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
},
{
@@ -571,6 +611,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantErr: "session.inputs.audio_s3.prefix must be a relative path",
},
@@ -584,6 +626,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantErr: "session.inputs.audio_s3.prefix must not contain path traversal",
},
@@ -598,6 +642,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`,
wantErr: "mutually exclusive",
},
@@ -644,6 +690,8 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, sessionYAML)
cfg, err := Load(pipelinePath, sessionPath)

View File

@@ -13,6 +13,20 @@ func TestTrimLoadAndValidate(t *testing.T) {
wantValidateErr string
assert func(t *testing.T, cfg *Config)
}{
{
name: "trim defaults when omitted",
trimYAML: "",
assert: func(t *testing.T, cfg *Config) {
t.Helper()
assertDefaultTrimConfig(t, cfg)
if cfg.Pipeline.Scriptorium == nil {
t.Fatal("scriptorium config should be defaulted when trim is enabled by default")
}
if cfg.Pipeline.Scriptorium.Binary != DefaultScriptoriumBinary {
t.Fatalf("scriptorium.binary = %q, want %q", cfg.Pipeline.Scriptorium.Binary, DefaultScriptoriumBinary)
}
},
},
{
name: "valid trim config",
trimYAML: `trim:
@@ -34,8 +48,8 @@ func TestTrimLoadAndValidate(t *testing.T) {
if cfg.Pipeline.Trim == nil {
t.Fatal("trim config should be present")
}
if cfg.Pipeline.Trim.Enabled != true {
t.Fatalf("trim.enabled = %t, want true", cfg.Pipeline.Trim.Enabled)
if cfg.Pipeline.Trim.Enabled == nil || !*cfg.Pipeline.Trim.Enabled {
t.Fatalf("trim.enabled = %#v, want true", cfg.Pipeline.Trim.Enabled)
}
if cfg.Pipeline.Trim.Bounds.ProfileID != "" {
t.Fatalf("trim.bounds.profile_id = %q, want empty", cfg.Pipeline.Trim.Bounds.ProfileID)
@@ -43,68 +57,26 @@ func TestTrimLoadAndValidate(t *testing.T) {
},
},
{
name: "enabled omitted defaults disabled",
name: "enabled omitted defaults enabled",
trimYAML: `trim:
output_path: transcripts/final.trimmed.json
bounds:
prompt_id: dnd_session.bounds
transcript_input_name: transcript
output_path: artifacts/session_bounds.json
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Trim == nil {
t.Fatal("trim config should be present")
}
if cfg.Pipeline.Trim.Enabled {
t.Fatal("trim.enabled should default to false when omitted")
assertDefaultTrimConfig(t, cfg)
},
},
{
name: "explicit disabled remains disabled",
trimYAML: `trim:
enabled: false
`,
assert: func(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Trim == nil || cfg.Pipeline.Trim.Enabled == nil || *cfg.Pipeline.Trim.Enabled {
t.Fatalf("trim.enabled = %#v, want false", cfg.Pipeline.Trim)
}
},
},
{
name: "missing prompt id fails when enabled",
trimYAML: `trim:
enabled: true
output_path: transcripts/final.trimmed.json
bounds:
transcript_input_name: transcript
output_path: artifacts/session_bounds.json
`,
wantValidateErr: "pipeline.trim.bounds.prompt_id is required when pipeline.trim.enabled is true",
},
{
name: "missing transcript input name fails when enabled",
trimYAML: `trim:
enabled: true
output_path: transcripts/final.trimmed.json
bounds:
prompt_id: dnd_session.bounds
output_path: artifacts/session_bounds.json
`,
wantValidateErr: "pipeline.trim.bounds.transcript_input_name is required when pipeline.trim.enabled is true",
},
{
name: "missing bounds output path fails when enabled",
trimYAML: `trim:
enabled: true
output_path: transcripts/final.trimmed.json
bounds:
prompt_id: dnd_session.bounds
transcript_input_name: transcript
`,
wantValidateErr: "pipeline.trim.bounds.output_path is required when pipeline.trim.enabled is true",
},
{
name: "missing trimmed output path fails when enabled",
trimYAML: `trim:
enabled: true
bounds:
prompt_id: dnd_session.bounds
transcript_input_name: transcript
output_path: artifacts/session_bounds.json
`,
wantValidateErr: "pipeline.trim.output_path is required when pipeline.trim.enabled is true",
},
{
name: "invalid timeout fails",
trimYAML: `trim:
@@ -185,3 +157,31 @@ func TestTrimLoadAndValidate(t *testing.T) {
})
}
}
func assertDefaultTrimConfig(t *testing.T, cfg *Config) {
t.Helper()
if cfg.Pipeline.Trim == nil {
t.Fatal("trim config should be present")
}
if cfg.Pipeline.Trim.Enabled == nil || !*cfg.Pipeline.Trim.Enabled {
t.Fatalf("trim.enabled = %#v, want true", cfg.Pipeline.Trim.Enabled)
}
if cfg.Pipeline.Trim.OutputPath != DefaultTrimOutputPath {
t.Fatalf("trim.output_path = %q, want %q", cfg.Pipeline.Trim.OutputPath, DefaultTrimOutputPath)
}
if cfg.Pipeline.Trim.Bounds.PromptID != DefaultTrimBoundsPromptID {
t.Fatalf("trim.bounds.prompt_id = %q, want %q", cfg.Pipeline.Trim.Bounds.PromptID, DefaultTrimBoundsPromptID)
}
if cfg.Pipeline.Trim.Bounds.TranscriptInputName != DefaultTrimBoundsTranscriptInputName {
t.Fatalf("trim.bounds.transcript_input_name = %q, want %q", cfg.Pipeline.Trim.Bounds.TranscriptInputName, DefaultTrimBoundsTranscriptInputName)
}
if cfg.Pipeline.Trim.Bounds.OutputPath != DefaultTrimBoundsOutputPath {
t.Fatalf("trim.bounds.output_path = %q, want %q", cfg.Pipeline.Trim.Bounds.OutputPath, DefaultTrimBoundsOutputPath)
}
if cfg.Pipeline.Trim.Bounds.Timeout != DefaultTrimBoundsTimeout {
t.Fatalf("trim.bounds.timeout = %q, want %q", cfg.Pipeline.Trim.Bounds.Timeout, DefaultTrimBoundsTimeout)
}
if cfg.Pipeline.Trim.Seriatim.Report == nil || *cfg.Pipeline.Trim.Seriatim.Report != DefaultTrimSeriatimReport {
t.Fatalf("trim.seriatim.report = %#v, want %t", cfg.Pipeline.Trim.Seriatim.Report, DefaultTrimSeriatimReport)
}
}

View File

@@ -9,7 +9,6 @@ import (
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
@@ -52,6 +51,21 @@ func validateCampaign(cfg *CampaignConfig) error {
if CampaignID(cfg) == "" {
return fmt.Errorf("campaign.campaign_id is required")
}
if strings.TrimSpace(cfg.Inputs.SpeakersFile) == "" {
return fmt.Errorf("campaign.inputs.speakers_file is required")
}
if strings.TrimSpace(cfg.Inputs.AutocorrectFile) == "" {
return fmt.Errorf("campaign.inputs.autocorrect_file is required")
}
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
return fmt.Errorf("campaign.inputs.glossary_file is required")
}
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
return fmt.Errorf("campaign.inputs.players_file is required")
}
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("campaign.inputs.party_file is required")
}
return nil
}
@@ -89,6 +103,9 @@ func validatePipeline(cfg *PipelineConfig) error {
if err := validateTrim(cfg.Trim); err != nil {
return err
}
if err := validateRender(cfg.Render); err != nil {
return err
}
if err := validateScriptorium(cfg.Scriptorium); err != nil {
return err
}
@@ -274,7 +291,10 @@ func validateTrim(cfg *TrimConfig) error {
if cfg == nil {
return nil
}
if !cfg.Enabled {
if cfg.Enabled == nil {
return fmt.Errorf("pipeline.trim.enabled must be set (defaults should populate this)")
}
if !*cfg.Enabled {
return nil
}
@@ -300,6 +320,29 @@ func validateTrim(cfg *TrimConfig) error {
return nil
}
func validateRender(cfg *RenderConfig) error {
if cfg == nil {
return nil
}
if cfg.Enabled == nil {
return fmt.Errorf("pipeline.render.enabled must be set (defaults should populate this)")
}
if cfg.IncludeTimestamps == nil {
return fmt.Errorf("pipeline.render.include_timestamps must be set (defaults should populate this)")
}
if cfg.IncludeSegmentIDs == nil {
return fmt.Errorf("pipeline.render.include_segment_ids must be set (defaults should populate this)")
}
format := strings.TrimSpace(cfg.Format)
if format != "markdown" {
return fmt.Errorf("pipeline.render.format must be markdown")
}
if cfg.Title != "" && strings.TrimSpace(cfg.Title) == "" {
return fmt.Errorf("pipeline.render.title must be non-empty when provided")
}
return nil
}
func validateWhisperX(cfg WhisperXConfig) error {
if strings.TrimSpace(cfg.TranscribeURL) == "" {
return fmt.Errorf("pipeline.whisperx.transcribe_url is required")
@@ -567,6 +610,12 @@ func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
return fmt.Errorf("session.inputs.glossary_file is required")
}
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
return fmt.Errorf("session.inputs.players_file is required")
}
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("session.inputs.party_file is required")
}
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
@@ -639,13 +688,9 @@ var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) {
trimmedSource := strings.TrimSpace(source)
if isStaticSupportedScriptoriumInputSource(trimmedSource) {
return "", nil
}
if strings.HasPrefix(trimmedSource, "narratio.previous_session.artifact") {
referenced, ok := artifactpolicy.ParsePreviousSessionSource(trimmedSource)
if !ok {
descriptor, err := artifactpolicy.DescribeScriptoriumInputSource(trimmedSource)
if err != nil {
if errors.Is(err, artifactpolicy.ErrInvalidPreviousSessionSource) {
return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$",
artifactName,
@@ -653,20 +698,6 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
source,
)
}
if _, ok := configuredArtifacts[referenced]; !ok {
return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
artifactName,
inputName,
source,
referenced,
)
}
return "", nil
}
referenced, ok := artifactpolicy.ParseConfiguredSource(trimmedSource)
if !ok {
return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
artifactName,
@@ -674,28 +705,28 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
source,
)
}
if _, ok := configuredArtifacts[referenced]; !ok {
if err := artifactpolicy.ValidateInputConfiguredReference(descriptor, configuredArtifacts); err != nil {
var unknownConfigured *artifactpolicy.UnknownConfiguredArtifactError
if errors.As(err, &unknownConfigured) {
return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
artifactName,
inputName,
source,
referenced,
unknownConfigured.ConfiguredKey,
)
}
return referenced, nil
return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
artifactName,
inputName,
source,
)
}
func isStaticSupportedScriptoriumInputSource(source string) bool {
if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(source); ok {
return true
}
switch source {
case "narratio.bounds.session":
return true
default:
return false
if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact {
return descriptor.Source.ConfiguredKey, nil
}
return "", nil
}
func validateEnvVarNameField(fieldName, value string) error {

128
internal/fileops/fileops.go Normal file
View File

@@ -0,0 +1,128 @@
package fileops
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// WriteFileAtomic writes data to dst atomically via temp file + rename.
func WriteFileAtomic(dst string, data []byte, perm os.FileMode) error {
if strings.TrimSpace(dst) == "" {
return fmt.Errorf("destination path is required")
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
base := filepath.Base(dst)
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+base+".tmp-*")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpPath)
}
}()
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return fmt.Errorf("write temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpPath, perm); err != nil {
return fmt.Errorf("set temp file permissions: %w", err)
}
if err := os.Rename(tmpPath, dst); err != nil {
return fmt.Errorf("install temp file: %w", err)
}
removeTmp = false
return nil
}
// CopyFileAtomic copies src to dst atomically via temp file + rename.
func CopyFileAtomic(src, dst string, perm os.FileMode) error {
_, err := CopyFileAtomicWithChecksum(src, dst, perm)
return err
}
// CopyFileAtomicWithChecksum copies src to dst atomically and returns the SHA-256 checksum.
func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, error) {
if strings.TrimSpace(src) == "" || strings.TrimSpace(dst) == "" {
return "", fmt.Errorf("source and destination paths are required")
}
in, err := os.Open(src)
if err != nil {
return "", fmt.Errorf("open source file: %w", err)
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
base := filepath.Base(dst)
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+base+".tmp-*")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpPath)
}
}()
digest := sha256.New()
if _, err := io.Copy(io.MultiWriter(tmp, digest), in); err != nil {
_ = tmp.Close()
return "", fmt.Errorf("copy file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return "", fmt.Errorf("sync temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return "", fmt.Errorf("close temp file: %w", err)
}
if err := os.Chmod(tmpPath, perm); err != nil {
return "", fmt.Errorf("set temp file permissions: %w", err)
}
if err := os.Rename(tmpPath, dst); err != nil {
return "", fmt.Errorf("install temp file: %w", err)
}
removeTmp = false
return hex.EncodeToString(digest.Sum(nil)), nil
}
// InstallDownloadedTempFile installs a previously downloaded temp file at dst.
func InstallDownloadedTempFile(tmpPath, dst string, perm os.FileMode) error {
if strings.TrimSpace(tmpPath) == "" || strings.TrimSpace(dst) == "" {
return fmt.Errorf("temp and destination paths are required")
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
if err := os.Chmod(tmpPath, perm); err != nil {
return fmt.Errorf("set temp file permissions: %w", err)
}
if err := os.Rename(tmpPath, dst); err != nil {
return fmt.Errorf("install downloaded file: %w", err)
}
return nil
}

View File

@@ -0,0 +1,125 @@
package fileops
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteFileAtomicOverwritesAndLeavesNoTempFile(t *testing.T) {
root := t.TempDir()
dst := filepath.Join(root, "out", "value.txt")
if err := WriteFileAtomic(dst, []byte("one"), 0o644); err != nil {
t.Fatalf("WriteFileAtomic(first) error = %v", err)
}
if err := WriteFileAtomic(dst, []byte("two"), 0o644); err != nil {
t.Fatalf("WriteFileAtomic(second) error = %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "two" {
t.Fatalf("file content = %q, want %q", string(data), "two")
}
assertNoMatchingTempFiles(t, filepath.Dir(dst), "."+filepath.Base(dst)+".tmp-")
}
func TestWriteFileAtomicCleansTempFileOnInstallFailure(t *testing.T) {
root := t.TempDir()
blockedPath := filepath.Join(root, "blocked")
if err := os.MkdirAll(blockedPath, 0o755); err != nil {
t.Fatalf("MkdirAll(blockedPath) error = %v", err)
}
err := WriteFileAtomic(blockedPath, []byte("data"), 0o644)
if err == nil {
t.Fatal("WriteFileAtomic() error = nil, want install failure")
}
assertNoMatchingTempFiles(t, root, ".blocked.tmp-")
}
func TestCopyFileAtomicWithChecksumMatchesDestination(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source.txt")
dst := filepath.Join(root, "out", "copied.txt")
if err := os.WriteFile(src, []byte("copied-data"), 0o644); err != nil {
t.Fatalf("WriteFile(source) error = %v", err)
}
checksum, err := CopyFileAtomicWithChecksum(src, dst, 0o644)
if err != nil {
t.Fatalf("CopyFileAtomicWithChecksum() error = %v", err)
}
wantChecksum := "6e5c3f239e28cc315d57b2fcfc24169369c44a25802c0616a6d7081707fd24df"
if checksum != wantChecksum {
t.Fatalf("checksum = %q, want %q", checksum, wantChecksum)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile(destination) error = %v", err)
}
if string(data) != "copied-data" {
t.Fatalf("destination content = %q, want %q", string(data), "copied-data")
}
assertNoMatchingTempFiles(t, filepath.Dir(dst), ".copied.txt.tmp-")
}
func TestCopyFileAtomicCleansTempFileOnInstallFailure(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source.txt")
if err := os.WriteFile(src, []byte("copied-data"), 0o644); err != nil {
t.Fatalf("WriteFile(source) error = %v", err)
}
blockedPath := filepath.Join(root, "blocked")
if err := os.MkdirAll(blockedPath, 0o755); err != nil {
t.Fatalf("MkdirAll(blockedPath) error = %v", err)
}
err := CopyFileAtomic(src, blockedPath, 0o644)
if err == nil {
t.Fatal("CopyFileAtomic() error = nil, want install failure")
}
assertNoMatchingTempFiles(t, root, ".blocked.tmp-")
}
func TestInstallDownloadedTempFileSetsPermissions(t *testing.T) {
root := t.TempDir()
tmpPath := filepath.Join(root, ".payload.tmp")
dst := filepath.Join(root, "out", "payload.json")
if err := os.WriteFile(tmpPath, []byte("{\"ok\":true}\n"), 0o600); err != nil {
t.Fatalf("WriteFile(temp) error = %v", err)
}
if err := InstallDownloadedTempFile(tmpPath, dst, 0o644); err != nil {
t.Fatalf("InstallDownloadedTempFile() error = %v", err)
}
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Fatalf("temp file still exists: stat err = %v", err)
}
info, err := os.Stat(dst)
if err != nil {
t.Fatalf("Stat(destination) error = %v", err)
}
if info.Mode().Perm() != 0o644 {
t.Fatalf("destination mode = %o, want 644", info.Mode().Perm())
}
}
func assertNoMatchingTempFiles(t *testing.T, dir, prefix string) {
t.Helper()
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir(%q) error = %v", dir, err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), prefix) {
t.Fatalf("unexpected temp file residue: %s", filepath.Join(dir, e.Name()))
}
}
}

View File

@@ -2,6 +2,8 @@ package pathsafe
import (
"errors"
"path/filepath"
"strings"
"testing"
)
@@ -41,3 +43,76 @@ func TestNormalizeRelativeDestination(t *testing.T) {
})
}
}
func TestJoinSlashRelativeUnderRoot(t *testing.T) {
root := filepath.Join(t.TempDir(), "session")
tests := []struct {
name string
input string
want string
wantErr error
}{
{name: "valid relative", input: "artifacts/session_recap.md", want: filepath.Join(root, "artifacts", "session_recap.md")},
{name: "windows separators normalized", input: `artifacts\session_recap.md`, want: filepath.Join(root, "artifacts", "session_recap.md")},
{name: "reject empty", input: "", wantErr: ErrRelativePathRequired},
{name: "reject absolute", input: "/artifacts/session_recap.md", wantErr: ErrRelativePathAbsolute},
{name: "reject traversal", input: "../artifacts/session_recap.md", wantErr: ErrRelativePathEscape},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := JoinSlashRelativeUnderRoot(root, tt.input)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("JoinSlashRelativeUnderRoot() error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("JoinSlashRelativeUnderRoot() error = %v", err)
}
if got != tt.want {
t.Fatalf("JoinSlashRelativeUnderRoot() = %q, want %q", got, tt.want)
}
})
}
}
func TestSlashRelativeFromRoot(t *testing.T) {
root := filepath.Join(t.TempDir(), "session")
target := filepath.Join(root, "transcripts", "full.json")
got, err := SlashRelativeFromRoot(root, target)
if err != nil {
t.Fatalf("SlashRelativeFromRoot() error = %v", err)
}
if got != "transcripts/full.json" {
t.Fatalf("SlashRelativeFromRoot() = %q, want transcripts/full.json", got)
}
got, err = SlashRelativeFromRoot(root, `transcripts\full.json`)
if err != nil {
t.Fatalf("SlashRelativeFromRoot(relative with windows separators) error = %v", err)
}
if got != "transcripts/full.json" {
t.Fatalf("SlashRelativeFromRoot(relative with windows separators) = %q, want transcripts/full.json", got)
}
}
func TestSlashRelativeFromRootRejectsOutsideRoot(t *testing.T) {
root := filepath.Join(t.TempDir(), "session")
outside := filepath.Join(filepath.Dir(root), "outside", "file.txt")
_, err := SlashRelativeFromRoot(root, outside)
if !errors.Is(err, ErrRelativePathEscape) {
t.Fatalf("SlashRelativeFromRoot() error = %v, want %v", err, ErrRelativePathEscape)
}
}
func TestJoinSlashRelativeUnderRootRequiresRoot(t *testing.T) {
_, err := JoinSlashRelativeUnderRoot("", "artifacts/session_recap.md")
if err == nil || !strings.Contains(err.Error(), "root path is required") {
t.Fatalf("JoinSlashRelativeUnderRoot() error = %v, want root-required error", err)
}
}

View File

@@ -0,0 +1,58 @@
package pathsafe
import (
"fmt"
"path/filepath"
"strings"
)
// JoinSlashRelativeUnderRoot validates a slash-style relative path and resolves
// it under root. The returned path uses the host filepath separator.
func JoinSlashRelativeUnderRoot(root, relative string) (string, error) {
rootClean := filepath.Clean(strings.TrimSpace(root))
if rootClean == "." || rootClean == "" {
return "", fmt.Errorf("root path is required")
}
normalized, err := NormalizeRelativeDestination(relative)
if err != nil {
return "", err
}
joined := filepath.Clean(filepath.Join(rootClean, filepath.FromSlash(normalized)))
rel, err := filepath.Rel(rootClean, joined)
if err != nil {
return "", fmt.Errorf("resolve relative path under root: %w", err)
}
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", ErrRelativePathEscape
}
return joined, nil
}
// SlashRelativeFromRoot derives a slash-style relative path for target under
// root. Target may be absolute or relative to root.
func SlashRelativeFromRoot(root, target string) (string, error) {
rootClean := filepath.Clean(strings.TrimSpace(root))
if rootClean == "." || rootClean == "" {
return "", fmt.Errorf("root path is required")
}
targetClean := filepath.Clean(strings.TrimSpace(target))
if targetClean == "." || targetClean == "" {
return "", ErrRelativePathRequired
}
if !filepath.IsAbs(targetClean) {
targetClean = filepath.Clean(filepath.Join(rootClean, targetClean))
}
rel, err := filepath.Rel(rootClean, targetClean)
if err != nil {
return "", fmt.Errorf("derive path relative to root: %w", err)
}
normalized, err := NormalizeRelativeDestination(filepath.ToSlash(rel))
if err != nil {
return "", err
}
return normalized, nil
}

View File

@@ -229,7 +229,11 @@ func artifactRelativePathCandidates(
candidates = append(candidates, normalized)
}
sourceDescriptor, err := artifactpolicy.PreviousSessionSourceDescriptorForConfiguredKey(artifactName)
sourceID := artifactpolicy.ConfiguredSourceID(artifactName)
if err == nil {
sourceID = sourceDescriptor.ConfiguredSourceID
}
if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok {
appendCandidate(rel)
base := path.Base(rel)
@@ -309,11 +313,7 @@ func deriveManifestRelativePath(previousManifest *manifest.Manifest, localPath s
if !ok {
return "", false
}
rel, err := filepath.Rel(sessionRoot, trimmed)
if err != nil {
return "", false
}
normalized, err := pathsafe.NormalizeRelativeDestination(filepath.ToSlash(rel))
normalized, err := pathsafe.SlashRelativeFromRoot(sessionRoot, trimmed)
if err != nil {
return "", false
}
@@ -375,11 +375,7 @@ func relativeToSession(paths artifacts.SessionPaths, localPath string) (string,
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("session root is required")
}
rel, err := filepath.Rel(root, filepath.Clean(localPath))
if err != nil {
return "", fmt.Errorf("resolve previous-cache relative path: %w", err)
}
normalized, err := pathsafe.NormalizeRelativeDestination(filepath.ToSlash(rel))
normalized, err := pathsafe.SlashRelativeFromRoot(root, localPath)
if err != nil {
return "", fmt.Errorf("resolve previous-cache relative path: %w", err)
}

View File

@@ -628,11 +628,21 @@ func resolveScriptoriumInput(
runtimeCatalog *artifacts.ArtifactCatalog,
) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
source := strings.TrimSpace(inputCfg.Source)
classified, classifyErr := artifactpolicy.ClassifySource(source)
if classifyErr != nil {
return "", false, nil, classifyErr
descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source)
if describeErr != nil {
return "", false, nil, describeErr
}
if classified.Kind == artifactpolicy.SourceKindPreviousArtifact {
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, paths)
if err != nil {
if inputCfg.Required {
return "", false, nil, err
}
return "", false, nil, nil
}
return resolvedPath, ok, nil, nil
}
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
if err == nil {
copy := resolved
@@ -657,19 +667,25 @@ func resolveScriptoriumInput(
return resolved.Path, true, &copy, nil
}
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
if classified.Kind == artifactpolicy.SourceKindConfiguredArtifact {
if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact {
if inputCfg.Required {
return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source)
}
return "", false, nil, nil
}
switch classified.ID {
switch descriptor.Source.ID {
case artifacts.ArtifactTranscriptPolished:
return "", false, nil, nil
case artifacts.ArtifactTranscriptFinal:
return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
case artifacts.ArtifactTranscriptFinalTrimmed:
return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first")
case artifacts.ArtifactTranscriptFinalMarkdown, artifacts.ArtifactTranscriptFinalTrimmedMarkdown:
return "", false, nil, fmt.Errorf(
"rendered markdown transcript input is unavailable for source %q; run narratio run-stage render %s --force",
descriptor.Source.ID,
paths.SessionID,
)
default:
return "", false, nil, nil
}
@@ -678,6 +694,36 @@ func resolveScriptoriumInput(
}
}
func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) {
filename, ok := preparedStableInputFilename(sourceID)
if !ok {
return "", false, fmt.Errorf("unsupported prepared input source %q", sourceID)
}
path := filepath.Join(paths.InputsDir, filename)
if err := requireNonEmptyFile(path, "prepared input "+sourceID); err != nil {
return "", false, fmt.Errorf(
"prepared input source %q is unavailable; run narratio run-stage prepare %s --force: %w",
sourceID,
paths.SessionID,
err,
)
}
return path, true, nil
}
func preparedStableInputFilename(sourceID string) (string, bool) {
switch strings.TrimSpace(sourceID) {
case artifactpolicy.SourceInputPlayers:
return "players.yml", true
case artifactpolicy.SourceInputParty:
return "party.yml", true
case artifactpolicy.SourceInputGlossary:
return "glossary.yml", true
default:
return "", false
}
}
func buildAnalyzeRuntimeArtifactCatalog(
paths artifacts.SessionPaths,
scriptoriumCfg *config.ScriptoriumConfig,

View File

@@ -961,6 +961,121 @@ func TestAnalyzeSupportsNormalizedTranscriptSourceWhenConfigured(t *testing.T) {
}
}
func TestAnalyzeSupportsRenderedMarkdownTranscriptSourceWhenConfigured(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
markdownPath := filepath.Join(paths.TranscriptsDir, "final.md")
writeAnalyzeFile(t, markdownPath, "# Session Transcript\n")
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
Source: "narratio.transcript.final_markdown",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(fake.RunRequests) != 1 {
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
}
if fake.RunRequests[0].InputPaths["transcript"] != markdownPath {
t.Fatalf("transcript input = %q, want markdown transcript path", fake.RunRequests[0].InputPaths["transcript"])
}
}
func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
playersPath := filepath.Join(paths.InputsDir, "players.yml")
partyPath := filepath.Join(paths.InputsDir, "party.yml")
glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml")
writeAnalyzeFile(t, playersPath, "- Eric\n")
writeAnalyzeFile(t, partyPath, "- Arannis\n")
writeAnalyzeFile(t, glossaryPath, "- term: Ten Towns\n")
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
Source: "narratio.input.players",
Required: true,
}
artifact.Inputs["party"] = config.ScriptoriumInputConfig{
Source: "narratio.input.party",
Required: true,
}
artifact.Inputs["glossary"] = config.ScriptoriumInputConfig{
Source: "narratio.input.glossary",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(fake.RunRequests) != 1 {
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
}
if fake.RunRequests[0].InputPaths["players"] != playersPath {
t.Fatalf("players input = %q, want %q", fake.RunRequests[0].InputPaths["players"], playersPath)
}
if fake.RunRequests[0].InputPaths["party"] != partyPath {
t.Fatalf("party input = %q, want %q", fake.RunRequests[0].InputPaths["party"], partyPath)
}
if fake.RunRequests[0].InputPaths["glossary"] != glossaryPath {
t.Fatalf("glossary input = %q, want %q", fake.RunRequests[0].InputPaths["glossary"], glossaryPath)
}
}
func TestAnalyzeMissingRequiredPreparedStableInputFailsWithPrepareGuidance(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
Source: "narratio.input.players",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "prepared input source \"narratio.input.players\" is unavailable") ||
!strings.Contains(err.Error(), "run narratio run-stage prepare 2026-05-03 --force") {
t.Fatalf("error = %q, want prepared input guidance", err.Error())
}
}
func TestAnalyzeMissingOptionalPreparedStableInputIsOmitted(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
Source: "narratio.input.players",
Required: false,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(fake.RunRequests) != 1 {
t.Fatalf("run requests = %d, want 1", len(fake.RunRequests))
}
if _, ok := fake.RunRequests[0].InputPaths["players"]; ok {
t.Fatalf("players input should be omitted: %#v", fake.RunRequests[0].InputPaths)
}
}
func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
@@ -1045,6 +1160,42 @@ func TestAnalyzeFailsWhenNormalizedTranscriptMissing(t *testing.T) {
}
}
func TestAnalyzeFailsWhenRenderedMarkdownTranscriptMissing(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
Source: "narratio.transcript.final_markdown",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "run narratio run-stage render") || !strings.Contains(err.Error(), "--force") {
t.Fatalf("error = %q, want render guidance", err.Error())
}
}
func TestAnalyzeFailsWhenRenderedTrimmedMarkdownTranscriptMissing(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["transcript"] = config.ScriptoriumInputConfig{
Source: "narratio.transcript.final_trimmed_markdown",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "run narratio run-stage render") || !strings.Contains(err.Error(), "--force") {
t.Fatalf("error = %q, want render guidance", err.Error())
}
}
func TestAnalyzeFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)

View File

@@ -99,7 +99,7 @@ func (normalizeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (
stderrPath = filepath.Join(runLayout.LogsDir, "seriatim.normalize.stderr.log")
generatedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.normalize.generated.yml")
}
timeout, err := resolveTrimSeriatimTimeout(env.Config.Pipeline.Seriatim.Timeout)
timeout, err := resolveSeriatimStageTimeout(env.Config.Pipeline.Seriatim.Timeout)
if err != nil {
return nil, fmt.Errorf("normalize: resolve seriatim timeout: %w", err)
}

View File

@@ -74,6 +74,7 @@ func All() []Stage {
polishStage{},
normalizeStage{},
trimStage{},
renderStage{},
analyzeStage{},
publishStage{},
placeholderStage{name: "notify"},

View File

@@ -33,11 +33,13 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
campaignPath := filepath.Join(cfgDir, "campaign.yml")
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
writeStageTestFile(t, sessionPath, "session_id: 2026-05-03\n")
writeStageTestFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
writeStageTestFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
writeStageTestFile(t, pipelinePath, "workspace:\n root: "+root+"\n")
writeStageTestFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeStageTestFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
writeStageTestFile(t, filepath.Join(cfgDir, "audio", "alice.flac"), "a")
wf := &whisperx.FakeClient{}
@@ -82,6 +84,16 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
ConfigPath: campaignPath,
Source: "campaign_config",
},
PlayersFile: config.ResolvedInputFile{
Path: "./players.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PartyFile: config.ResolvedInputFile{
Path: "./party.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
},
Session: &config.SessionConfig{
SessionID: "2026-05-03",
@@ -101,7 +113,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
}
m := manifest.New("2026-05-03", time.Now().UTC())
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
m.RunID = "20260516T000000Z-abcdef12"
@@ -194,6 +206,15 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
}
continue
}
if s.Name() == "render" {
if result.Metadata["stage"] != "render" {
t.Fatalf("render metadata = %#v, want stage=render", result.Metadata)
}
if len(result.Outputs) != 2 {
t.Fatalf("render outputs = %#v, want 2 markdown outputs", result.Outputs)
}
continue
}
if s.Name() == "publish" {
if result.Metadata["stage"] != "publish" {
t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata)

View File

@@ -32,6 +32,8 @@ func (prepareStage) Declares() IODecl {
{Kind: "config", Category: "inputs", RelativePath: "speakers.yml"},
{Kind: "config", Category: "inputs", RelativePath: "autocorrect.yml"},
{Kind: "config", Category: "inputs", RelativePath: "glossary.yml"},
{Kind: "config", Category: "inputs", RelativePath: "players.yml"},
{Kind: "config", Category: "inputs", RelativePath: "party.yml"},
{Kind: "audio", Category: "audio", RelativePath: "*.flac"},
},
}
@@ -75,6 +77,8 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
speakersInput := stableInputSource(env.Config.StableInputs.SpeakersFile, env.Config.Session.Inputs.SpeakersFile, sessionSrc)
autocorrectInput := stableInputSource(env.Config.StableInputs.AutocorrectFile, env.Config.Session.Inputs.AutocorrectFile, sessionSrc)
glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc)
playersInput := stableInputSource(env.Config.StableInputs.PlayersFile, env.Config.Session.Inputs.PlayersFile, sessionSrc)
partyInput := stableInputSource(env.Config.StableInputs.PartyFile, env.Config.Session.Inputs.PartyFile, sessionSrc)
speakersSrc, err := resolveConfigRelativePath(speakersInput)
if err != nil {
@@ -88,6 +92,14 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil {
return nil, fmt.Errorf("prepare: glossary path: %w", err)
}
playersSrc, err := resolveConfigRelativePath(playersInput)
if err != nil {
return nil, fmt.Errorf("prepare: players path: %w", err)
}
partySrc, err := resolveConfigRelativePath(partyInput)
if err != nil {
return nil, fmt.Errorf("prepare: party path: %w", err)
}
for _, required := range []struct {
path string
@@ -96,6 +108,8 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
{path: speakersSrc, name: "speakers.yml"},
{path: autocorrectSrc, name: "autocorrect.yml"},
{path: glossarySrc, name: "glossary.yml"},
{path: playersSrc, name: "players.yml"},
{path: partySrc, name: "party.yml"},
} {
if err := requireFile(required.path, required.name); err != nil {
return nil, fmt.Errorf("prepare: %w", err)
@@ -107,7 +121,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err)
}
inputs := make([]manifest.InputRecord, 0, 6+len(resolvedLocalAudio))
inputs := make([]manifest.InputRecord, 0, 8+len(resolvedLocalAudio))
registerInput := func(kind, path, checksum string) {
inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum})
}
@@ -166,6 +180,8 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
{kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source},
{kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source},
{kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source},
{kind: "players", src: playersSrc, dst: filepath.Join(paths.InputsDir, "players.yml"), source: playersInput.Source},
{kind: "party", src: partySrc, dst: filepath.Join(paths.InputsDir, "party.yml"), source: partyInput.Source},
} {
checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst)
if err != nil {

View File

@@ -8,6 +8,7 @@ import (
"sort"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/previouscache"
)
@@ -54,8 +55,35 @@ func hydratePreviousSessionArtifacts(
if err := os.MkdirAll(filepath.Dir(record.LocalPath), 0o755); err != nil {
return nil, fmt.Errorf("create previous-session path directory for %q: %w", record.LocalPath, err)
}
if err := env.ObjectStore.Download(ctx, record.RemoteKey, record.LocalPath); err != nil {
return nil, fmt.Errorf("download previous-session object %q to %q: %w", record.RemoteKey, record.LocalPath, err)
base := filepath.Base(record.LocalPath)
tmp, err := os.CreateTemp(filepath.Dir(record.LocalPath), "."+base+".prepare-previous-*.tmp")
if err != nil {
return nil, fmt.Errorf("create previous-session temp file for %q: %w", record.LocalPath, err)
}
tmpPath := tmp.Name()
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return nil, fmt.Errorf("close previous-session temp file for %q: %w", record.LocalPath, err)
}
if err := func() error {
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpPath)
}
}()
if err := env.ObjectStore.Download(ctx, record.RemoteKey, tmpPath); err != nil {
return fmt.Errorf("download previous-session object %q to temp file: %w", record.RemoteKey, err)
}
if err := fileops.InstallDownloadedTempFile(tmpPath, record.LocalPath, 0o644); err != nil {
return fmt.Errorf("install previous-session object %q at %q: %w", record.RemoteKey, record.LocalPath, err)
}
removeTmp = false
return nil
}(); err != nil {
return nil, err
}
if record.Kind == preparePreviousInputKindArtifact {
if err := requireNonEmptyFile(record.LocalPath, "previous-session artifact "+record.RequirementName); err != nil {

View File

@@ -41,6 +41,8 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) {
filepath.Join(paths.InputsDir, "speakers.yml"),
filepath.Join(paths.InputsDir, "autocorrect.yml"),
filepath.Join(paths.InputsDir, "glossary.yml"),
filepath.Join(paths.InputsDir, "players.yml"),
filepath.Join(paths.InputsDir, "party.yml"),
filepath.Join(paths.AudioDir, "alice.flac"),
filepath.Join(paths.AudioDir, "bob.flac"),
} {
@@ -49,8 +51,8 @@ func TestPrepareStageExplicitAudioFiles(t *testing.T) {
}
}
if len(m.Inputs) != 8 {
t.Fatalf("manifest inputs len = %d, want 8", len(m.Inputs))
if len(m.Inputs) != 10 {
t.Fatalf("manifest inputs len = %d, want 10", len(m.Inputs))
}
for _, in := range m.Inputs {
if in.Checksum == "" {
@@ -613,11 +615,15 @@ inputs:
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
`)
writeFile(t, sessionPath, "session_id: 2026-05-03\n")
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
cfg := &config.Config{
Pipeline: &config.PipelineConfig{
@@ -651,6 +657,16 @@ inputs:
ConfigPath: campaignPath,
Source: "campaign_config",
},
PlayersFile: config.ResolvedInputFile{
Path: "./players.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
PartyFile: config.ResolvedInputFile{
Path: "./party.yml",
ConfigPath: campaignPath,
Source: "campaign_config",
},
},
}

View File

@@ -32,6 +32,7 @@ var publishPrerequisiteStages = []string{
"polish",
"normalize",
"trim",
"render",
"analyze",
}

292
internal/stage/render.go Normal file
View File

@@ -0,0 +1,292 @@
package stage
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
type renderStage struct{}
func (renderStage) Name() string { return "render" }
func (renderStage) Declares() IODecl {
return IODecl{
Inputs: []artifacts.Ref{
{Kind: "transcript_final", Category: "transcripts", RelativePath: "transcripts/final.json"},
{Kind: "transcript_final_trimmed", Category: "transcripts", RelativePath: "transcripts/final.trimmed.json"},
},
Outputs: []artifacts.Ref{
{Kind: artifacts.TranscriptOutputKindFinalMarkdown, Category: "transcripts", RelativePath: artifacts.TranscriptPathFinalMarkdown},
{Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown, Category: "transcripts", RelativePath: artifacts.TranscriptPathFinalTrimmedMarkdown},
},
}
}
func (renderStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
if env == nil || env.Config == nil {
return nil, fmt.Errorf("render: stage environment config is required")
}
if env.ArtifactStore == nil {
return nil, fmt.Errorf("render: artifact store is required")
}
if env.Config.Pipeline == nil || env.Config.Session == nil {
return nil, fmt.Errorf("render: resolved config must include pipeline and session")
}
if env.Seriatim == nil {
return nil, fmt.Errorf("render: seriatim adapter is required")
}
var sessionID string
if m != nil {
sessionID = strings.TrimSpace(m.SessionID)
}
if sessionID == "" {
sessionID = strings.TrimSpace(env.Config.Session.SessionID)
}
if sessionID == "" {
return nil, fmt.Errorf("render: session id is required")
}
paths := sessionPathsForEnv(env, sessionID)
runLayout, err := resolveRunStageLayout(env, m, paths, sessionID, "render")
if err != nil {
return nil, fmt.Errorf("render: resolve run-stage layout: %w", err)
}
renderCfg := renderConfigOrDefault(env.Config.Pipeline.Render)
enabled := renderCfg.Enabled == nil || *renderCfg.Enabled
format := strings.TrimSpace(renderCfg.Format)
if format == "" {
format = config.DefaultRenderFormat
}
title := resolveRenderTitle(renderCfg, env.Config.Session)
includeTimestamps := renderCfg.IncludeTimestamps == nil || *renderCfg.IncludeTimestamps
includeSegmentIDs := config.DefaultRenderSegmentIDs
if renderCfg.IncludeSegmentIDs != nil {
includeSegmentIDs = *renderCfg.IncludeSegmentIDs
}
includeMetadata := renderCfg.IncludeMetadata
meta := map[string]any{
"stage": "render",
"render_enabled": enabled,
"format": format,
"title": title,
"include_timestamps": includeTimestamps,
"include_segment_ids": includeSegmentIDs,
"include_metadata": includeMetadata,
"binary": env.Config.Pipeline.Seriatim.Binary,
"timeout": env.Config.Pipeline.Seriatim.Timeout,
}
if !enabled {
meta["skipped"] = true
meta["reason"] = "pipeline.render.enabled is false"
return &StageResult{Metadata: meta}, nil
}
finalInput, err := artifacts.ResolveSessionArtifact(paths, m, artifacts.ArtifactTranscriptFinal)
if err != nil {
return nil, wrapRenderInputResolveError(err, sessionID, artifacts.ArtifactTranscriptFinal, "normalize")
}
finalTrimmedInput, err := artifacts.ResolveSessionArtifact(paths, m, artifacts.ArtifactTranscriptFinalTrimmed)
if err != nil {
return nil, wrapRenderInputResolveError(err, sessionID, artifacts.ArtifactTranscriptFinalTrimmed, "trim")
}
canonicalFinalMarkdownPath, err := resolveScriptoriumOutputPath(paths, artifacts.TranscriptPathFinalMarkdown)
if err != nil {
return nil, fmt.Errorf("render: resolve canonical final markdown output path: %w", err)
}
canonicalFinalTrimmedMarkdownPath, err := resolveScriptoriumOutputPath(paths, artifacts.TranscriptPathFinalTrimmedMarkdown)
if err != nil {
return nil, fmt.Errorf("render: resolve canonical final trimmed markdown output path: %w", err)
}
runFinalMarkdownPath, err := runLocalPathForCanonical(runLayout, paths, canonicalFinalMarkdownPath)
if err != nil {
return nil, fmt.Errorf("render: resolve run-local final markdown output path: %w", err)
}
runFinalTrimmedMarkdownPath, err := runLocalPathForCanonical(runLayout, paths, canonicalFinalTrimmedMarkdownPath)
if err != nil {
return nil, fmt.Errorf("render: resolve run-local final trimmed markdown output path: %w", err)
}
finalStdoutLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final.stdout.log")
finalStderrLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final.stderr.log")
finalGeneratedConfigPath := filepath.Join(paths.ConfigDir, "seriatim.render.final.generated.yml")
finalTrimmedStdoutLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final_trimmed.stdout.log")
finalTrimmedStderrLogPath := filepath.Join(paths.LogsDir, "seriatim.render.final_trimmed.stderr.log")
finalTrimmedGeneratedConfigPath := filepath.Join(paths.ConfigDir, "seriatim.render.final_trimmed.generated.yml")
if runLayout.Enabled {
finalStdoutLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final.stdout.log")
finalStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final.stderr.log")
finalGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.render.final.generated.yml")
finalTrimmedStdoutLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final_trimmed.stdout.log")
finalTrimmedStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.render.final_trimmed.stderr.log")
finalTrimmedGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.render.final_trimmed.generated.yml")
}
timeout, err := resolveSeriatimStageTimeout(env.Config.Pipeline.Seriatim.Timeout)
if err != nil {
return nil, fmt.Errorf("render: resolve seriatim timeout: %w", err)
}
finalReq := seriatim.RenderRequest{
Binary: env.Config.Pipeline.Seriatim.Binary,
InputTranscriptPath: finalInput.Path,
OutputRenderedPath: runFinalMarkdownPath,
Format: format,
Title: title,
IncludeTimestamps: includeTimestamps,
IncludeSegmentIDs: includeSegmentIDs,
IncludeMetadata: includeMetadata,
StdoutLogPath: finalStdoutLogPath,
StderrLogPath: finalStderrLogPath,
GeneratedConfigPath: finalGeneratedConfigPath,
Timeout: timeout,
}
finalRes, err := env.Seriatim.Render(ctx, finalReq)
if err != nil {
return nil, fmt.Errorf("render: seriatim render failed for %q: %w", artifacts.ArtifactTranscriptFinal, err)
}
finalRenderedPath := coalesceString(finalRes.OutputRenderedPath, finalReq.OutputRenderedPath)
if err := requireNonEmptyFile(finalRenderedPath, "final transcript markdown output"); err != nil {
return nil, fmt.Errorf("render: %w", err)
}
finalTrimmedReq := seriatim.RenderRequest{
Binary: env.Config.Pipeline.Seriatim.Binary,
InputTranscriptPath: finalTrimmedInput.Path,
OutputRenderedPath: runFinalTrimmedMarkdownPath,
Format: format,
Title: title,
IncludeTimestamps: includeTimestamps,
IncludeSegmentIDs: includeSegmentIDs,
IncludeMetadata: includeMetadata,
StdoutLogPath: finalTrimmedStdoutLogPath,
StderrLogPath: finalTrimmedStderrLogPath,
GeneratedConfigPath: finalTrimmedGeneratedConfigPath,
Timeout: timeout,
}
finalTrimmedRes, err := env.Seriatim.Render(ctx, finalTrimmedReq)
if err != nil {
return nil, fmt.Errorf("render: seriatim render failed for %q: %w", artifacts.ArtifactTranscriptFinalTrimmed, err)
}
finalTrimmedRenderedPath := coalesceString(finalTrimmedRes.OutputRenderedPath, finalTrimmedReq.OutputRenderedPath)
if err := requireNonEmptyFile(finalTrimmedRenderedPath, "final trimmed transcript markdown output"); err != nil {
return nil, fmt.Errorf("render: %w", err)
}
materializedFinalMarkdown, err := materializeRunLocalOutput(env.ArtifactStore, finalRenderedPath, canonicalFinalMarkdownPath, artifacts.Ref{
Kind: artifacts.TranscriptOutputKindFinalMarkdown,
Category: "transcripts",
SessionID: sessionID,
})
if err != nil {
return nil, fmt.Errorf("render: materialize canonical final markdown output: %w", err)
}
materializedFinalTrimmedMarkdown, err := materializeRunLocalOutput(env.ArtifactStore, finalTrimmedRenderedPath, canonicalFinalTrimmedMarkdownPath, artifacts.Ref{
Kind: artifacts.TranscriptOutputKindFinalTrimmedMarkdown,
Category: "transcripts",
SessionID: sessionID,
})
if err != nil {
return nil, fmt.Errorf("render: materialize canonical final trimmed markdown output: %w", err)
}
meta["final_input_path"] = finalInput.Path
meta["final_input_provenance"] = finalInput.Provenance
meta["final_input_run_id"] = finalInput.ProducerRunID
meta["final_trimmed_input_path"] = finalTrimmedInput.Path
meta["final_trimmed_input_provenance"] = finalTrimmedInput.Provenance
meta["final_trimmed_input_run_id"] = finalTrimmedInput.ProducerRunID
meta["run_final_markdown_path"] = finalRenderedPath
meta["final_markdown_path"] = canonicalFinalMarkdownPath
meta["run_final_trimmed_markdown_path"] = finalTrimmedRenderedPath
meta["final_trimmed_markdown_path"] = canonicalFinalTrimmedMarkdownPath
populateRenderAdapterMetadata(meta, "final_adapter_", finalRes)
populateRenderAdapterMetadata(meta, "final_trimmed_adapter_", finalTrimmedRes)
return &StageResult{
Outputs: []artifacts.Ref{
materializedFinalMarkdown,
materializedFinalTrimmedMarkdown,
},
Logs: dedupeAndSortPaths([]string{
finalStdoutLogPath,
finalStderrLogPath,
finalTrimmedStdoutLogPath,
finalTrimmedStderrLogPath,
}),
GeneratedConfigs: dedupeAndSortPaths([]string{
finalGeneratedConfigPath,
finalTrimmedGeneratedConfigPath,
}),
Metadata: meta,
}, nil
}
func renderConfigOrDefault(cfg *config.RenderConfig) *config.RenderConfig {
if cfg != nil {
return cfg
}
enabled := true
includeTimestamps := true
includeSegmentIDs := config.DefaultRenderSegmentIDs
return &config.RenderConfig{
Enabled: &enabled,
Format: config.DefaultRenderFormat,
IncludeTimestamps: &includeTimestamps,
IncludeSegmentIDs: &includeSegmentIDs,
IncludeMetadata: config.DefaultRenderMetadata,
}
}
func resolveRenderTitle(renderCfg *config.RenderConfig, sessionCfg *config.SessionConfig) string {
if renderCfg != nil && strings.TrimSpace(renderCfg.Title) != "" {
return strings.TrimSpace(renderCfg.Title)
}
if sessionCfg != nil && strings.TrimSpace(sessionCfg.Title) != "" {
return strings.TrimSpace(sessionCfg.Title)
}
return ""
}
func wrapRenderInputResolveError(err error, sessionID, sourceID, guidanceStage string) error {
var notFound *artifacts.SessionArtifactNotFoundError
if errors.As(err, &notFound) {
return fmt.Errorf(
"render: required input %q is unavailable; run narratio run-stage %s %s --force",
sourceID,
guidanceStage,
sessionID,
)
}
return fmt.Errorf("render: resolve %q input: %w", sourceID, err)
}
func populateRenderAdapterMetadata(meta map[string]any, prefix string, result seriatim.RenderResult) {
if meta == nil {
return
}
meta[prefix+"duration_ms"] = result.Duration.Milliseconds()
meta[prefix+"exit_code"] = result.ExitCode
meta[prefix+"invoked_binary"] = result.InvokedBinary
meta[prefix+"format"] = result.Format
meta[prefix+"title"] = result.Title
meta[prefix+"output_path"] = result.OutputRenderedPath
meta[prefix+"generated_config"] = result.GeneratedConfigPath
meta[prefix+"stdout_log_path"] = result.StdoutLogPath
meta[prefix+"stderr_log_path"] = result.StderrLogPath
if result.Metadata != nil {
meta[prefix+"metadata"] = result.Metadata
}
}

View File

@@ -0,0 +1,208 @@
package stage
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestRenderStageDisabledSucceedsWithoutOutputs(t *testing.T) {
env, m, ser := setupRenderEnv(t)
enabled := false
env.Config.Pipeline.Render.Enabled = &enabled
result, err := (renderStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("render.Run() error = %v", err)
}
if len(result.Outputs) != 0 {
t.Fatalf("outputs = %#v, want empty when disabled", result.Outputs)
}
if len(ser.RenderRequests) != 0 {
t.Fatalf("render requests = %d, want 0 when disabled", len(ser.RenderRequests))
}
if result.Metadata["render_enabled"] != false {
t.Fatalf("metadata render_enabled = %#v, want false", result.Metadata["render_enabled"])
}
}
func TestRenderStageResolvesManifestInputsFirst(t *testing.T) {
env, m, ser := setupRenderEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
manifestFinal := filepath.Join(paths.ArtifactsDir, "final.from-manifest.json")
manifestTrimmed := filepath.Join(paths.ArtifactsDir, "final.trimmed.from-manifest.json")
writeFile(t, manifestFinal, `{"segments":[{"id":101}]}`)
writeFile(t, manifestTrimmed, `{"segments":[{"id":202}]}`)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
m.MarkStageSucceeded("normalize", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final", LocalPath: manifestFinal}})
m.MarkStageSucceeded("trim", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "transcript_final_trimmed", LocalPath: manifestTrimmed}})
result, err := (renderStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("render.Run() error = %v", err)
}
if len(ser.RenderRequests) != 2 {
t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests))
}
if ser.RenderRequests[0].InputTranscriptPath != manifestFinal {
t.Fatalf("first input path = %q, want %q", ser.RenderRequests[0].InputTranscriptPath, manifestFinal)
}
if ser.RenderRequests[1].InputTranscriptPath != manifestTrimmed {
t.Fatalf("second input path = %q, want %q", ser.RenderRequests[1].InputTranscriptPath, manifestTrimmed)
}
if result.Metadata["final_input_provenance"] != "manifest.normalize.outputs" {
t.Fatalf("final_input_provenance = %#v, want manifest.normalize.outputs", result.Metadata["final_input_provenance"])
}
if result.Metadata["final_trimmed_input_provenance"] != "manifest.trim.outputs" {
t.Fatalf("final_trimmed_input_provenance = %#v, want manifest.trim.outputs", result.Metadata["final_trimmed_input_provenance"])
}
if len(result.Outputs) != 2 {
t.Fatalf("outputs = %#v, want 2", result.Outputs)
}
if result.Outputs[0].Kind != artifacts.TranscriptOutputKindFinalMarkdown {
t.Fatalf("outputs[0].kind = %q, want %q", result.Outputs[0].Kind, artifacts.TranscriptOutputKindFinalMarkdown)
}
if result.Outputs[1].Kind != artifacts.TranscriptOutputKindFinalTrimmedMarkdown {
t.Fatalf("outputs[1].kind = %q, want %q", result.Outputs[1].Kind, artifacts.TranscriptOutputKindFinalTrimmedMarkdown)
}
}
func TestRenderStageTitleFallsBackToSessionTitle(t *testing.T) {
env, m, ser := setupRenderEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
env.Config.Pipeline.Render.Title = ""
env.Config.Session.Title = "Session Title"
_, err := (renderStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("render.Run() error = %v", err)
}
if len(ser.RenderRequests) != 2 {
t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests))
}
if ser.RenderRequests[0].Title != "Session Title" || ser.RenderRequests[1].Title != "Session Title" {
t.Fatalf("render titles = %q/%q, want Session Title", ser.RenderRequests[0].Title, ser.RenderRequests[1].Title)
}
}
func TestRenderStageFailsWhenFinalInputMissing(t *testing.T) {
env, m, _ := setupRenderEnv(t)
_, err := (renderStage{}).Run(context.Background(), env, m)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "run narratio run-stage normalize") || !strings.Contains(err.Error(), "--force") {
t.Fatalf("error = %q, want normalize guidance", err.Error())
}
}
func TestRenderStageFailsWhenFinalTrimmedInputMissing(t *testing.T) {
env, m, _ := setupRenderEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
_, err := (renderStage{}).Run(context.Background(), env, m)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "run narratio run-stage trim") || !strings.Contains(err.Error(), "--force") {
t.Fatalf("error = %q, want trim guidance", err.Error())
}
}
func TestRenderStageUsesRunLocalOutputsAndMaterializesCanonical(t *testing.T) {
env, m, ser := setupRenderEnv(t)
env.Config.Session.Campaign = "sample-campaign"
m.Campaign = "sample-campaign"
m.RunID = "20260518T010203Z-abcdef12"
paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.json"), `{"segments":[{"id":1}]}`)
writeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"id":2}]}`)
result, err := (renderStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("render.Run() error = %v", err)
}
if len(ser.RenderRequests) != 2 {
t.Fatalf("render requests = %d, want 2", len(ser.RenderRequests))
}
if !strings.Contains(ser.RenderRequests[0].OutputRenderedPath, filepath.Join("runs", m.RunID, "render", "outputs")) {
t.Fatalf("run output path = %q, want run-local output path", ser.RenderRequests[0].OutputRenderedPath)
}
for _, out := range result.Outputs {
if strings.Contains(out.AbsolutePath, string(filepath.Separator)+"runs"+string(filepath.Separator)) {
t.Fatalf("materialized output path = %q, want canonical session path", out.AbsolutePath)
}
}
if len(result.GeneratedConfigs) != 2 {
t.Fatalf("generated configs = %#v, want two generated config paths", result.GeneratedConfigs)
}
if len(result.Logs) != 4 {
t.Fatalf("logs = %#v, want four log paths", result.Logs)
}
}
func setupRenderEnv(t *testing.T) (*Env, *manifest.Manifest, *seriatim.FakeRunner) {
t.Helper()
workspace := t.TempDir()
cfgDir := t.TempDir()
sessionPath := filepath.Join(cfgDir, "session.yml")
pipelinePath := filepath.Join(cfgDir, "pipeline.yml")
writeFile(t, sessionPath, "session_id: 2026-05-03\n")
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
enabled := true
includeTimestamps := true
includeSegmentIDs := false
seriatimReport := false
cfg := &config.Config{
PipelinePath: pipelinePath,
SessionPath: sessionPath,
Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: workspace},
Seriatim: config.SeriatimConfig{
Binary: "seriatim",
Timeout: "10m",
OutputSchema: "seriatim-intermediate",
CoalesceGap: func() *float64 { v := 3.0; return &v }(),
Report: &seriatimReport,
},
Render: &config.RenderConfig{
Enabled: &enabled,
Format: "markdown",
Title: "Pipeline Title",
IncludeTimestamps: &includeTimestamps,
IncludeSegmentIDs: &includeSegmentIDs,
IncludeMetadata: false,
},
},
Session: &config.SessionConfig{
SessionID: "2026-05-03",
Campaign: "sample-campaign",
},
}
store := artifacts.NewLocalStore(workspace)
if _, err := store.EnsureLayoutFor("sample-campaign", "2026-05-03"); err != nil {
t.Fatalf("EnsureLayout() error = %v", err)
}
ser := &seriatim.FakeRunner{}
return &Env{
Config: cfg,
ArtifactStore: store,
Seriatim: ser,
}, manifest.New("2026-05-03", time.Now().UTC()), ser
}

View File

@@ -9,6 +9,7 @@ import (
"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/pathsafe"
)
type runStageLayout struct {
@@ -92,18 +93,17 @@ func runLocalPathForCanonical(layout runStageLayout, sessionPaths artifacts.Sess
if cleanCanonical == "" {
return "", fmt.Errorf("canonical path is required")
}
rel, err := filepath.Rel(filepath.Clean(sessionPaths.Root), cleanCanonical)
rel, err := pathsafe.SlashRelativeFromRoot(sessionPaths.Root, cleanCanonical)
if err != nil {
return "", fmt.Errorf("derive session-relative path for %q: %w", cleanCanonical, err)
}
rel = filepath.Clean(rel)
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("canonical path %q is outside session root %q", cleanCanonical, sessionPaths.Root)
}
if rel == config.PathPreviousDirSegment || strings.HasPrefix(rel, config.PathPreviousDirSegment+string(filepath.Separator)) {
if rel == config.PathPreviousDirSegment || strings.HasPrefix(rel, config.PathPreviousDirSegment+"/") {
return cleanCanonical, nil
}
localPath := filepath.Join(layout.OutputsDir, rel)
localPath, err := pathsafe.JoinSlashRelativeUnderRoot(layout.OutputsDir, rel)
if err != nil {
return "", fmt.Errorf("resolve run-local output path for %q: %w", cleanCanonical, err)
}
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
return "", fmt.Errorf("create run-local output parent for %q: %w", localPath, err)
}

View File

@@ -236,10 +236,12 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
campaignPath := filepath.Join(cfgDir, "campaign.yml")
writeFile(t, sessionPath, "session_id: 2026-05-03\ncampaign: sample-campaign\n")
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
writeFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n")
writeFile(t, campaignPath, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n")
writeFile(t, filepath.Join(cfgDir, "speakers.yml"), "alice: alice.flac\n")
writeFile(t, filepath.Join(cfgDir, "autocorrect.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "glossary.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "players.yml"), "[]\n")
writeFile(t, filepath.Join(cfgDir, "party.yml"), "[]\n")
retries := 3
concurrency := 2
@@ -267,12 +269,16 @@ func setupTranscribeEnv(t *testing.T, audioFiles []string) (*Env, *manifest.Mani
SpeakersFile: "./speakers.yml",
AutocorrectFile: "./autocorrect.yml",
GlossaryFile: "./glossary.yml",
PlayersFile: "./players.yml",
PartyFile: "./party.yml",
},
},
StableInputs: config.ResolvedStableInputs{
SpeakersFile: config.ResolvedInputFile{Path: "./speakers.yml", ConfigPath: campaignPath, Source: "campaign_config"},
AutocorrectFile: config.ResolvedInputFile{Path: "./autocorrect.yml", ConfigPath: campaignPath, Source: "campaign_config"},
GlossaryFile: config.ResolvedInputFile{Path: "./glossary.yml", ConfigPath: campaignPath, Source: "campaign_config"},
PlayersFile: config.ResolvedInputFile{Path: "./players.yml", ConfigPath: campaignPath, Source: "campaign_config"},
PartyFile: config.ResolvedInputFile{Path: "./party.yml", ConfigPath: campaignPath, Source: "campaign_config"},
},
}

View File

@@ -71,7 +71,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag
}
trimCfg := env.Config.Pipeline.Trim
enabled := trimCfg != nil && trimCfg.Enabled
enabled := trimCfg != nil && trimCfg.Enabled != nil && *trimCfg.Enabled
canonicalTrimmedPath, err := resolveTrimmedOutputPath(paths, trimCfg)
if err != nil {
@@ -315,7 +315,7 @@ func (trimStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Stag
trimStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.trim.stderr.log")
trimGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.trim.generated.yml")
}
trimTimeout, err := resolveTrimSeriatimTimeout(env.Config.Pipeline.Seriatim.Timeout)
trimTimeout, err := resolveSeriatimStageTimeout(env.Config.Pipeline.Seriatim.Timeout)
if err != nil {
return nil, fmt.Errorf("trim: resolve seriatim timeout: %w", err)
}
@@ -398,7 +398,7 @@ func copyTranscript(store artifacts.Store, src, dst string) error {
return nil
}
func resolveTrimSeriatimTimeout(raw string) (time.Duration, error) {
func resolveSeriatimStageTimeout(raw string) (time.Duration, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return 0, nil

View File

@@ -275,7 +275,8 @@ func TestTrimStageDisabledCopiesNormalizedTranscript(t *testing.T) {
writeFile(t, normalized, normalizedBody)
disabled := *env.Config.Pipeline.Trim
disabled.Enabled = false
enabled := false
disabled.Enabled = &enabled
env.Config.Pipeline.Trim = &disabled
result, err := (trimStage{}).Run(context.Background(), env, m)
@@ -424,6 +425,7 @@ func setupTrimEnv(t *testing.T) (*Env, *manifest.Manifest, *boundsScriptoriumRun
writeFile(t, pipelinePath, "workspace:\n root: "+workspace+"\n")
seriatimReport := false
trimEnabled := true
cfg := &config.Config{
PipelinePath: pipelinePath,
SessionPath: sessionPath,
@@ -437,7 +439,7 @@ func setupTrimEnv(t *testing.T) (*Env, *manifest.Manifest, *boundsScriptoriumRun
Report: &seriatimReport,
},
Trim: &config.TrimConfig{
Enabled: true,
Enabled: &trimEnabled,
OutputPath: "transcripts/final.trimmed.json",
Bounds: config.TrimBoundsConfig{
PromptID: "dnd_session.bounds",