16 Commits

Author SHA1 Message Date
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
98649f4d81 Add a roadmap to implement the remaining items identfied by the code quality audit 2026-05-23 10:35:26 -05:00
8a559efd5b Audit code quality and deduplication opportunities 2026-05-23 10:10:21 -05:00
79 changed files with 3039 additions and 682 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. 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 ```bash
narratio run 2026-04-04 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: Top-level commands:
- `run <session_id>`: run full stage order. - `run <session_id>`: run full stage order.
- `resume <session_id>`: continue from first non-succeeded stage.
- `run-stage <stage> <session_id>`: run one stage. - `run-stage <stage> <session_id>`: run one stage.
- `analyze <session_id>`: force-run analyze. - `analyze <session_id>`: force-run analyze.
- `publish <session_id>`: force-run publish. - `publish <session_id>`: force-run publish.
@@ -77,19 +76,9 @@ Behavior:
- evaluates full stage order; - evaluates full stage order;
- skips already-succeeded stages unless `--force` is set; - skips already-succeeded stages unless `--force` is set;
- continues interrupted or partially completed sessions by running non-succeeded stages;
- writes session and run manifests. - 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` ### `run-stage`
```bash ```bash
@@ -104,6 +93,7 @@ Valid stage names:
- `polish` - `polish`
- `normalize` - `normalize`
- `trim` - `trim`
- `render`
- `analyze` - `analyze`
- `publish` - `publish`
- `notify` - `notify`
@@ -253,7 +243,7 @@ Behavior:
## `--artifacts` Selection Rules ## `--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`; - names must exist in `pipeline.scriptorium.artifacts`;
- empty entries are invalid; - empty entries are invalid;
- repeated names are deduplicated. - repeated names are deduplicated.

View File

@@ -98,6 +98,12 @@ publish:
- source: narratio.transcript.final_trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/final.trimmed.json dest: transcripts/final.trimmed.json
required: true 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 - source: narratio.artifact.session_recap
dest: artifacts/session_recap.md dest: artifacts/session_recap.md
required: true required: true
@@ -138,7 +144,7 @@ Rules:
| `pipeline.cache.s3_audio` | bool | No | `true` | | `pipeline.cache.s3_audio` | bool | No | `true` |
| `pipeline.publish.enabled` | bool | No | `true` | | `pipeline.publish.enabled` | bool | No | `true` |
| `pipeline.publish.upload_run` | 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[].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[].dest` | string | Conditional | derived if omitted and source supports derivation |
| `pipeline.publish.outputs[].required` | bool | No | `true` | | `pipeline.publish.outputs[].required` | bool | No | `true` |
@@ -188,6 +194,12 @@ Rules:
| `pipeline.trim.bounds.render_debug` | bool | No | `false` | | `pipeline.trim.bounds.render_debug` | bool | No | `false` |
| `pipeline.trim.bounds.render_output_path` | string | Conditional | required when `render_debug` is true | | `pipeline.trim.bounds.render_output_path` | string | Conditional | required when `render_debug` is true |
| `pipeline.trim.seriatim.report` | bool | No | `false` | | `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.binary` | string | No | `scriptorium` |
| `pipeline.scriptorium.config_path` | string | No | empty | | `pipeline.scriptorium.config_path` | string | No | empty |
| `pipeline.scriptorium.timeout` | duration | No | `10m` | | `pipeline.scriptorium.timeout` | duration | No | `10m` |

View File

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

View File

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

View File

@@ -20,9 +20,10 @@ Canonical stage order from `internal/stage.All()`:
4. `polish` 4. `polish`
5. `normalize` 5. `normalize`
6. `trim` 6. `trim`
7. `analyze` 7. `render`
8. `publish` 8. `analyze`
9. `notify` (placeholder) 9. `publish`
10. `notify` (placeholder)
`notify` is currently a placeholder stage with optional notifier call behavior; it has no persisted pipeline outputs. `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-polish.md`
- `stage-normalize.md` - `stage-normalize.md`
- `stage-trim.md` - `stage-trim.md`
- `stage-render.md`
- `stage-analyze.md` - `stage-analyze.md`
- `stage-publish.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.polished` -> `transcripts/polished.json` (`polish`)
- `narratio.transcript.final` -> `transcripts/final.json` (`normalize`) - `narratio.transcript.final` -> `transcripts/final.json` (`normalize`)
- `narratio.transcript.final_trimmed` -> `transcripts/final.trimmed.json` (`trim`) - `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`) - `narratio.bounds.session` -> `artifacts/session_bounds.json` (`trim`)
## Configured and Previous-Session Sources ## Configured and Previous-Session Sources
@@ -53,7 +55,8 @@ Previous-session sources (`narratio.previous_session.artifact.*`):
Validation by content type: 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; - bounds built-in: valid JSON;
- configured/previous-session artifact files: non-empty text file. - configured/previous-session artifact files: non-empty text file.

View File

@@ -30,6 +30,7 @@ Supported source families:
## Failure Semantics ## Failure Semantics
- required missing configured/previous-session inputs fail. - required missing configured/previous-session inputs fail.
- missing required previous-session 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. - dependency cycles or unavailable required dependencies fail.
- adapter validation failures fail stage. - adapter validation failures fail stage.

View File

@@ -4,7 +4,7 @@
Upload run/session outputs to object storage and atomically advance remote current state. Upload run/session outputs to object storage and atomically advance remote current state.
## Inputs ## 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}/**` - run root `runs/{run_id}/**`
- publish output rules (`pipeline.publish.outputs`) - publish output rules (`pipeline.publish.outputs`)
- effective publish locks (static + remote merged lock set) - 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

@@ -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). 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 and Session Selection
Campaign selection priority: Campaign selection priority:
@@ -63,7 +36,34 @@ 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. If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
## Stage Execution and Resume Behavior ## 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: Canonical stage order:
@@ -73,14 +73,15 @@ Canonical stage order:
4. `polish` 4. `polish`
5. `normalize` 5. `normalize`
6. `trim` 6. `trim`
7. `analyze` 7. `render`
8. `publish` 8. `analyze`
9. `notify` 9. `publish`
10. `notify`
Execution rules: Execution rules:
- succeeded stages are skipped unless `--force` is set; - 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`. - force rerunning a succeeded upstream stage marks succeeded downstream stages as `stale`.
Single-stage execution: Single-stage execution:
@@ -91,7 +92,7 @@ narratio run-stage normalize 2026-04-04 --force
## Artifact Selection ## 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: Selection behavior:
@@ -244,6 +245,7 @@ Rules:
## Operational Caveats ## Operational Caveats
- Local and S3 audio modes are mutually exclusive. - 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. - Restore requires configured object storage and committed remote current state.
- Storage-backed commands load filesystem secrets before object-store initialization. - 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 ## Repository layout
- `cmd/narratio/`: CLI entrypoint. - `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/config/`: strict YAML loading, defaults, and validation.
- `internal/stage/`: stage implementations and stage registry/order. - `internal/stage/`: stage implementations and stage registry/order.
- `internal/adapters/`: external boundary adapters (WhisperX, Seriatim, Audita, Scriptorium, storage, notify). - `internal/adapters/`: external boundary adapters (WhisperX, Seriatim, Audita, Scriptorium, storage, notify).

View File

@@ -217,6 +217,33 @@ Safe fix:
- correct publish source/destination rules; - correct publish source/destination rules;
- retry after storage failure is resolved. - 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 ## Secrets or storage credential failure
Symptom: Symptom:

View File

@@ -48,6 +48,12 @@ publish:
- source: narratio.transcript.final_trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/final.trimmed.json dest: transcripts/final.trimmed.json
required: true 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 - source: narratio.artifact.session_recap
dest: artifacts/session_recap.md dest: artifacts/session_recap.md
required: true required: true

View File

@@ -26,6 +26,12 @@ publish:
- source: narratio.transcript.final_trimmed - source: narratio.transcript.final_trimmed
dest: transcripts/final.trimmed.json dest: transcripts/final.trimmed.json
required: true 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 - source: narratio.artifact.session_recap
dest: artifacts/session_recap.md dest: artifacts/session_recap.md
required: true required: true

View File

@@ -68,6 +68,26 @@ func (n *NoopRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
}, nil }, 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. // FakeRunner captures merge requests and returns deterministic responses.
type FakeRunner struct { type FakeRunner struct {
Requests []MergeRequest Requests []MergeRequest
@@ -79,6 +99,9 @@ type FakeRunner struct {
TrimRequests []TrimRequest TrimRequests []TrimRequest
TrimErr error TrimErr error
TrimResult TrimResult TrimResult TrimResult
RenderRequests []RenderRequest
RenderErr error
RenderResult RenderResult
} }
// Run records request and returns configured response. // Run records request and returns configured response.
@@ -195,6 +218,46 @@ func (f *FakeRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
return res, nil 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 { func materializePlaceholders(req MergeRequest) error {
if req.OutputMergedTranscriptPath != "" { if req.OutputMergedTranscriptPath != "" {
if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil { 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 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") 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 package seriatim
import ( import (
@@ -6,11 +6,12 @@ import (
"time" "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 { type Runner interface {
Run(ctx context.Context, req MergeRequest) (MergeResult, error) Run(ctx context.Context, req MergeRequest) (MergeResult, error)
Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error) Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error)
Trim(ctx context.Context, req TrimRequest) (TrimResult, error) Trim(ctx context.Context, req TrimRequest) (TrimResult, error)
Render(ctx context.Context, req RenderRequest) (RenderResult, error)
} }
// MergeRequest describes a seriatim merge invocation. // MergeRequest describes a seriatim merge invocation.
@@ -90,3 +91,33 @@ type TrimResult struct {
KeepSelector string KeepSelector string
Metadata map[string]any 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" "strconv"
"strings" "strings"
"time" "time"
"unicode/utf8"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess" "gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
) )
@@ -384,6 +385,96 @@ func (r *SubprocessRunner) Normalize(ctx context.Context, req NormalizeRequest)
}, nil }, 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 { func (r *SubprocessRunner) buildMergeArgs(req MergeRequest) []string {
args := []string{"merge"} args := []string{"merge"}
@@ -480,6 +571,22 @@ func buildNormalizeArgs(req NormalizeRequest, outputSchema string) []string {
return args 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 { func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, timeout time.Duration) error {
payload := map[string]any{ payload := map[string]any{
"schema": "seriatim.generated.v1", "schema": "seriatim.generated.v1",
@@ -509,6 +616,24 @@ func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644) 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 { func validateJSONFile(path string) error {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -541,3 +666,20 @@ func validateJSONFileWithSegments(path string) error {
} }
return nil 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) { func TestSubprocessRunnerConstructorValidation(t *testing.T) {
_, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{}) _, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{})
if err == nil { if err == nil {
@@ -702,6 +852,14 @@ func TestSeriatimSubprocessHelper(t *testing.T) {
case "normalize_report_missing": case "normalize_report_missing":
writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`) writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
os.Exit(0) 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: default:
_, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode)) _, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode))
os.Exit(2) os.Exit(2)
@@ -777,6 +935,25 @@ func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest {
return req 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 { func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner {
t.Helper() t.Helper()
coalesce := 3.0 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() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "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) seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
} }
if err := store.Save(context.Background(), manifestPath, seed); err != nil { if err := store.Save(context.Background(), manifestPath, seed); err != nil {
@@ -135,16 +135,16 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
} }
var out bytes.Buffer var out bytes.Buffer
err := Resume( err := Run(
context.Background(), context.Background(),
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"}, []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
&out, &out,
) )
if err != nil { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
if !strings.Contains(out.String(), "has no remaining stages") { if !strings.Contains(out.String(), "executed=0 skipped=10") {
t.Fatalf("output = %q, want no remaining stages", out.String()) t.Fatalf("output = %q, want all stages skipped", out.String())
} }
} }

View File

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

View File

@@ -31,10 +31,9 @@ func TestExecuteValidCommands(t *testing.T) {
args []string args []string
wantOut 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: "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\nanalyze: skip\npublish: skip\nnotify: skip"}, {name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\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: "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="}, {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: "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: "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: "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 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-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:"}, {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:"},
@@ -332,7 +331,7 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
if code != 0 { if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String()) t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
} }
if !strings.Contains(stdout.String(), "narratio 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()) t.Fatalf("stdout = %q, want successful run output", stdout.String())
} }
} }

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

View File

@@ -10,7 +10,6 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
@@ -64,26 +63,18 @@ func sessionSourceSummary(cfg *config.Config) string {
} }
func validateStableInputFindings(cfg *config.Config) []finding { func validateStableInputFindings(cfg *config.Config) []finding {
items := []struct { checks := inspectStableInputs(cfg)
name string out := make([]finding, 0, len(checks))
in config.ResolvedInputFile for _, check := range checks {
}{ if check.Err != nil {
{"speakers", cfg.StableInputs.SpeakersFile}, msg := check.Name + ": " + check.Err.Error()
{"autocorrect", cfg.StableInputs.AutocorrectFile}, if strings.TrimSpace(check.Path) != "" {
{"glossary", cfg.StableInputs.GlossaryFile}, msg = fmt.Sprintf("%s missing: %v", check.Name, check.Err)
} }
out := make([]finding, 0, len(items)) out = append(out, errorFinding("inputs", msg))
for _, item := range items {
path, err := resolveHelperConfigRelativePath(item.in)
if err != nil {
out = append(out, errorFinding("inputs", item.name+": "+err.Error()))
continue continue
} }
if _, err := os.Stat(path); err != nil { out = append(out, okFinding("inputs", check.Name+": "+check.Path))
out = append(out, errorFinding("inputs", fmt.Sprintf("%s missing: %v", item.name, err)))
} else {
out = append(out, okFinding("inputs", item.name+": "+path))
}
} }
return out return out
} }
@@ -103,76 +94,22 @@ func resolveHelperConfigRelativePath(input config.ResolvedInputFile) (string, er
} }
func validateLocalAudioFindings(cfg *config.Config) []finding { func validateLocalAudioFindings(cfg *config.Config) []finding {
if cfg.Session.Inputs.AudioS3 != nil { check := inspectLocalAudioPresence(cfg)
if !check.Checked {
return nil return nil
} }
audioDir := strings.TrimSpace(cfg.Session.Inputs.AudioDir) if check.Err != nil {
if audioDir == "" && len(cfg.Session.Inputs.AudioFiles) == 0 { return []finding{errorFinding("audio", check.Err.Error())}
return []finding{errorFinding("audio", "audio_dir, audio_files, or audio_s3 is required")}
} }
base := filepath.Dir(cfg.SessionPath) return []finding{okFinding("audio", fmt.Sprintf("%d local audio file(s)", len(check.Paths)))}
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)))}
} }
func validateRemoteAudioFinding(ctx context.Context, cfg *config.Config, store storage.ObjectStore) finding { 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) check := inspectRemoteAudioPresence(ctx, cfg, store)
audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, cfg.Session.Inputs.AudioS3.Prefix) if check.Err != nil {
objects, err := store.List(ctx, audioPrefix) return errorFinding("audio", check.Err.Error())
if err != nil {
return errorFinding("audio", err.Error())
} }
count := 0 return okFinding("audio", fmt.Sprintf("%d remote .flac object(s)", len(check.Keys)))
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
} }
func loadLocalManifest(ctx context.Context, path string) (*manifest.Manifest, error) { func loadLocalManifest(ctx context.Context, path string) (*manifest.Manifest, error) {

View File

@@ -593,6 +593,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) { func TestExecuteLocksCannotModifyStaticLocks(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -884,6 +931,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) { func TestExecuteSessionValidateReportsPreviousStateFindingAndReturnsFindingError(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
@@ -925,11 +997,13 @@ func TestExecutePublishLoadsRemoteLocks(t *testing.T) {
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath}) restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03") 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. // The publish stage only checks the manifest statuses and source files.
_ = stageName _ = stageName
} }
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`) 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 stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -993,7 +1067,7 @@ func writeValidPublishRunConfigFiles(t *testing.T, workspaceRoot string) (string
m := manifest.New("2026-05-03", nowUTC()) m := manifest.New("2026-05-03", nowUTC())
m.Campaign = "sample-campaign" m.Campaign = "sample-campaign"
m.RunID = "20260521T160000Z-test" 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) m.MarkStageSucceeded(name, nowUTC(), nil)
} }
path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID) path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)

View File

@@ -0,0 +1,274 @@
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},
}
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. // LocksAdd adds or updates one remote lock.
func LocksAdd(ctx context.Context, args []string, out io.Writer) error { func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
var positionalSessionID string
var source string
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
positionalSessionID = strings.TrimSpace(args[0])
source = strings.TrimSpace(args[1])
args = append([]string(nil), args[2:]...)
}
fs := flag.NewFlagSet("locks add", flag.ContinueOnError) fs := flag.NewFlagSet("locks add", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
@@ -63,26 +56,8 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
addCommonConfigFlags(fs, &flags) addCommonConfigFlags(fs, &flags)
fs.StringVar(&reason, "reason", "", "lock reason") fs.StringVar(&reason, "reason", "", "lock reason")
fs.BoolVar(&force, "force", false, "update existing remote lock") fs.BoolVar(&force, "force", false, "update existing remote lock")
if err := fs.Parse(args); err != nil { source, err := parseSessionIDAndOnePositionalArg("locks add", "source id", fs, args, &flags.sessionID)
return fmt.Errorf("locks add: invalid flags: %w", err) if err != nil {
}
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 {
return err return err
} }
if strings.TrimSpace(flags.sessionID) == "" { 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. // LocksRemove removes one remote lock.
func LocksRemove(ctx context.Context, args []string, out io.Writer) error { func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
var positionalSessionID string
var source string
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
positionalSessionID = strings.TrimSpace(args[0])
source = strings.TrimSpace(args[1])
args = append([]string(nil), args[2:]...)
}
fs := flag.NewFlagSet("locks remove", flag.ContinueOnError) fs := flag.NewFlagSet("locks remove", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var flags commonConfigFlags var flags commonConfigFlags
addCommonConfigFlags(fs, &flags) addCommonConfigFlags(fs, &flags)
if err := fs.Parse(args); err != nil { source, err := parseSessionIDAndOnePositionalArg("locks remove", "source id", fs, args, &flags.sessionID)
return fmt.Errorf("locks remove: invalid flags: %w", err) if err != nil {
}
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 {
return err return err
} }
if strings.TrimSpace(flags.sessionID) == "" { 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)) 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")) 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")) findings = append(findings, errorFinding("previous", "previous_session_id is required by configured previous-session artifacts"))
} else if storeErr != nil { } else if previous.Err != nil {
findings = append(findings, errorFinding("previous", "previous-session artifacts cannot be checked because storage is unavailable")) findings = append(findings, errorFinding("previous", previous.Err.Error()))
} else { } 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) locks := inspectEffectiveLocks(ctx, cfg, store)
if lockErr != nil { if locks.Err != nil {
findings = append(findings, errorFinding("locks", lockErr.Error())) findings = append(findings, errorFinding("locks", locks.Err.Error()))
} else if len(locks.All) == 0 { } else if len(locks.Locks.All) == 0 {
findings = append(findings, okFinding("locks", "no effective publish locks")) findings = append(findings, okFinding("locks", "no effective publish locks"))
} else { } 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)))) findings = append(findings, warnFinding("locks", fmt.Sprintf("%s locked: %s", lock.Source, strings.TrimSpace(lock.Reason))))
} }
} }

View File

@@ -5,8 +5,10 @@ import (
"flag" "flag"
"fmt" "fmt"
"io" "io"
"sort"
"strings" "strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
) )
@@ -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, "Campaign: %s\n", cfg.Session.Campaign)
fmt.Fprintf(out, "Workspace: %s\n", paths.Root) fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg)) 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 { if m, err := loadLocalManifest(ctx, paths.ManifestPath); err != nil {
fmt.Fprintf(out, "Local manifest: error: %v\n", err) 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 { if storeErr != nil {
fmt.Fprintf(out, "Remote publish: unavailable: %v\n", storeErr) fmt.Fprintf(out, "Remote publish: unavailable: %v\n", storeErr)
} else if store != nil { } else if store != nil {
current, err := discoverRemoteCurrentStateFn(ctx, cfg, store) current := inspectRemoteCurrentState(ctx, cfg, store)
if err != nil { if current.Err != nil {
fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", err) fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", current.Err)
} else { } else {
fmt.Fprintf(out, "Remote publish: current run %s\n", current.RunID) fmt.Fprintf(out, "Remote publish: current run %s\n", current.State.RunID)
fmt.Fprintf(out, "Remote manifest: %s\n", current.CurrentManifestKey) 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 { if catalog, catalogErr := buildHelperArtifactCatalog(cfg); catalogErr != nil {
fmt.Fprintf(out, "Remote outputs: error: %v\n", catalogErr) fmt.Fprintf(out, "Remote outputs: error: %v\n", catalogErr)
} else if storeErr == nil { } else if storeErr == nil {
catalogLocks := locks catalogLocks := locks
if err != nil { if lockErr != nil {
catalogLocks = &effectiveLocks{ catalogLocks = &effectiveLocks{
Static: staticPublishLocks(cfg), Static: staticPublishLocks(cfg),
All: 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:") fmt.Fprintln(out, "Remote outputs:")
writeArtifactList(out, cfg, catalog, catalogLocks, publishedRemoteState) writeArtifactList(out, cfg, catalog, catalogLocks, publishedRemoteState)
} }
if err != nil { if lockErr != nil {
fmt.Fprintf(out, "Publish locks: error: %v\n", err) fmt.Fprintf(out, "Publish locks: error: %v\n", lockErr)
} else { } else {
writeLocks(out, cfg, locks) 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) fmt.Fprintf(out, "- narratio session restore %s --dry-run\n", cfg.Session.SessionID)
return nil 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") { if !strings.Contains(got, "narratio session plan: workdir prepared at") {
t.Fatalf("first output = %q, want workdir prepared", got) t.Fatalf("first output = %q, want workdir prepared", got)
} }
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} { for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
if !strings.Contains(got, name+": run") { if !strings.Contains(got, name+": run") {
t.Fatalf("first output = %q, missing stage %q", got, name) t.Fatalf("first output = %q, missing stage %q", got, name)
} }
} }
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) t.Fatalf("first output = %q, want totals", got)
} }
@@ -84,8 +84,8 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
if !strings.Contains(got, "trim: run") { if !strings.Contains(got, "trim: run") {
t.Fatalf("output = %q, want trim run", got) t.Fatalf("output = %q, want trim run", got)
} }
if !strings.Contains(got, "totals: run=7 skip=2") { if !strings.Contains(got, "totals: run=8 skip=2") {
t.Fatalf("output = %q, want totals run=7 skip=2", got) t.Fatalf("output = %q, want totals run=8 skip=2", got)
} }
} }

View File

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

View File

@@ -355,7 +355,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
if err != nil { if err != nil {
t.Fatalf("Load() error = %v", err) 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.MarkStageSucceeded(name, time.Now().UTC(), nil)
} }
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID) 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/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/audio" "gitea.maximumdirect.net/eric/narratio/internal/audio"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
@@ -114,10 +115,7 @@ func executeRestoreDownloadAction(
} }
} }
if err := os.Chmod(tmpPath, 0o644); err != nil { if err := fileops.InstallDownloadedTempFile(tmpPath, safeLocalPath, 0o644); err != nil {
return fmt.Errorf("set file permissions: %w", err)
}
if err := os.Rename(tmpPath, safeLocalPath); err != nil {
return fmt.Errorf("install file atomically: %w", err) return fmt.Errorf("install file atomically: %w", err)
} }
removeTmp = false removeTmp = false

View File

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

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 package app
import ( import (
"context"
"fmt"
"time" "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/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage" "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 return sr != nil && sr.Status == manifest.StatusSucceeded
} }
func firstNonSucceededIndex(stages []stage.Stage, m *manifest.Manifest) int { func loadManifestIfPresent(ctx context.Context, cfg *config.Config) (*manifest.Manifest, error) {
for i, s := range stages { path := artifacts.SessionManifestPathForCampaign(
if !stageSucceeded(m, s.Name()) { cfg.Pipeline.Workspace.Root,
return i 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 { func canonicalStageNames() []string {

View File

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

View File

@@ -13,7 +13,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
func TestResumeStartsAfterCompletedStages(t *testing.T) { func TestRunContinuesAfterCompletedStages(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
@@ -32,12 +32,12 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n") mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
var out bytes.Buffer var out bytes.Buffer
err := Resume(context.Background(), []string{"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 { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
if !strings.Contains(out.String(), "executed=7 skipped=0") { if !strings.Contains(out.String(), "executed=8 skipped=2") {
t.Fatalf("output = %q, want executed=7 skipped=0", out.String()) t.Fatalf("output = %q, want executed=8 skipped=2", out.String())
} }
loaded, err := store.Load(context.Background(), manifestPath) 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() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "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) m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
} }
if err := store.Save(context.Background(), manifestPath, m); err != nil { if err := store.Save(context.Background(), manifestPath, m); err != nil {
@@ -64,20 +64,20 @@ func TestResumeNoRemainingStages(t *testing.T) {
} }
var out bytes.Buffer 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 { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
if !strings.Contains(out.String(), "has no remaining stages") { if !strings.Contains(out.String(), "executed=0 skipped=10") {
t.Fatalf("output = %q, want no remaining stages", out.String()) t.Fatalf("output = %q, want executed=0 skipped=10", out.String())
} }
} }
func TestResumeForceRerunsSucceeded(t *testing.T) { func TestRunForceRerunsSucceeded(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") 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() defer srv.Close()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
@@ -85,7 +85,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "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) m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
} }
if err := store.Save(context.Background(), manifestPath, m); err != nil { if err := store.Save(context.Background(), manifestPath, m); err != nil {
@@ -93,11 +93,11 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
} }
var out bytes.Buffer 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 { 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()) 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() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json") manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
@@ -176,7 +176,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "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) seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
} }
if err := store.Save(context.Background(), manifestPath, seed); err != nil { if err := store.Save(context.Background(), manifestPath, seed); err != nil {
@@ -196,19 +196,19 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
if err != nil { if err != nil {
t.Fatalf("load manifest after force: %v", err) t.Fatalf("load manifest after force: %v", err)
} }
for _, name := range []string{"normalize", "trim", "analyze", "publish", "notify"} { for _, name := range []string{"normalize", "trim", "render", "analyze", "publish", "notify"} {
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale { if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name]) t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
} }
} }
out.Reset() out.Reset()
err = Resume(context.Background(), []string{"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 { if err != nil {
t.Fatalf("Resume() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
if !strings.Contains(out.String(), "executed=5 skipped=0") { if !strings.Contains(out.String(), "executed=6 skipped=4") {
t.Fatalf("output = %q, want resume to execute normalize..notify", out.String()) t.Fatalf("output = %q, want run to execute stale downstream stages", out.String())
} }
} }
@@ -266,3 +266,30 @@ func TestRunStageNormalizeExecutes(t *testing.T) {
t.Fatalf("normalize stage = %#v, want succeeded", m.Stages["normalize"]) 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/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim" "gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx" "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/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/logging" "gitea.maximumdirect.net/eric/narratio/internal/logging"
@@ -410,6 +411,8 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
if stageName == "analyze" { if stageName == "analyze" {
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind) sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
kind = "scriptorium_artifact" kind = "scriptorium_artifact"
} else {
sourceID = sourceIDForOutputKind(kind)
} }
out = append(out, manifest.ArtifactRecord{ out = append(out, manifest.ArtifactRecord{
Kind: kind, Kind: kind,
@@ -424,6 +427,22 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
return out 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) { func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *stage.StageResult) {
if m == nil || result == nil { if m == nil || result == nil {
return return

View File

@@ -273,7 +273,7 @@ func TestExecuteStagesPublishSkipsRequiredUnselectedConfiguredOutput(t *testing.
manifestPath := manifestPathFor(cfg) manifestPath := manifestPathFor(cfg)
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC()) seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
seed.Campaign = cfg.Session.Campaign 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) seed.MarkStageSucceeded(stageName, time.Now().UTC(), nil)
} }
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
@@ -332,8 +332,8 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("executeStages() error = %v", err) t.Fatalf("executeStages() error = %v", err)
} }
if len(summary.StageNames) != 9 || len(summary.Executed) != 9 || len(summary.Skipped) != 0 { if len(summary.StageNames) != 10 || len(summary.Executed) != 10 || len(summary.Skipped) != 0 {
t.Fatalf("summary = %#v, want all 9 executed", summary) t.Fatalf("summary = %#v, want all 10 executed", summary)
} }
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
@@ -342,7 +342,7 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
t.Fatalf("Load manifest error = %v", err) t.Fatalf("Load manifest error = %v", err)
} }
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} { for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
sr := m.Stages[name] sr := m.Stages[name]
if sr == nil { if sr == nil {
t.Fatalf("missing stage record %q", name) t.Fatalf("missing stage record %q", name)
@@ -425,6 +425,15 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
} }
continue 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 name == "publish" {
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" { if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata) t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata)
@@ -522,7 +531,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
store := &manifest.LocalStore{} store := &manifest.LocalStore{}
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC)) existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "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.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
} }
existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure") existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure")
@@ -553,7 +562,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded { if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded {
t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"]) t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"])
} }
for _, stageName := range []string{"normalize", "trim", "publish", "notify"} { for _, stageName := range []string{"normalize", "trim", "render", "publish", "notify"} {
if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale { if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale {
t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName]) t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName])
} }
@@ -977,7 +986,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
seed.S3Bucket = "my-dnd-archive" seed.S3Bucket = "my-dnd-archive"
seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/" seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/"
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/" 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) seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
} }
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil { if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {

View File

@@ -59,3 +59,37 @@ func parseSessionAwareFlags(command string, fs *flag.FlagSet, args []string, ses
} }
return resolveParsedSessionID(command, positionalSessionID, fs, sessionID) 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

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

View File

@@ -7,6 +7,8 @@ const (
SourceTranscriptPolished = "narratio.transcript.polished" SourceTranscriptPolished = "narratio.transcript.polished"
SourceTranscriptFinal = "narratio.transcript.final" SourceTranscriptFinal = "narratio.transcript.final"
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed" SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
SourceTranscriptFinalMarkdown = "narratio.transcript.final_markdown"
SourceTranscriptFinalTrimmedMarkdown = "narratio.transcript.final_trimmed_markdown"
) )
const ( const (
@@ -14,6 +16,8 @@ const (
TranscriptPathPolished = "transcripts/polished.json" TranscriptPathPolished = "transcripts/polished.json"
TranscriptPathFinal = "transcripts/final.json" TranscriptPathFinal = "transcripts/final.json"
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json" TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
TranscriptPathFinalMarkdown = "transcripts/final.md"
TranscriptPathFinalTrimmedMarkdown = "transcripts/final.trimmed.md"
) )
const ( const (
@@ -21,6 +25,8 @@ const (
TranscriptOutputKindPolished = "transcript_polished" TranscriptOutputKindPolished = "transcript_polished"
TranscriptOutputKindFinal = "transcript_final" TranscriptOutputKindFinal = "transcript_final"
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed" TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
TranscriptOutputKindFinalMarkdown = "transcript_final_markdown"
TranscriptOutputKindFinalTrimmedMarkdown = "transcript_final_trimmed_markdown"
) )
// TranscriptArtifactSpec describes one built-in transcript artifact mapping. // TranscriptArtifactSpec describes one built-in transcript artifact mapping.
@@ -56,6 +62,18 @@ var runtimeTranscriptArtifacts = []TranscriptArtifactSpec{
ProducerStage: "trim", ProducerStage: "trim",
OutputKind: TranscriptOutputKindFinalTrimmed, 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. // 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 package artifactpolicy
import ( import (
"errors"
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
@@ -19,6 +20,11 @@ const (
var configuredSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`) 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 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 type SourceKind string
const ( const (
@@ -34,6 +40,28 @@ type Source struct {
ConfiguredKey string 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. // ConfiguredSourceID converts a configured artifact key into source id form.
func ConfiguredSourceID(key string) string { func ConfiguredSourceID(key string) string {
return configuredSourcePrefix + strings.TrimSpace(key) return configuredSourcePrefix + strings.TrimSpace(key)
@@ -83,6 +111,70 @@ func ClassifySource(source string) (Source, error) {
return Source{}, fmt.Errorf("unsupported artifact source %q", source) 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 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
}
// 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. // ValidatePublishSource validates that a source is publish-compatible and references a known configured artifact.
func ValidatePublishSource(source string, configured map[string]string) (Source, error) { func ValidatePublishSource(source string, configured map[string]string) (Source, error) {
classified, err := ClassifySource(source) classified, err := ClassifySource(source)

View File

@@ -1,6 +1,7 @@
package artifactpolicy package artifactpolicy
import ( import (
"errors"
"strings" "strings"
"testing" "testing"
) )
@@ -66,6 +67,14 @@ func TestResolvePublishedDestination(t *testing.T) {
t.Fatalf("built-in destination = %q, want transcripts/final.trimmed.json", got) 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) got, err = ResolvePublishedDestination("narratio.artifact.session_recap", "", configured)
if err != nil { if err != nil {
t.Fatalf("ResolvePublishedDestination(configured) error = %v", err) t.Fatalf("ResolvePublishedDestination(configured) error = %v", err)
@@ -90,3 +99,102 @@ func TestResolvePublishedDestinationRejectsTraversal(t *testing.T) {
t.Fatal("ResolvePublishedDestination() error = nil, want traversal rejection") 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: "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 ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
ArtifactTranscriptFinalMarkdown = artifactmodel.SourceTranscriptFinalMarkdown
ArtifactTranscriptFinalTrimmedMarkdown = artifactmodel.SourceTranscriptFinalTrimmedMarkdown
ArtifactBoundsSession = "narratio.bounds.session" ArtifactBoundsSession = "narratio.bounds.session"
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache" ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
@@ -29,6 +31,8 @@ const (
TranscriptPathPolished = artifactmodel.TranscriptPathPolished TranscriptPathPolished = artifactmodel.TranscriptPathPolished
TranscriptPathFinal = artifactmodel.TranscriptPathFinal TranscriptPathFinal = artifactmodel.TranscriptPathFinal
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
TranscriptPathFinalMarkdown = artifactmodel.TranscriptPathFinalMarkdown
TranscriptPathFinalTrimmedMarkdown = artifactmodel.TranscriptPathFinalTrimmedMarkdown
) )
const ( const (
@@ -36,6 +40,8 @@ const (
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
TranscriptOutputKindFinalMarkdown = artifactmodel.TranscriptOutputKindFinalMarkdown
TranscriptOutputKindFinalTrimmedMarkdown = artifactmodel.TranscriptOutputKindFinalTrimmedMarkdown
) )
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID. // ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
@@ -67,7 +73,7 @@ func buildArtifactRegistry() map[string]artifactSpec {
CanonicalRelPath: transcript.CanonicalRelPath, CanonicalRelPath: transcript.CanonicalRelPath,
ProducerStage: transcript.ProducerStage, ProducerStage: transcript.ProducerStage,
OutputKind: transcript.OutputKind, OutputKind: transcript.OutputKind,
ContentKind: contentTranscriptJSON, ContentKind: transcriptContentKind(transcript),
} }
} }
registry[ArtifactBoundsSession] = artifactSpec{ registry[ArtifactBoundsSession] = artifactSpec{
@@ -80,6 +86,15 @@ func buildArtifactRegistry() map[string]artifactSpec {
return registry 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. // ResolvedSessionArtifact describes one session-level artifact lookup result.
type ResolvedSessionArtifact struct { type ResolvedSessionArtifact struct {
ID string 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) { func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") 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) { func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) {
workspace := t.TempDir() workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session") paths := buildSessionPaths(workspace, "campaign", "session")

View File

@@ -216,11 +216,10 @@ func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
} }
func runtimeBuiltInArtifactIDs() []string { func runtimeBuiltInArtifactIDs() []string {
return []string{ ids := make([]string, 0, len(RuntimeTranscriptArtifacts())+1)
ArtifactTranscriptBase, for _, transcript := range RuntimeTranscriptArtifacts() {
ArtifactTranscriptPolished, ids = append(ids, transcript.SourceID)
ArtifactTranscriptFinal,
ArtifactTranscriptFinalTrimmed,
ArtifactBoundsSession,
} }
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) { func TestArtifactCatalogRegisterConfiguredArtifactsDefaultsToEnabled(t *testing.T) {
catalog := NewArtifactCatalog() catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{ if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{

View File

@@ -9,6 +9,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
) )
// ErrLockConflict is returned when a session lock already exists. // 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) 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) 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) == "" { if strings.TrimSpace(path) == "" {
return fmt.Errorf("write file atomic: path is required") return fmt.Errorf("write file atomic: path is required")
} }
if err := fileops.WriteFileAtomic(path, data, perm); err != nil {
dir := filepath.Dir(path) return fmt.Errorf("write file atomic: %w", err)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("write file atomic: create parent dir %q: %w", dir, 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 return nil
} }
@@ -256,62 +223,21 @@ func (s *LocalStore) ReleaseSessionLock(lock *LockHandle) error {
} }
func resolveInRoot(root, relative string) (string, error) { func resolveInRoot(root, relative string) (string, error) {
rel := filepath.Clean(relative) joined, err := pathsafe.JoinSlashRelativeUnderRoot(root, filepath.ToSlash(relative))
if rel == "." || rel == "" { 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") return "", fmt.Errorf("relative destination path is required")
} }
if filepath.IsAbs(rel) { return joined, nil
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
} }

View File

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

View File

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

View File

@@ -2,16 +2,14 @@ package audio
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
) )
// S3MaterializeRequest describes one S3-backed audio materialization. // 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 { if ok, err := validCachedAudio(cachePath, req.Object.Size); err != nil {
return S3MaterializeResult{}, err return S3MaterializeResult{}, err
} else if ok { } else if ok {
checksum, err := copyFileAtomicWithChecksum(cachePath, req.DestPath, 0o644) checksum, err := fileops.CopyFileAtomicWithChecksum(cachePath, req.DestPath, 0o644)
if err != nil { if err != nil {
return S3MaterializeResult{}, fmt.Errorf("materialize cached audio %q: %w", cachePath, err) 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) 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 { if err != nil {
return S3MaterializeResult{}, fmt.Errorf("materialize downloaded audio %q: %w", filepath.Base(req.DestPath), err) 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 result.Downloaded = true
if result.CachePath != "" { 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) 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 { if err := store.Download(ctx, key, tmpPath); err != nil {
return err return err
} }
if err := os.Chmod(tmpPath, 0o644); err != nil { if err := fileops.InstallDownloadedTempFile(tmpPath, destPath, 0o644); err != nil {
return fmt.Errorf("set temp file permissions: %w", err) return err
}
if err := os.Rename(tmpPath, destPath); err != nil {
return fmt.Errorf("install downloaded file: %w", err)
} }
removeTmp = false removeTmp = false
return nil 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

@@ -28,6 +28,7 @@ type PipelineConfig struct {
Audita AuditaConfig `yaml:"audita"` Audita AuditaConfig `yaml:"audita"`
Normalize *NormalizeConfig `yaml:"normalize"` Normalize *NormalizeConfig `yaml:"normalize"`
Trim *TrimConfig `yaml:"trim"` Trim *TrimConfig `yaml:"trim"`
Render *RenderConfig `yaml:"render"`
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"` Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
Notification NotificationConfig `yaml:"notification"` Notification NotificationConfig `yaml:"notification"`
} }
@@ -209,6 +210,16 @@ type TrimSeriatimConfig struct {
Report *bool `yaml:"report"` 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. // ScriptoriumConfig configures Scriptorium-backed artifact generation.
type ScriptoriumConfig struct { type ScriptoriumConfig struct {
Binary string `yaml:"binary"` Binary string `yaml:"binary"`

View File

@@ -40,6 +40,12 @@ const (
DefaultTrimBoundsTimeout = "10m" DefaultTrimBoundsTimeout = "10m"
DefaultTrimSeriatimReport = false DefaultTrimSeriatimReport = false
DefaultRenderEnabled = true
DefaultRenderFormat = "markdown"
DefaultRenderTitle = ""
DefaultRenderTimestamps = true
DefaultRenderSegmentIDs = true
DefaultRenderMetadata = false
DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
DefaultNormalizeOutputSchema = "seriatim-intermediate" DefaultNormalizeOutputSchema = "seriatim-intermediate"
@@ -80,6 +86,8 @@ const (
// Callers should copy this slice before mutating. // Callers should copy this slice before mutating.
var DefaultPublishOutputs = []PublishOutputRule{ var DefaultPublishOutputs = []PublishOutputRule{
{Source: artifactmodel.SourceTranscriptFinalTrimmed, Dest: PathTranscriptFinalTrimmed}, {Source: artifactmodel.SourceTranscriptFinalTrimmed, Dest: PathTranscriptFinalTrimmed},
{Source: artifactmodel.SourceTranscriptFinalMarkdown, Dest: artifactmodel.TranscriptPathFinalMarkdown},
{Source: artifactmodel.SourceTranscriptFinalTrimmedMarkdown, Dest: artifactmodel.TranscriptPathFinalTrimmedMarkdown},
} }
// DefaultPipelineConfigSearchPaths defines the default search order for // DefaultPipelineConfigSearchPaths defines the default search order for

View File

@@ -337,6 +337,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
} }
applyNormalizeDefaults(cfg.Normalize) applyNormalizeDefaults(cfg.Normalize)
applyTrimDefaults(cfg.Trim) applyTrimDefaults(cfg.Trim)
applyRenderDefaults(&cfg.Render)
applyScriptoriumDefaults(cfg.Scriptorium) applyScriptoriumDefaults(cfg.Scriptorium)
} }
@@ -507,6 +508,30 @@ func applyTrimDefaults(cfg *TrimConfig) {
} }
} }
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) { func applyNormalizeDefaults(cfg *NormalizeConfig) {
if cfg == nil { if cfg == nil {
return return

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,21 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
transcript: transcript:
source: narratio.transcript.final_trimmed source: narratio.transcript.final_trimmed
required: true 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
`, `,
}, },
{ {

View File

@@ -179,18 +179,29 @@ func TestSpoolAndPublishDefaults(t *testing.T) {
if cfg.Pipeline.Publish.UploadRun == nil || !*cfg.Pipeline.Publish.UploadRun { if cfg.Pipeline.Publish.UploadRun == nil || !*cfg.Pipeline.Publish.UploadRun {
t.Fatalf("publish.upload_run = %#v, want true", cfg.Pipeline.Publish.UploadRun) t.Fatalf("publish.upload_run = %#v, want true", cfg.Pipeline.Publish.UploadRun)
} }
if len(cfg.Pipeline.Publish.Outputs) != 1 { if len(cfg.Pipeline.Publish.Outputs) != 3 {
t.Fatalf("publish.outputs len = %d, want 1 default", len(cfg.Pipeline.Publish.Outputs)) 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 { 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" { wantDest, ok := wantBySource[item.Source]
t.Fatalf("publish.outputs[0].source = %q, want narratio.transcript.final_trimmed", 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" { if item.Dest != wantDest {
t.Fatalf("publish.outputs[0].dest = %q, want transcripts/final.trimmed.json", item.Dest) 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)
} }
} }
@@ -337,6 +348,15 @@ publish:
`, `,
wantDest: "artifacts/session_recap.md", 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 { for _, tt := range tests {

View File

@@ -9,7 +9,6 @@ import (
"strings" "strings"
"time" "time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
) )
@@ -89,6 +88,9 @@ func validatePipeline(cfg *PipelineConfig) error {
if err := validateTrim(cfg.Trim); err != nil { if err := validateTrim(cfg.Trim); err != nil {
return err return err
} }
if err := validateRender(cfg.Render); err != nil {
return err
}
if err := validateScriptorium(cfg.Scriptorium); err != nil { if err := validateScriptorium(cfg.Scriptorium); err != nil {
return err return err
} }
@@ -300,6 +302,29 @@ func validateTrim(cfg *TrimConfig) error {
return nil 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 { func validateWhisperX(cfg WhisperXConfig) error {
if strings.TrimSpace(cfg.TranscribeURL) == "" { if strings.TrimSpace(cfg.TranscribeURL) == "" {
return fmt.Errorf("pipeline.whisperx.transcribe_url is required") return fmt.Errorf("pipeline.whisperx.transcribe_url is required")
@@ -639,13 +664,9 @@ var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) { func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) {
trimmedSource := strings.TrimSpace(source) trimmedSource := strings.TrimSpace(source)
if isStaticSupportedScriptoriumInputSource(trimmedSource) { descriptor, err := artifactpolicy.DescribeScriptoriumInputSource(trimmedSource)
return "", nil if err != nil {
} if errors.Is(err, artifactpolicy.ErrInvalidPreviousSessionSource) {
if strings.HasPrefix(trimmedSource, "narratio.previous_session.artifact") {
referenced, ok := artifactpolicy.ParsePreviousSessionSource(trimmedSource)
if !ok {
return "", fmt.Errorf( return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$", "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$",
artifactName, artifactName,
@@ -653,20 +674,6 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
source, 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( return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported", "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
artifactName, artifactName,
@@ -674,28 +681,28 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
source, 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( return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q", "pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
artifactName, artifactName,
inputName, inputName,
source, source,
referenced, unknownConfigured.ConfiguredKey,
) )
} }
return referenced, nil return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
artifactName,
inputName,
source,
)
} }
if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact {
func isStaticSupportedScriptoriumInputSource(source string) bool { return descriptor.Source.ConfiguredKey, nil
if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(source); ok {
return true
}
switch source {
case "narratio.bounds.session":
return true
default:
return false
} }
return "", nil
} }
func validateEnvVarNameField(fieldName, value string) error { 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 ( import (
"errors" "errors"
"path/filepath"
"strings"
"testing" "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) candidates = append(candidates, normalized)
} }
sourceDescriptor, err := artifactpolicy.PreviousSessionSourceDescriptorForConfiguredKey(artifactName)
sourceID := artifactpolicy.ConfiguredSourceID(artifactName) sourceID := artifactpolicy.ConfiguredSourceID(artifactName)
if err == nil {
sourceID = sourceDescriptor.ConfiguredSourceID
}
if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok { if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok {
appendCandidate(rel) appendCandidate(rel)
base := path.Base(rel) base := path.Base(rel)
@@ -309,11 +313,7 @@ func deriveManifestRelativePath(previousManifest *manifest.Manifest, localPath s
if !ok { if !ok {
return "", false return "", false
} }
rel, err := filepath.Rel(sessionRoot, trimmed) normalized, err := pathsafe.SlashRelativeFromRoot(sessionRoot, trimmed)
if err != nil {
return "", false
}
normalized, err := pathsafe.NormalizeRelativeDestination(filepath.ToSlash(rel))
if err != nil { if err != nil {
return "", false return "", false
} }
@@ -375,11 +375,7 @@ func relativeToSession(paths artifacts.SessionPaths, localPath string) (string,
if strings.TrimSpace(root) == "" { if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("session root is required") return "", fmt.Errorf("session root is required")
} }
rel, err := filepath.Rel(root, filepath.Clean(localPath)) normalized, err := pathsafe.SlashRelativeFromRoot(root, localPath)
if err != nil {
return "", fmt.Errorf("resolve previous-cache relative path: %w", err)
}
normalized, err := pathsafe.NormalizeRelativeDestination(filepath.ToSlash(rel))
if err != nil { if err != nil {
return "", fmt.Errorf("resolve previous-cache relative path: %w", err) return "", fmt.Errorf("resolve previous-cache relative path: %w", err)
} }

View File

@@ -628,11 +628,11 @@ func resolveScriptoriumInput(
runtimeCatalog *artifacts.ArtifactCatalog, runtimeCatalog *artifacts.ArtifactCatalog,
) (string, bool, *artifacts.ResolvedSessionArtifact, error) { ) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
source := strings.TrimSpace(inputCfg.Source) source := strings.TrimSpace(inputCfg.Source)
classified, classifyErr := artifactpolicy.ClassifySource(source) descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source)
if classifyErr != nil { if describeErr != nil {
return "", false, nil, classifyErr return "", false, nil, describeErr
} }
if classified.Kind == artifactpolicy.SourceKindPreviousArtifact { if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog) resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
if err == nil { if err == nil {
copy := resolved copy := resolved
@@ -657,19 +657,25 @@ func resolveScriptoriumInput(
return resolved.Path, true, &copy, nil return resolved.Path, true, &copy, nil
} }
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) { if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
if classified.Kind == artifactpolicy.SourceKindConfiguredArtifact { if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact {
if inputCfg.Required { if inputCfg.Required {
return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source) return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source)
} }
return "", false, nil, nil return "", false, nil, nil
} }
switch classified.ID { switch descriptor.Source.ID {
case artifacts.ArtifactTranscriptPolished: case artifacts.ArtifactTranscriptPolished:
return "", false, nil, nil return "", false, nil, nil
case artifacts.ArtifactTranscriptFinal: case artifacts.ArtifactTranscriptFinal:
return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first") return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
case artifacts.ArtifactTranscriptFinalTrimmed: case artifacts.ArtifactTranscriptFinalTrimmed:
return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first") 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: default:
return "", false, nil, nil return "", false, nil, nil
} }

View File

@@ -961,6 +961,31 @@ 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 TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) { func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t) env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) paths := sessionPathsForEnv(env, m.SessionID)
@@ -1045,6 +1070,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) { func TestAnalyzeFailsWhenProcessedTranscriptInvalidJSON(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t) env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID) 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") stderrPath = filepath.Join(runLayout.LogsDir, "seriatim.normalize.stderr.log")
generatedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.normalize.generated.yml") 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 { if err != nil {
return nil, fmt.Errorf("normalize: resolve seriatim timeout: %w", err) return nil, fmt.Errorf("normalize: resolve seriatim timeout: %w", err)
} }

View File

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

View File

@@ -101,7 +101,7 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
} }
m := manifest.New("2026-05-03", time.Now().UTC()) 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.MarkStageSucceeded(name, time.Now().UTC(), nil)
} }
m.RunID = "20260516T000000Z-abcdef12" m.RunID = "20260516T000000Z-abcdef12"
@@ -194,6 +194,15 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
} }
continue 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 s.Name() == "publish" {
if result.Metadata["stage"] != "publish" { if result.Metadata["stage"] != "publish" {
t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata) t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata)

View File

@@ -8,6 +8,7 @@ import (
"sort" "sort"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "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/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/previouscache" "gitea.maximumdirect.net/eric/narratio/internal/previouscache"
) )
@@ -54,8 +55,35 @@ func hydratePreviousSessionArtifacts(
if err := os.MkdirAll(filepath.Dir(record.LocalPath), 0o755); err != nil { 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) 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 record.Kind == preparePreviousInputKindArtifact {
if err := requireNonEmptyFile(record.LocalPath, "previous-session artifact "+record.RequirementName); err != nil { if err := requireNonEmptyFile(record.LocalPath, "previous-session artifact "+record.RequirementName); err != nil {

View File

@@ -32,6 +32,7 @@ var publishPrerequisiteStages = []string{
"polish", "polish",
"normalize", "normalize",
"trim", "trim",
"render",
"analyze", "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/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
) )
type runStageLayout struct { type runStageLayout struct {
@@ -92,18 +93,17 @@ func runLocalPathForCanonical(layout runStageLayout, sessionPaths artifacts.Sess
if cleanCanonical == "" { if cleanCanonical == "" {
return "", fmt.Errorf("canonical path is required") 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 { if err != nil {
return "", fmt.Errorf("derive session-relative path for %q: %w", cleanCanonical, err) return "", fmt.Errorf("derive session-relative path for %q: %w", cleanCanonical, err)
} }
rel = filepath.Clean(rel) if rel == config.PathPreviousDirSegment || strings.HasPrefix(rel, config.PathPreviousDirSegment+"/") {
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)) {
return cleanCanonical, nil 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 { if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
return "", fmt.Errorf("create run-local output parent for %q: %w", localPath, err) return "", fmt.Errorf("create run-local output parent for %q: %w", localPath, err)
} }

View File

@@ -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") trimStderrLogPath = filepath.Join(runLayout.LogsDir, "seriatim.trim.stderr.log")
trimGeneratedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.trim.generated.yml") 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 { if err != nil {
return nil, fmt.Errorf("trim: resolve seriatim timeout: %w", err) 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 return nil
} }
func resolveTrimSeriatimTimeout(raw string) (time.Duration, error) { func resolveSeriatimStageTimeout(raw string) (time.Duration, error) {
trimmed := strings.TrimSpace(raw) trimmed := strings.TrimSpace(raw)
if trimmed == "" { if trimmed == "" {
return 0, nil return 0, nil