Compare commits
16 Commits
5620fc5bcf
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| ffc07922c7 | |||
| f3310d4d16 | |||
| 88cee96d8d | |||
| 2fece10215 | |||
| 0658f2f642 | |||
| a51228c803 | |||
| 4491fb5ccd | |||
| 30b905765c | |||
| 03eac70881 | |||
| 0f7e6b979f | |||
| c366912586 | |||
| 9fe44cd00d | |||
| 094b0d2532 | |||
| 98649f4d81 | |||
| 8a559efd5b | |||
| 72deccb4e2 |
@@ -2,7 +2,7 @@
|
||||
|
||||
Narratio is a stage-driven Go orchestrator for turning D&D session audio into polished transcripts and generated artifacts.
|
||||
|
||||
It runs a deterministic workflow across `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `analyze`, and `publish`, with manifest-driven resume and restore support.
|
||||
It runs a deterministic workflow across `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `render`, `analyze`, and `publish`, with manifest-driven continuation and restore support.
|
||||
|
||||
```bash
|
||||
narratio run 2026-04-04
|
||||
|
||||
16
docs/cli.md
16
docs/cli.md
@@ -13,7 +13,6 @@ This runs the canonical full pipeline for session `2026-04-04`.
|
||||
Top-level commands:
|
||||
|
||||
- `run <session_id>`: run full stage order.
|
||||
- `resume <session_id>`: continue from first non-succeeded stage.
|
||||
- `run-stage <stage> <session_id>`: run one stage.
|
||||
- `analyze <session_id>`: force-run analyze.
|
||||
- `publish <session_id>`: force-run publish.
|
||||
@@ -77,19 +76,9 @@ Behavior:
|
||||
|
||||
- evaluates full stage order;
|
||||
- skips already-succeeded stages unless `--force` is set;
|
||||
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||
- writes session and run manifests.
|
||||
|
||||
### `resume`
|
||||
|
||||
```bash
|
||||
narratio resume <session_id> [--force] [--artifacts <name[,name...]>] [...common config flags]
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- when not forced, starts at first non-succeeded stage in manifest order;
|
||||
- with `--force`, reevaluates the selected stage list as runnable.
|
||||
|
||||
### `run-stage`
|
||||
|
||||
```bash
|
||||
@@ -104,6 +93,7 @@ Valid stage names:
|
||||
- `polish`
|
||||
- `normalize`
|
||||
- `trim`
|
||||
- `render`
|
||||
- `analyze`
|
||||
- `publish`
|
||||
- `notify`
|
||||
@@ -253,7 +243,7 @@ Behavior:
|
||||
|
||||
## `--artifacts` Selection Rules
|
||||
|
||||
- accepted on `run`, `resume`, `run-stage`, `analyze`, and `publish`;
|
||||
- accepted on `run`, `run-stage`, `analyze`, and `publish`;
|
||||
- names must exist in `pipeline.scriptorium.artifacts`;
|
||||
- empty entries are invalid;
|
||||
- repeated names are deduplicated.
|
||||
|
||||
@@ -98,6 +98,12 @@ publish:
|
||||
- source: narratio.transcript.final_trimmed
|
||||
dest: transcripts/final.trimmed.json
|
||||
required: true
|
||||
- source: narratio.transcript.final_markdown
|
||||
dest: transcripts/final.md
|
||||
required: true
|
||||
- source: narratio.transcript.final_trimmed_markdown
|
||||
dest: transcripts/final.trimmed.md
|
||||
required: true
|
||||
- source: narratio.artifact.session_recap
|
||||
dest: artifacts/session_recap.md
|
||||
required: true
|
||||
@@ -138,7 +144,7 @@ Rules:
|
||||
| `pipeline.cache.s3_audio` | bool | No | `true` |
|
||||
| `pipeline.publish.enabled` | bool | No | `true` |
|
||||
| `pipeline.publish.upload_run` | bool | No | `true` |
|
||||
| `pipeline.publish.outputs[]` | list | No | defaults to final trimmed transcript output |
|
||||
| `pipeline.publish.outputs[]` | list | No | defaults to final trimmed JSON plus final and final-trimmed Markdown outputs |
|
||||
| `pipeline.publish.outputs[].source` | string | Yes (per rule) | must reference built-in or configured artifact source |
|
||||
| `pipeline.publish.outputs[].dest` | string | Conditional | derived if omitted and source supports derivation |
|
||||
| `pipeline.publish.outputs[].required` | bool | No | `true` |
|
||||
@@ -188,6 +194,12 @@ Rules:
|
||||
| `pipeline.trim.bounds.render_debug` | bool | No | `false` |
|
||||
| `pipeline.trim.bounds.render_output_path` | string | Conditional | required when `render_debug` is true |
|
||||
| `pipeline.trim.seriatim.report` | bool | No | `false` |
|
||||
| `pipeline.render.enabled` | bool | No | `true` |
|
||||
| `pipeline.render.format` | string | No | `markdown` (only supported value) |
|
||||
| `pipeline.render.title` | string | No | empty (falls back to `session.title` when set) |
|
||||
| `pipeline.render.include_timestamps` | bool | No | `true` |
|
||||
| `pipeline.render.include_segment_ids` | bool | No | `false` |
|
||||
| `pipeline.render.include_metadata` | bool | No | `false` |
|
||||
| `pipeline.scriptorium.binary` | string | No | `scriptorium` |
|
||||
| `pipeline.scriptorium.config_path` | string | No | empty |
|
||||
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
|
||||
|
||||
@@ -10,7 +10,7 @@ These docs cover what Narratio expects from external tools and what each adapter
|
||||
|
||||
## Integration Contracts
|
||||
- `audita.md`: transcript polishing adapter (`audita process`).
|
||||
- `seriatim.md`: merge/normalize/trim adapter (`seriatim`).
|
||||
- `seriatim.md`: merge/normalize/trim/render adapter (`seriatim`).
|
||||
- `scriptorium.md`: artifact run/render adapter (`scriptorium run|render`).
|
||||
|
||||
## Related Canonical Docs
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Integration: Seriatim
|
||||
|
||||
## Purpose
|
||||
Define the Seriatim adapter contract used by `merge`, `normalize`, and `trim`.
|
||||
Define the Seriatim adapter contract used by `merge`, `normalize`, `trim`, and `render`.
|
||||
|
||||
## Adapter Boundary
|
||||
Interface:
|
||||
@@ -10,6 +10,7 @@ Interface:
|
||||
- `Run(ctx, MergeRequest)`
|
||||
- `Normalize(ctx, NormalizeRequest)`
|
||||
- `Trim(ctx, TrimRequest)`
|
||||
- `Render(ctx, RenderRequest)`
|
||||
|
||||
Primary implementation:
|
||||
- `internal/adapters/seriatim/SubprocessRunner`
|
||||
@@ -18,11 +19,13 @@ Execution modes:
|
||||
- `seriatim merge`
|
||||
- `seriatim normalize`
|
||||
- `seriatim trim`
|
||||
- `seriatim render`
|
||||
|
||||
## Request/Result Contracts
|
||||
- `MergeRequest`/`MergeResult`: multi-input merge to base transcript, optional report.
|
||||
- `NormalizeRequest`/`NormalizeResult`: transcript normalization with explicit schema.
|
||||
- `TrimRequest`/`TrimResult`: transcript trimming with required keep selector.
|
||||
- `RenderRequest`/`RenderResult`: transcript-to-markdown rendering with explicit format and render booleans.
|
||||
|
||||
Results include output/log/config paths, timing, exit code, and metadata.
|
||||
|
||||
@@ -36,9 +39,11 @@ Runner construction validates:
|
||||
Invocation fails on:
|
||||
- missing required request paths/inputs;
|
||||
- invalid normalize schema override;
|
||||
- unsupported render format;
|
||||
- subprocess failure;
|
||||
- invalid JSON outputs;
|
||||
- missing `segments` array for normalize/trim transcript outputs.
|
||||
- invalid JSON outputs for merge/normalize/trim;
|
||||
- missing `segments` array for normalize/trim transcript outputs;
|
||||
- empty render output files.
|
||||
|
||||
When report paths are provided/enabled, report files must parse as JSON.
|
||||
|
||||
@@ -49,7 +54,7 @@ When report paths are provided/enabled, report files must parse as JSON.
|
||||
- adapter does not write manifests or choose stage inputs.
|
||||
|
||||
## Config Mapping
|
||||
Config fields consumed through runner/stage wiring are under `pipeline.seriatim.*`.
|
||||
Config fields consumed through runner/stage wiring are under `pipeline.seriatim.*` and `pipeline.render.*`.
|
||||
|
||||
Maintained examples with Seriatim config:
|
||||
- `examples/pipeline.full.annotated.yml`
|
||||
|
||||
@@ -20,9 +20,10 @@ Canonical stage order from `internal/stage.All()`:
|
||||
4. `polish`
|
||||
5. `normalize`
|
||||
6. `trim`
|
||||
7. `analyze`
|
||||
8. `publish`
|
||||
9. `notify` (placeholder)
|
||||
7. `render`
|
||||
8. `analyze`
|
||||
9. `publish`
|
||||
10. `notify` (placeholder)
|
||||
|
||||
`notify` is currently a placeholder stage with optional notifier call behavior; it has no persisted pipeline outputs.
|
||||
|
||||
@@ -39,5 +40,6 @@ Canonical stage order from `internal/stage.All()`:
|
||||
- `stage-polish.md`
|
||||
- `stage-normalize.md`
|
||||
- `stage-trim.md`
|
||||
- `stage-render.md`
|
||||
- `stage-analyze.md`
|
||||
- `stage-publish.md`
|
||||
|
||||
@@ -14,9 +14,6 @@ Primary adapters:
|
||||
- `storage.ObjectStore`
|
||||
- `notify.Sender`
|
||||
|
||||
Legacy compatibility boundary:
|
||||
- `storage.Backend` remains in the storage adapter package and defaults to `NoopBackend`; current pipeline stages use `storage.ObjectStore`.
|
||||
|
||||
## Ownership
|
||||
Adapters own:
|
||||
- HTTP/subprocess/SDK argument and transport details.
|
||||
|
||||
@@ -9,6 +9,8 @@ Define canonical artifact IDs, runtime catalog behavior, source resolution rules
|
||||
- `narratio.transcript.polished` -> `transcripts/polished.json` (`polish`)
|
||||
- `narratio.transcript.final` -> `transcripts/final.json` (`normalize`)
|
||||
- `narratio.transcript.final_trimmed` -> `transcripts/final.trimmed.json` (`trim`)
|
||||
- `narratio.transcript.final_markdown` -> `transcripts/final.md` (`render`)
|
||||
- `narratio.transcript.final_trimmed_markdown` -> `transcripts/final.trimmed.md` (`render`)
|
||||
- `narratio.bounds.session` -> `artifacts/session_bounds.json` (`trim`)
|
||||
|
||||
## Configured and Previous-Session Sources
|
||||
@@ -53,7 +55,8 @@ Previous-session sources (`narratio.previous_session.artifact.*`):
|
||||
|
||||
Validation by content type:
|
||||
|
||||
- transcript built-ins: JSON with top-level `segments` array;
|
||||
- transcript JSON built-ins: JSON with top-level `segments` array;
|
||||
- transcript Markdown built-ins: non-empty text file;
|
||||
- bounds built-in: valid JSON;
|
||||
- configured/previous-session artifact files: non-empty text file.
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ Supported source families:
|
||||
## Failure Semantics
|
||||
- required missing configured/previous-session inputs fail.
|
||||
- missing required previous-session source includes prepare rerun guidance.
|
||||
- missing required `narratio.transcript.final_markdown` or `narratio.transcript.final_trimmed_markdown` inputs includes render rerun guidance.
|
||||
- dependency cycles or unavailable required dependencies fail.
|
||||
- adapter validation failures fail stage.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Upload run/session outputs to object storage and atomically advance remote current state.
|
||||
|
||||
## Inputs
|
||||
- successful prerequisite stages: `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `analyze`
|
||||
- successful prerequisite stages: `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `render`, `analyze`
|
||||
- run root `runs/{run_id}/**`
|
||||
- publish output rules (`pipeline.publish.outputs`)
|
||||
- effective publish locks (static + remote merged lock set)
|
||||
|
||||
29
docs/internal/stage-render.md
Normal file
29
docs/internal/stage-render.md
Normal 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.
|
||||
@@ -29,9 +29,6 @@ S3 constructor behavior:
|
||||
- `Upload` streams local file and returns remote metadata.
|
||||
- `Exists` maps not-found responses to `false`.
|
||||
|
||||
## Legacy Compatibility Interface
|
||||
`storage.Backend` (with `ArchiveRequest`) remains as compatibility surface with `NoopBackend`; it is not used by current stage execution.
|
||||
|
||||
## Invariants
|
||||
- storage layer is stateless regarding manifest/stage progression.
|
||||
- publish ordering semantics are owned by stage/app code, not storage adapters.
|
||||
|
||||
@@ -40,7 +40,7 @@ Run-local outputs are materialized back into canonical session paths before stag
|
||||
`artifacts.LocalStore` enforces single-writer session lock via `.lock` file (`ErrLockConflict` on contention).
|
||||
|
||||
## Cleanup Semantics
|
||||
Automatic post-publish cleanup (`runPostArchiveCleanup`):
|
||||
Automatic post-publish cleanup:
|
||||
- only runs when publish actually executed and succeeded;
|
||||
- requires `uploaded=true` and `current_pointer_written=true` metadata;
|
||||
- respects `pipeline.spool.delete_audio_after_publish` and `pipeline.workspace.cleanup_after_publish`;
|
||||
|
||||
@@ -4,33 +4,6 @@ Operator workflow for running, recovering, and publishing Narratio sessions.
|
||||
|
||||
For command syntax, see [docs/cli.md](./cli.md). For field-level config, see [docs/config.md](./config.md).
|
||||
|
||||
## Standard Session Workflow
|
||||
|
||||
1. Select pipeline/campaign/session config.
|
||||
2. Validate session readiness:
|
||||
|
||||
```bash
|
||||
narratio session validate 2026-04-04
|
||||
```
|
||||
|
||||
3. (Optional) inspect stage decisions:
|
||||
|
||||
```bash
|
||||
narratio session plan 2026-04-04
|
||||
```
|
||||
|
||||
4. Run the pipeline:
|
||||
|
||||
```bash
|
||||
narratio run 2026-04-04
|
||||
```
|
||||
|
||||
5. Check state:
|
||||
|
||||
```bash
|
||||
narratio session status 2026-04-04
|
||||
```
|
||||
|
||||
## Campaign and Session Selection
|
||||
|
||||
Campaign selection priority:
|
||||
@@ -63,7 +36,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.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -73,14 +73,15 @@ Canonical stage order:
|
||||
4. `polish`
|
||||
5. `normalize`
|
||||
6. `trim`
|
||||
7. `analyze`
|
||||
8. `publish`
|
||||
9. `notify`
|
||||
7. `render`
|
||||
8. `analyze`
|
||||
9. `publish`
|
||||
10. `notify`
|
||||
|
||||
Execution rules:
|
||||
|
||||
- succeeded stages are skipped unless `--force` is set;
|
||||
- `resume` starts at first non-succeeded stage;
|
||||
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||
- force rerunning a succeeded upstream stage marks succeeded downstream stages as `stale`.
|
||||
|
||||
Single-stage execution:
|
||||
@@ -91,7 +92,7 @@ narratio run-stage normalize 2026-04-04 --force
|
||||
|
||||
## Artifact Selection
|
||||
|
||||
`--artifacts` can be used on `run`, `resume`, `run-stage`, `analyze`, and `publish`.
|
||||
`--artifacts` can be used on `run`, `run-stage`, `analyze`, and `publish`.
|
||||
|
||||
Selection behavior:
|
||||
|
||||
@@ -244,6 +245,7 @@ Rules:
|
||||
## Operational Caveats
|
||||
|
||||
- Local and S3 audio modes are mutually exclusive.
|
||||
- Publish requires prerequisite stages through analyze to be succeeded.
|
||||
- Publish requires prerequisite stages through `render` and `analyze` to be succeeded.
|
||||
- Markdown publish defaults require render outputs (`transcripts/final.md` and `transcripts/final.trimmed.md`).
|
||||
- Restore requires configured object storage and committed remote current state.
|
||||
- Storage-backed commands load filesystem secrets before object-store initialization.
|
||||
|
||||
@@ -6,7 +6,7 @@ Canonical contributor workflow and engineering conventions for implemented Narra
|
||||
## Repository layout
|
||||
|
||||
- `cmd/narratio/`: CLI entrypoint.
|
||||
- `internal/app/`: command handlers, plan/run/resume orchestration, cleanup gates, secrets loading.
|
||||
- `internal/app/`: command handlers, run/stage orchestration, cleanup gates, secrets loading.
|
||||
- `internal/config/`: strict YAML loading, defaults, and validation.
|
||||
- `internal/stage/`: stage implementations and stage registry/order.
|
||||
- `internal/adapters/`: external boundary adapters (WhisperX, Seriatim, Audita, Scriptorium, storage, notify).
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
# Roadmap: Code Quality and Deduplication Audit
|
||||
|
||||
Status: Draft audit report
|
||||
|
||||
This report is a pre-1.0 implementation audit focused on high-confidence opportunities to simplify, centralize, or clarify Narratio before release. It is intentionally report-only: no refactors are included here.
|
||||
|
||||
The requested `docs/architecture.md` and `docs/development.md` paths do not exist in the current tree. This audit used the current policy documents at `docs/policy/architecture.md` and `docs/policy/development.md`, plus the current user, operator, and internal docs.
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Overall code quality is solid. The codebase has strong package boundaries in the important places: storage adapters expose a narrow object-store interface, AWS SDK types do not leak into app or stage logic, config loading is strict, and pipeline execution remains explicit and stage-driven. Recent pre-1.0 work has also produced useful central points for campaign/session config loading, secret-backed object-store creation, S3 audio caching, transcript artifact naming, local session paths, and S3 key construction.
|
||||
|
||||
The main release risk is not a large architectural flaw. It is policy drift from rapid feature growth. Several public-interface decisions now appear in more than one implementation path: artifact source interpretation, publish-output destination derivation, remote current-state inspection, cleanup safety checks, and session-oriented command parsing. Most of these are correct today, but a future bug fix would likely have to be made in multiple files.
|
||||
|
||||
Top three refactoring targets before 1.0:
|
||||
|
||||
1. Centralize artifact source and publish-output resolution across config validation, publish execution, status/artifacts output, restore, previous-cache hydration, and analyze input resolution.
|
||||
2. Consolidate shared session-command flag parsing and config-loading context for run/resume/run-stage/analyze/publish/restore/clean/session helpers without introducing a generic command framework.
|
||||
3. Finish the publish terminology cleanup internally so public `publish` behavior is not implemented through `archive`-named files, helpers, errors, and tests.
|
||||
|
||||
The codebase appears ready for a limited cleanup pass. No major architecture rewrite is warranted before 1.0.
|
||||
|
||||
## 2. High-Confidence Deduplication Opportunities
|
||||
|
||||
### Artifact Source and Publish Destination Policy Is Split Across Packages
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/config/validate.go`
|
||||
- `internal/artifacts/artifact_resolver.go`
|
||||
- `internal/artifacts/catalog.go`
|
||||
- `internal/stage/archive.go`
|
||||
- `internal/app/operator_helpers.go`
|
||||
- `internal/previouscache/previouscache.go`
|
||||
- `internal/stage/analyze.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
- Config validation accepts and derives destinations for `pipeline.publish.outputs[]` in `publishSourceKnown` and `derivePublishOutputDest`.
|
||||
- Publish execution derives destinations again in `resolvePublishOutputDest`.
|
||||
- Status and `artifacts list` derive destination display and remote checks in `helperPublishedOutputDest`.
|
||||
- Previous-cache hydration reconstructs candidate artifact locations from manifest outputs, published paths, and configured Scriptorium paths in `artifactRelativePathCandidates`.
|
||||
- Analyze resolves previous-session, built-in, and configured artifact sources separately in `resolveScriptoriumInput`.
|
||||
|
||||
Why it matters:
|
||||
|
||||
Artifact source IDs now define the public contract for analyze inputs, previous-session inputs, publish outputs, locks, status, artifacts listing, restore, and validation. When source interpretation is spread across these packages, it is easy for one path to accept, reject, or resolve a source differently from another.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
Create one small artifact-source policy layer, likely in `internal/artifacts` or a dependency-light sibling of `internal/artifactmodel`, that can:
|
||||
|
||||
- classify source IDs as built-in, configured artifact, or previous-session configured artifact;
|
||||
- validate a source against the current Scriptorium config;
|
||||
- derive the default published destination for a source;
|
||||
- normalize relative artifact destinations;
|
||||
- return consistent display metadata for status and artifacts output.
|
||||
|
||||
Then update config validation, publish execution, helper commands, previous-cache planning, and analyze input resolution to call that policy instead of deriving partial answers locally.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- `internal/artifacts`: source classification, configured artifact validation, default destination derivation, relative destination normalization.
|
||||
- `internal/config`: publish outputs and locks validate through the shared policy.
|
||||
- `internal/stage`: publish output resolution preserves locked, optional, required, and selected-artifact behavior.
|
||||
- `internal/app`: `artifacts list`, `status`, and locks use the same source rules as publish.
|
||||
- `internal/previouscache`: previous-session source resolution still checks manifest outputs, published paths, and configured output paths in the intended order.
|
||||
|
||||
Risk level: Medium. The behavior is public, but a table-driven shared policy should reduce risk if introduced behind existing tests.
|
||||
|
||||
### Publish Terminology Cleanup Is Incomplete Internally
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/stage/archive.go`
|
||||
- `internal/stage/archive_test.go`
|
||||
- `internal/artifacts/archive_identity.go`
|
||||
- `internal/app/post_archive_cleanup.go`
|
||||
- `internal/app/remote_locks.go`
|
||||
- `internal/app/operator_helpers.go`
|
||||
- tests under `internal/app` and `internal/config`
|
||||
- `internal/adapters/storage/archive.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
The public contract now uses `publish`, `published`, and `publish outputs`, but several internal names still use `archive`, `promotion`, or `promoted`. Examples include `archiveStage`, `ResolveArchiveSessionPrefix`, `ResolveArchiveCurrentStateKeys`, `runPostArchiveCleanup`, `staticArchiveLocks`, `normalizeArchiveRelativePath`, and test names such as `TestArchiveUploadsRunRecordPromotionsAndCurrentPointer`.
|
||||
|
||||
Why it matters:
|
||||
|
||||
This is mostly clarity risk, not current behavior risk. However, public docs and config now use publish terminology, while implementation and tests still use old names. This makes code review harder and increases the chance that future work reintroduces old config or command language.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
Do a mechanical naming cleanup after artifact-source policy is centralized:
|
||||
|
||||
- rename `internal/stage/archive.go` to a publish-oriented file and rename `archiveStage` to `publishStage`;
|
||||
- rename archive identity helpers to publish/current-state helpers while keeping S3 layout unchanged;
|
||||
- rename post-archive cleanup helpers and tests to post-publish cleanup;
|
||||
- update old comments and test failure messages that still say archive/promote when they mean publish/published;
|
||||
- leave the immutable run-history path `runs/{run_id}` unchanged.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Existing `internal/stage`, `internal/app`, and `internal/artifacts` tests.
|
||||
- A final term sweep for old terminology, allowing only historical roadmap references and adapter names that are intentionally retained.
|
||||
|
||||
Risk level: Low to Medium. Mostly mechanical, but broad enough to create churn.
|
||||
|
||||
### Session-Oriented CLI Parsing Is Repeated
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/run.go`
|
||||
- `internal/app/resume.go`
|
||||
- `internal/app/run_stage.go`
|
||||
- `internal/app/restore.go`
|
||||
- `internal/app/clean.go`
|
||||
- `internal/app/operator_helpers.go`
|
||||
- `internal/app/session_args.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
Many commands repeat the same flag setup and session ID handling:
|
||||
|
||||
- `--config`, `--campaign`, `--campaign-file`, `--session`, and `--previous-session-id`;
|
||||
- positional session ID extraction;
|
||||
- `--session-id` compatibility through `applyParsedSessionIDArg`;
|
||||
- selected artifact parsing and validation for run/resume/analyze/publish/run-stage;
|
||||
- load through `loadCommandConfig` followed by `config.Validate`.
|
||||
|
||||
Why it matters:
|
||||
|
||||
The command set has recently moved toward `narratio session <subcommand> <session_id>` and shorter top-level convenience commands. Repeated parser setup makes it easy for one command to miss a new flag, use a stale help string, or apply session ID precedence differently.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
Keep command functions explicit, but add a small internal parser helper for common session-aware commands. Avoid a generic CLI framework. A good target is a helper that returns:
|
||||
|
||||
- common config flags;
|
||||
- resolved positional/flag session ID;
|
||||
- previous session override;
|
||||
- optional selected configured artifacts;
|
||||
- normalized command-specific positional validation.
|
||||
|
||||
`run-stage` can remain special because it has both stage and session positional arguments, but it should reuse the same common flag registration and selected-artifact parsing.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Existing app command tests for run, resume, run-stage, analyze, publish, restore, clean, and session subcommands.
|
||||
- Focused tests for positional session ID vs `--session-id` mismatch, missing session ID, and unsupported `--artifacts` by command/stage.
|
||||
|
||||
Risk level: Medium. Refactor is local to app parsing but touches many public commands.
|
||||
|
||||
### Remote Current-State Discovery Is Reimplemented in Several Forms
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/restore_discovery.go`
|
||||
- `internal/previouscache/previouscache.go`
|
||||
- `internal/app/operator_helpers.go`
|
||||
- `internal/stage/prepare_previous.go`
|
||||
- `internal/app/remote_locks.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
Several paths check or download remote current state:
|
||||
|
||||
- restore discovers current run ID and current manifest, validates campaign/session identity, and decodes the manifest;
|
||||
- previous-cache planning repeats current run pointer and manifest checks for the previous session;
|
||||
- session validation checks previous current state with `Exists` calls;
|
||||
- remote lock loading separately checks and downloads `locks.yml`;
|
||||
- remote session fallback lists and downloads `session.yml`.
|
||||
|
||||
Why it matters:
|
||||
|
||||
These workflows are similar but not identical. Some need missing remote state to be an error, while status treats it as state. Still, the low-level sequence of key construction, `Exists`, temp download, decode, and campaign/session/run validation appears multiple times.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
Extract narrow app-level or artifact-level helpers for remote session state objects, not a generic storage workflow engine. Candidate helpers:
|
||||
|
||||
- download object to temp safely;
|
||||
- load current run pointer and manifest for a supplied session prefix;
|
||||
- validate downloaded current manifest identity;
|
||||
- represent missing current state as a typed error so status can downgrade it while restore/prepare fail.
|
||||
|
||||
Keep `storage.ObjectStore` as the boundary and keep S3 key construction in `internal/artifacts`.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- `internal/app`: restore current-state discovery, status missing-state behavior, session validate previous-state behavior.
|
||||
- `internal/previouscache`: required vs optional previous artifact behavior with missing current pointers/manifests.
|
||||
- `internal/app`: malformed remote lock/session data still fails closed where publish-capable execution requires it.
|
||||
|
||||
Risk level: Medium. The missing-state policy differs by caller, so the refactor should centralize mechanics and typed outcomes, not final command decisions.
|
||||
|
||||
### Safe Local Deletion Policy Is Duplicated
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/clean.go`
|
||||
- `internal/app/post_archive_cleanup.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
Both `clean` and post-publish cleanup implement scoped deletion checks:
|
||||
|
||||
- reject empty roots/targets;
|
||||
- resolve absolute paths;
|
||||
- refuse root deletion;
|
||||
- refuse deletion outside the configured root;
|
||||
- refuse symlink deletion;
|
||||
- handle missing targets as successful no-ops.
|
||||
|
||||
Why it matters:
|
||||
|
||||
Deletion policy is high-risk code. Even if the current implementations agree, future fixes should not need to be made twice.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
Extract a small app-level cleanup safety helper, for example `cleanup_target.go`, with functions for:
|
||||
|
||||
- validating a scoped directory target;
|
||||
- validating a scoped file target;
|
||||
- validating removable children under a root.
|
||||
|
||||
Keep command-specific reporting in `clean.go` and manifest metadata handling in post-publish cleanup.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Move the existing focused unsafe-path tests to the shared helper.
|
||||
- Preserve `clean` dry-run tests and post-publish cleanup eligibility tests.
|
||||
|
||||
Risk level: Low. This is a contained refactor with clear behavior preservation.
|
||||
|
||||
### Temp Object Download Helper Is Duplicated
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/restore_discovery.go`
|
||||
- `internal/previouscache/previouscache.go`
|
||||
- `internal/app/remote_locks.go`
|
||||
- `internal/app/config_loader.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
Multiple call sites create a temp file, close it, download an object into it, and delete it on error or defer deletion. The app package has one `downloadObjectToTemp`, while `internal/previouscache` has another copy.
|
||||
|
||||
Why it matters:
|
||||
|
||||
Temp-download behavior affects cleanup, error wording, and future hardening. It is not worth abstracting all storage use, but this small operation is repeated enough to centralize.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
Add a narrow helper close to the storage boundary. Options:
|
||||
|
||||
- `internal/adapters/storage` helper only if it does not learn Narratio session semantics;
|
||||
- `internal/storageutil` if a small internal utility package is acceptable;
|
||||
- app-level helper plus a previouscache dependency inversion if the team wants to avoid a new package.
|
||||
|
||||
The helper should not hide `ObjectStore`; it should only implement safe temp download mechanics.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- temp file cleanup on failed download;
|
||||
- successful download returns a cleaned temp path;
|
||||
- callers preserve their current contextual error messages.
|
||||
|
||||
Risk level: Low.
|
||||
|
||||
## 3. Medium-Confidence Opportunities
|
||||
|
||||
### Operator Helper Implementation Is Too Broad for One File
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/operator_helpers.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
This 1,100+ line file owns session validation, status, session init, artifacts listing, locks list/add/remove, lock-store mutation, artifact catalog rendering, remote output availability, finding formatting, local input validation, and template rendering.
|
||||
|
||||
Why it matters:
|
||||
|
||||
The code is not inherently wrong, and keeping helper commands in `internal/app` fits the architecture. The issue is discoverability and local coupling. Small changes to one helper command require navigating unrelated helper behavior.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
Split by command or responsibility:
|
||||
|
||||
- `session_init.go`
|
||||
- `session_validate.go`
|
||||
- `status.go`
|
||||
- `artifacts_list.go`
|
||||
- `locks.go`
|
||||
- `helper_findings.go`
|
||||
- `helper_artifacts.go`
|
||||
|
||||
Do this only after higher-value policy centralization so the file split does not preserve duplicated logic under new names.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Existing `internal/app/operator_helpers_test.go` can be split later, but a file split alone should not require behavior changes.
|
||||
|
||||
Risk level: Low.
|
||||
|
||||
### Restore Planning Contains Its Own Remote-to-Local Path Policy
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/restore_plan.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
Restore maps remote session keys back to local session paths in `restoreLocalRelativePathForKey`, with explicit include/exclude rules for `current/`, `runs/`, `logs/`, `reports/`, `config/`, `inputs/`, `transcripts/`, `artifacts/`, `previous/`, and optional `audio/`.
|
||||
|
||||
Why it may be intentional:
|
||||
|
||||
Restore is the only command that should translate an entire remote session prefix into a local session subset. It has command-specific conflict and `--include-audio` semantics.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
Do not generalize this immediately. If it changes again, move only the remote-key-to-local-restore-scope classifier into a small helper with table-driven tests. Leave restore action classification local to restore.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Restore scope tests for every included/excluded root.
|
||||
- Audio-specific conflict behavior remains separate.
|
||||
|
||||
Risk level: Low.
|
||||
|
||||
### Manifest Output Scanning Is Repeated but Mostly Stage-Specific
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/artifacts/artifact_resolver.go`
|
||||
- `internal/previouscache/previouscache.go`
|
||||
- `internal/app/runner.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
Several call sites inspect manifest stage outputs or metadata to find artifact paths, published paths, run roots, or configured artifact outputs.
|
||||
|
||||
Why it may be intentional:
|
||||
|
||||
Manifest state has different meanings depending on caller: runtime artifact resolution, previous-cache reconstruction, and run summary construction are not the same policy.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
Avoid a broad manifest-query abstraction before 1.0. Consider adding only narrow helpers for stable metadata reads, such as reading `published_paths` from the publish stage, if the previous-cache and restore paths continue to grow.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Existing manifest resolver tests plus previous-cache tests.
|
||||
|
||||
Risk level: Low.
|
||||
|
||||
### Command Output Formatting Could Be More Consistent
|
||||
|
||||
Affected files/packages:
|
||||
|
||||
- `internal/app/operator_helpers.go`
|
||||
- `internal/app/restore_report.go`
|
||||
- `internal/app/restore_plan.go`
|
||||
- `internal/app/clean.go`
|
||||
- `internal/app/plan.go`
|
||||
|
||||
Duplicated or near-duplicated behavior:
|
||||
|
||||
Status, session validate, artifacts list, locks, clean dry-run, restore dry-run, and plan all render text directly with `fmt.Fprintf`.
|
||||
|
||||
Why it may be intentional:
|
||||
|
||||
The output remains text-only and command-specific. A generic renderer would add complexity without much value.
|
||||
|
||||
Recommended refactor:
|
||||
|
||||
Postpone unless user-facing inconsistencies become painful. A small findings renderer already exists for validation-style output; that is enough for now.
|
||||
|
||||
Suggested tests:
|
||||
|
||||
- Snapshot-style output tests only for stable operator-facing lines that support workflows.
|
||||
|
||||
Risk level: Low.
|
||||
|
||||
## 4. Boundary and Responsibility Concerns
|
||||
|
||||
The major boundaries are healthy:
|
||||
|
||||
- `internal/adapters/storage` owns external storage implementation details.
|
||||
- App code creates object stores through `newCommandObjectStore`, which loads filesystem secrets first.
|
||||
- Stage code depends on `storage.ObjectStore`, not AWS SDK types.
|
||||
- `internal/audio` correctly centralizes S3 audio cache materialization without making the storage adapter aware of cache policy.
|
||||
- `internal/artifacts` owns most local paths and S3 keys.
|
||||
|
||||
Concerns to address:
|
||||
|
||||
- Artifact source policy is split between `internal/config`, `internal/artifacts`, `internal/stage`, `internal/app`, and `internal/previouscache`. This is the clearest boundary drift because source IDs are a shared public contract.
|
||||
- `internal/config` currently derives default publish destinations. Validation should be able to call source policy, but the canonical mapping itself should live outside config.
|
||||
- `internal/app/operator_helpers.go` owns artifact catalog rendering and remote published-output state. That is acceptable for formatting, but destination derivation and source classification should move out.
|
||||
- `internal/stage/archive.go` implements the public `publish` stage. This does not violate boundaries, but it creates conceptual drift.
|
||||
|
||||
Recommended home for shared logic:
|
||||
|
||||
- Source classification and destination derivation: `internal/artifacts` or `internal/artifactmodel` plus a small adapter from Scriptorium config.
|
||||
- Remote key construction: continue using `internal/artifacts`.
|
||||
- Object-store initialization: keep in `internal/app`.
|
||||
- Command parsing: keep in `internal/app`.
|
||||
- Stage-specific execution policy: keep in `internal/stage`.
|
||||
|
||||
## 5. Path and Remote Key Construction Review
|
||||
|
||||
Local path construction is mostly centralized:
|
||||
|
||||
- `internal/artifacts/paths.go` owns session work roots, run roots, spool paths, previous-cache paths, and audio cache paths.
|
||||
- Stage code often gets `artifacts.SessionPaths` and joins stage-local files from those roots, which is appropriate.
|
||||
- The previous-cache redundant nested artifact path has already been addressed by `previousArtifactCacheRelativePath`.
|
||||
|
||||
Remote key construction is mostly centralized:
|
||||
|
||||
- `internal/artifacts/s3_keys.go` owns session prefixes, run prefixes, audio prefixes, `session.yml`, `locks.yml`, current manifest/run pointer keys, published output keys, and run-relative keys.
|
||||
- App and stage code call these helpers rather than scattering full S3 key string concatenation.
|
||||
|
||||
Areas needing cleanup:
|
||||
|
||||
- `ResolveArchiveBucket`, `ResolveArchiveSessionPrefix`, `ResolveArchiveRunPrefix`, and `ResolveArchiveCurrentStateKeys` should be renamed to publish/current-state terminology.
|
||||
- `normalizeArchiveRelativePath` exists in both `internal/stage/archive.go` and `internal/previouscache/previouscache.go`; `normalizeHelperArchiveRelativePath` exists in `internal/app/operator_helpers.go`. These should converge into one helper for clean relative artifact destination paths.
|
||||
- `restore_plan.go` owns `normalizeRemoteKey` and remote key scope mapping. That may remain restore-specific, but it should be watched because it overlaps with S3 key normalization helpers.
|
||||
- `downloadObjectToTemp` exists in more than one package and can be centralized.
|
||||
|
||||
## 6. Artifact/Catalog/Source Resolution Review
|
||||
|
||||
Artifact source handling has a strong foundation:
|
||||
|
||||
- Transcript source IDs and paths are centralized in `internal/artifactmodel/transcripts.go`.
|
||||
- Runtime artifact registry and resolver live in `internal/artifacts/artifact_resolver.go`.
|
||||
- Configured artifact source IDs are consistently formed by `artifacts.ConfiguredArtifactSourceID`.
|
||||
- Previous-session source IDs are recognized by `artifacts.PreviousSessionArtifactName`.
|
||||
- The runtime catalog supports built-ins, configured artifacts, selected artifact execution, and availability.
|
||||
|
||||
The remaining issue is that consumers still build their own partial views of this model:
|
||||
|
||||
- config validation validates and derives publish output destinations;
|
||||
- publish execution resolves included outputs, skipped optional outputs, skipped unselected outputs, and locked outputs;
|
||||
- status/artifacts list derives display destinations and remote published state;
|
||||
- previous-cache planning reconstructs candidate remote paths from previous manifests and publish metadata;
|
||||
- analyze input resolution has its own missing-source messages and previous-session behavior.
|
||||
|
||||
Recommendation:
|
||||
|
||||
Make artifact/source resolution the next cleanup target. The goal is not to create one all-purpose resolver. The goal is to centralize the public source vocabulary and destination derivation so each caller can keep its own policy for missing/required/locked behavior.
|
||||
|
||||
## 7. Config and Command-Loading Review
|
||||
|
||||
Config loading is generally consistent:
|
||||
|
||||
- `loadCommandConfig` is the main command path for pipeline, campaign, session, local discovery, and remote session fallback.
|
||||
- `loadPipelineCampaignConfig` covers commands that create session config and therefore cannot load an existing session.
|
||||
- `newCommandObjectStore` correctly centralizes secret-backed object-store creation.
|
||||
- `config.LoadSessionBytesWithOptions` now rejects session templates outside `session init`, preserving strict concrete session loading.
|
||||
|
||||
Intentional differences:
|
||||
|
||||
- `session init` loads only pipeline and campaign because it creates `session.yml`.
|
||||
- `clean --all` loads only pipeline because it is not session-specific.
|
||||
- `status --manifest` remains a compatibility/local-manifest mode.
|
||||
|
||||
Likely accidental drift to clean up:
|
||||
|
||||
- Common flags and help strings are repeated across commands.
|
||||
- Some helper-command messages still say archive where they now mean publish.
|
||||
- `restore` uses `fs.SetOutput(out)` while most other command parsers discard flag package output and wrap errors themselves. This may be intentional for `--help`, but it is a difference worth documenting or standardizing.
|
||||
- App tests and helper names still contain old archive/promotion terminology, making it harder to see which public contract is current.
|
||||
|
||||
## 8. Refactors to Avoid Before 1.0
|
||||
|
||||
Avoid these before release:
|
||||
|
||||
- A generic workflow engine or DAG abstraction. The explicit stage list is a core design choice and is working.
|
||||
- A broad manifest query framework. Add narrow helpers only where repeated policy is clear.
|
||||
- Moving secret loading into storage adapters. Secret loading is app orchestration policy and should stay out of adapters.
|
||||
- Making storage adapters infer campaign/session/root-prefix semantics. They should continue to receive concrete keys.
|
||||
- Replacing command functions with a generic CLI framework. Small shared flag parsers are enough.
|
||||
- Generalizing all file copy/download behavior. S3 audio cache materialization is intentionally special; ordinary restore/download logic has different semantics.
|
||||
- Adding compatibility aliases for old archive/promote or old transcript names during cleanup. The repo has intentionally made hard cutovers.
|
||||
|
||||
## 9. Recommended Implementation Sequence
|
||||
|
||||
1. Centralize relative artifact destination normalization and temp object download helpers.
|
||||
- Scope: low-risk shared helpers for repeated mechanics.
|
||||
- Tests: `internal/artifacts` or helper-package tests, plus existing app/stage tests.
|
||||
|
||||
2. Centralize artifact source and publish-output policy.
|
||||
- Scope: source classification, source validation, default published destination derivation, destination normalization.
|
||||
- Tests: `internal/artifacts`, `internal/config`, `internal/stage -run Publish`, `internal/app -run 'Artifacts|Status|Locks'`, `internal/previouscache`.
|
||||
|
||||
3. Finish publish terminology cleanup.
|
||||
- Scope: rename archive-named files/helpers/tests/comments where they now mean publish; keep S3 layout stable.
|
||||
- Tests: `go test ./internal/stage -v`, `go test ./internal/app -v`, `go test ./internal/artifacts -v`.
|
||||
|
||||
4. Consolidate session-aware command parsing.
|
||||
- Scope: common config/session/artifact flag registration and session ID resolution; no public CLI behavior change.
|
||||
- Tests: app command tests for run, resume, run-stage, analyze, publish, restore, clean, session helpers.
|
||||
|
||||
5. Extract remote current-state mechanics.
|
||||
- Scope: shared helpers for current run pointer/manifest load and identity validation, with typed missing-state errors.
|
||||
- Tests: restore discovery, previous-cache, status, session validate.
|
||||
|
||||
6. Split operator helper implementation by responsibility.
|
||||
- Scope: file organization and small formatting/helper extraction only after policy deduplication.
|
||||
- Tests: existing `internal/app` tests.
|
||||
|
||||
7. Sweep dead transitional terminology and stale tests.
|
||||
- Scope: comments, test names, old strings, internal docs that still say archive/promote where publish is now canonical.
|
||||
- Tests: final `rg` sweeps plus full test run.
|
||||
|
||||
## 10. Test Strategy
|
||||
|
||||
Focused package checks for cleanup work:
|
||||
|
||||
- `go test ./internal/artifacts -v`
|
||||
- `go test ./internal/config -v`
|
||||
- `go test ./internal/stage -run 'Analyze|Publish|Prepare|Restore' -v`
|
||||
- `go test ./internal/app -run 'Run|RunStage|Analyze|Publish|Restore|Clean|Status|Artifacts|Locks|Session' -v`
|
||||
- `go test ./internal/previouscache -v`
|
||||
- `go test ./internal/adapters/storage -v`
|
||||
- `go test ./internal/manifest -v`
|
||||
|
||||
Tests to add or strengthen during follow-up refactors:
|
||||
|
||||
- one table of valid/invalid artifact source IDs used by config validation, publish, locks, status, and analyze;
|
||||
- one table of default published destination derivation for built-in and configured artifacts;
|
||||
- relative destination normalization and path traversal rejection;
|
||||
- shared remote current-state load outcomes: missing pointer, missing manifest, malformed manifest, campaign mismatch, session mismatch, run ID mismatch;
|
||||
- shared cleanup safety helper behavior for files, directories, roots, symlinks, and outside-root paths;
|
||||
- common session command parsing behavior for positional session IDs, `--session-id`, mismatch errors, and unsupported artifacts flags.
|
||||
|
||||
Full validation after each cleanup commit:
|
||||
|
||||
- `go test ./...`
|
||||
|
||||
Useful final searches:
|
||||
|
||||
- `rg -n "archive|promote|promoted|promotion" internal docs examples cmd`
|
||||
- `rg -n "ResolveArchive|archiveStage|post_archive|staticArchive|normalizeArchive" internal`
|
||||
- `rg -n "narratio.transcript.merged|narratio.transcript.full|narratio.transcript.trimmed" internal docs examples`
|
||||
- `rg -n "previous_session_artifact|promote_artifacts|pipeline.archive" internal docs examples`
|
||||
|
||||
## 11. Appendix: Findings Not Worth Acting On
|
||||
|
||||
- Stage-local path joins for files inside a stage run directory are acceptable. They are local implementation details, not shared path policy.
|
||||
- Direct `fmt.Fprintf` output in simple commands is acceptable. A generic renderer would likely obscure behavior.
|
||||
- Restore's remote-session-prefix filtering is command-specific enough to stay local unless restore scope changes again.
|
||||
- `session init` template rendering should remain separate from ordinary session loading. That separation is now a useful safety boundary.
|
||||
- S3 audio cache materialization is already centralized in `internal/audio`; do not fold it into a generic downloader.
|
||||
- Manifest-driven resume behavior should not be abstracted broadly. The explicit runner behavior is easier to audit.
|
||||
@@ -1,294 +0,0 @@
|
||||
# Roadmap: Pre-1.0 Code Cleanup
|
||||
|
||||
Status: Implemented (Stages 1-6 complete)
|
||||
|
||||
This roadmap turns the findings in `docs/roadmap/audit.md` into staged cleanup work for the 1.0 release and records completion status for each selected stage.
|
||||
|
||||
The cleanup work must follow the policy documents under `docs/policy/`, especially these invariants:
|
||||
|
||||
- keep Narratio explicit and stage-driven;
|
||||
- do not introduce a generic workflow engine, DAG abstraction, or generic CLI framework;
|
||||
- keep external-system details behind adapters;
|
||||
- do not move campaign/session/root-prefix semantics into storage adapters;
|
||||
- keep AWS SDK types out of app and stage logic;
|
||||
- keep path and remote key construction centralized;
|
||||
- preserve manifest-driven run state;
|
||||
- keep public CLI/config behavior stable unless a stage explicitly says it is an internal naming cleanup.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not change public command syntax, config schema, S3 key layout, manifest schema, or artifact source IDs as part of this cleanup.
|
||||
- Do not add compatibility aliases or migration logic.
|
||||
- Do not rewrite stage execution, manifest state transitions, or adapter contracts.
|
||||
- Do not generalize text output into a generic reporting framework.
|
||||
- Do not fold S3 audio cache behavior into a generic downloader.
|
||||
- Do not move secret loading into storage adapters.
|
||||
|
||||
## Stage 1: Shared Low-Risk Mechanics
|
||||
|
||||
Goal: remove duplicated mechanics that are easy to test and should not affect public behavior.
|
||||
|
||||
Implementation decisions:
|
||||
|
||||
- Add one shared helper for safe relative artifact destination normalization.
|
||||
- It must reject empty paths, absolute paths, `.`, `..`, and traversal outside the artifact/session scope.
|
||||
- It must normalize separators to slash-form for artifact and S3 destination logic.
|
||||
- It must be dependency-light enough to be called from config validation, app helpers, publish execution, and previous-cache planning.
|
||||
- Add one shared object-store temp download helper.
|
||||
- It must take `context.Context`, `storage.ObjectStore`, a key, and a temp-file pattern.
|
||||
- It must create and close the temp file before download, remove the temp file on failed download, and return a cleaned local path on success.
|
||||
- It must not infer bucket, campaign, session, run, or root-prefix semantics.
|
||||
- Extract shared cleanup target validation for local deletion.
|
||||
- Cover scoped directory deletion, scoped file deletion, and removable children under a root.
|
||||
- Preserve existing safety rules: reject empty roots/targets, root deletion, outside-root paths, symlinks, and wrong target types.
|
||||
- Keep command-specific output in `clean` and manifest metadata handling in post-publish cleanup.
|
||||
|
||||
Expected callers:
|
||||
|
||||
- replace duplicate relative destination normalization in publish execution, helper command rendering, and previous-cache planning;
|
||||
- replace duplicate temp download helpers in app and previous-cache code;
|
||||
- replace duplicate scoped deletion validation in clean and post-publish cleanup.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add focused tests for destination normalization and path traversal rejection.
|
||||
- Add temp download tests for success, failed download cleanup, and preserved contextual caller errors.
|
||||
- Add shared cleanup validation tests for directories, files, symlinks, missing targets, root deletion, and outside-root targets.
|
||||
- Run:
|
||||
- `go test ./internal/artifacts -v`
|
||||
- `go test ./internal/adapters/storage -v`
|
||||
- `go test ./internal/app -run 'Clean|Post' -v`
|
||||
- `go test ./...`
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- duplicated low-level mechanics are removed;
|
||||
- public behavior and output are unchanged;
|
||||
- no stage, command, or config semantics move into storage adapters.
|
||||
|
||||
## Stage 2: Artifact Source and Published Output Policy
|
||||
|
||||
Goal: make artifact source IDs and published-output destination derivation a single shared policy.
|
||||
|
||||
Implementation decisions:
|
||||
|
||||
- Introduce `internal/artifactpolicy` as the shared source policy package.
|
||||
- This package is the long-term home because it avoids config/artifacts import cycles.
|
||||
- It may depend on dependency-light model packages, but it must not depend on app, stage, manifest stores, storage adapters, or downstream adapters.
|
||||
- Centralize these behaviors in `internal/artifactpolicy`:
|
||||
- classify source IDs as built-in, configured artifact, or previous-session configured artifact;
|
||||
- parse configured artifact keys from `narratio.artifact.<key>`;
|
||||
- parse previous-session artifact keys from `narratio.previous_session.artifact.<key>`;
|
||||
- validate configured artifact sources against `pipeline.scriptorium.artifacts`;
|
||||
- validate publish lock/output sources;
|
||||
- derive default published destinations for built-in and configured artifact sources;
|
||||
- normalize safe relative published-output destinations.
|
||||
- Update callers to consume the shared policy:
|
||||
- config validation for `publish.outputs` and `publish.locks`;
|
||||
- publish-stage output resolution;
|
||||
- status and `artifacts list` rendering;
|
||||
- locks list/add/remove validation;
|
||||
- analyze input source handling;
|
||||
- previous-cache candidate planning.
|
||||
- Preserve caller-specific policy at call sites.
|
||||
- Required vs optional behavior remains in publish, analyze, restore, and previous-cache callers.
|
||||
- Locked output behavior remains in publish.
|
||||
- Text formatting remains in app commands.
|
||||
- Manifest path scanning remains in artifact/previous-cache logic unless directly tied to source policy.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add `internal/artifactpolicy` table tests for source classification, configured artifact validation, previous-session parsing, default destination derivation, and destination normalization.
|
||||
- Update `internal/config` tests so publish outputs and locks validate through the shared policy.
|
||||
- Update `internal/stage` publish tests for selected, unselected, optional, required, and locked output behavior.
|
||||
- Update `internal/app` tests for status, artifacts list, and locks.
|
||||
- Update `internal/previouscache` tests for previous-session candidate ordering.
|
||||
- Run:
|
||||
- `go test ./internal/artifacts -v`
|
||||
- `go test ./internal/config -v`
|
||||
- `go test ./internal/stage -run 'Analyze|Publish' -v`
|
||||
- `go test ./internal/app -run 'Artifacts|Status|Locks' -v`
|
||||
- `go test ./internal/previouscache -v`
|
||||
- `go test ./...`
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- artifact source vocabulary and destination derivation are no longer reimplemented in config, app, stage, and previous-cache packages;
|
||||
- every caller still owns its own missing/required/optional/locked decision;
|
||||
- public behavior is unchanged.
|
||||
|
||||
## Stage 3: Publish Terminology Cleanup
|
||||
|
||||
Goal: align internal implementation names with the public publish contract.
|
||||
|
||||
Implementation decisions:
|
||||
|
||||
- Rename archive-named internal files, types, helpers, comments, and tests that now implement publish behavior.
|
||||
- Replace names such as:
|
||||
- `archiveStage` with `publishStage`;
|
||||
- `ResolveArchiveSessionPrefix` with publish/current-state terminology;
|
||||
- `ResolveArchiveRunPrefix` with publish/run-history terminology;
|
||||
- `ResolveArchiveCurrentStateKeys` with current-state terminology;
|
||||
- `runPostArchiveCleanup` with post-publish cleanup terminology;
|
||||
- `staticArchiveLocks` with publish lock terminology.
|
||||
- Keep the S3 layout stable:
|
||||
- `{session_prefix}/runs/{run_id}/`;
|
||||
- `{session_prefix}/current/manifest.json`;
|
||||
- `{session_prefix}/current/run_id.txt`;
|
||||
- `{session_prefix}/locks.yml`.
|
||||
- Keep the public stage name `publish`.
|
||||
- Keep old archive/promote references only where they are historical roadmap context or intentionally describe immutable run history.
|
||||
|
||||
Tests and checks:
|
||||
|
||||
- Run:
|
||||
- `go test ./internal/stage -v`
|
||||
- `go test ./internal/app -v`
|
||||
- `go test ./internal/artifacts -v`
|
||||
- `go test ./...`
|
||||
- Run stale-term sweeps:
|
||||
- `rg -n "archive|promote|promoted|promotion" internal docs examples cmd`
|
||||
- `rg -n "ResolveArchive|archiveStage|post_archive|staticArchive|normalizeArchive" internal`
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- public publish behavior is no longer implemented through archive/promote names;
|
||||
- remaining old terms are intentionally historical, test-fixture bucket names, or roadmap-only context;
|
||||
- no config, CLI, manifest, or S3 layout changes are introduced.
|
||||
|
||||
## Stage 4: Session Command Parsing Consolidation
|
||||
|
||||
Goal: reduce command-loading drift while keeping command handlers explicit.
|
||||
|
||||
Implementation decisions:
|
||||
|
||||
- Add a small app-level parser helper for common session-aware commands.
|
||||
- Centralize:
|
||||
- common config flags: `--config`, `--campaign`, `--campaign-file`, `--session`;
|
||||
- positional session ID handling;
|
||||
- `--session-id` compatibility;
|
||||
- `--previous-session-id`;
|
||||
- optional selected-artifact parsing for commands that support it.
|
||||
- Keep command handlers explicit and readable.
|
||||
- Do not introduce a generic CLI framework.
|
||||
- Treat these as intentional special cases:
|
||||
- `session init` loads pipeline and campaign but not session;
|
||||
- `clean --all` loads pipeline only;
|
||||
- `status --manifest` remains local-manifest mode;
|
||||
- `run-stage` keeps its stage-name positional handling but reuses common flag parsing where practical.
|
||||
- Standardize flag help text where commands use the same semantics.
|
||||
|
||||
Tests:
|
||||
|
||||
- Update app command tests for:
|
||||
- positional session ID;
|
||||
- `--session-id`;
|
||||
- positional/flag mismatch;
|
||||
- missing session ID;
|
||||
- `--previous-session-id`;
|
||||
- unsupported `--artifacts` by command/stage;
|
||||
- unchanged behavior for `session init`, `clean --all`, and `status --manifest`.
|
||||
- Run:
|
||||
- `go test ./internal/app -run 'Run|RunStage|Analyze|Publish|Restore|Clean|Session' -v`
|
||||
- `go test ./internal/app -v`
|
||||
- `go test ./...`
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- shared session flag/session ID behavior has one implementation;
|
||||
- command handlers remain command-specific;
|
||||
- public command syntax and output stay unchanged.
|
||||
|
||||
## Stage 5: Remote Current-State Mechanics
|
||||
|
||||
Goal: centralize remote current-state loading mechanics without hiding caller policy.
|
||||
|
||||
Implementation decisions:
|
||||
|
||||
- Extract narrow helpers for remote current state.
|
||||
- Load current run pointer through `storage.ObjectStore`.
|
||||
- Load and decode current manifest through `storage.ObjectStore`.
|
||||
- Validate campaign, session, and run identity when requested by the caller.
|
||||
- Return typed missing-state errors.
|
||||
- Preserve caller policy:
|
||||
- restore treats missing or invalid current state as an error;
|
||||
- previous-cache hydration fails for required previous artifacts and skips optional missing artifacts;
|
||||
- status reports missing remote state as state, not command failure;
|
||||
- session validate emits findings and fails only for error findings.
|
||||
- Keep all remote key construction in `internal/artifacts`.
|
||||
- Keep object-store initialization in `internal/app`.
|
||||
- Do not add storage adapter knowledge of campaigns, sessions, runs, root prefixes, current state, or manifests.
|
||||
|
||||
Tests:
|
||||
|
||||
- Add helper tests for:
|
||||
- missing current run pointer;
|
||||
- missing current manifest;
|
||||
- empty run pointer;
|
||||
- malformed manifest;
|
||||
- campaign mismatch;
|
||||
- session mismatch;
|
||||
- run ID mismatch.
|
||||
- Update restore, previous-cache, status, and session validate tests to prove their caller-specific behavior is unchanged.
|
||||
- Run:
|
||||
- `go test ./internal/app -run 'Restore|Status|SessionValidate' -v`
|
||||
- `go test ./internal/previouscache -v`
|
||||
- `go test ./...`
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- low-level remote current-state mechanics are shared;
|
||||
- missing-state behavior remains caller-specific;
|
||||
- storage adapter boundaries remain unchanged.
|
||||
|
||||
## Stage 6: Operator Helper File Split and Final Sweep
|
||||
|
||||
Goal: improve maintainability after shared policy and mechanics are already centralized.
|
||||
|
||||
Implementation decisions:
|
||||
|
||||
- Split the large operator helper implementation by command or responsibility.
|
||||
- Suggested file grouping:
|
||||
- session init;
|
||||
- session validate;
|
||||
- status;
|
||||
- artifacts list;
|
||||
- locks;
|
||||
- helper findings;
|
||||
- helper artifact rendering.
|
||||
- Do not change command syntax, text output, config loading, remote loading, lock behavior, or artifact catalog behavior during the split.
|
||||
- Keep output formatting text-only and command-specific unless a concrete inconsistency remains after the split.
|
||||
- Update roadmap status notes after each completed stage.
|
||||
|
||||
Tests and checks:
|
||||
|
||||
- Run:
|
||||
- `go test ./internal/app -v`
|
||||
- `go test ./...`
|
||||
- Final searches:
|
||||
- `rg -n "archive|promote|promoted|promotion" internal docs examples cmd`
|
||||
- `rg -n "narratio.transcript.merged|narratio.transcript.full|narratio.transcript.trimmed" internal docs examples`
|
||||
- `rg -n "previous_session_artifact|promote_artifacts|pipeline.archive" internal docs examples`
|
||||
|
||||
Completion criteria:
|
||||
|
||||
- operator helper code is easier to navigate;
|
||||
- stale implementation terminology is removed or intentionally documented;
|
||||
- no behavior changes are introduced by file organization.
|
||||
|
||||
## Overall Validation
|
||||
|
||||
After each implementation stage:
|
||||
|
||||
- run the focused tests listed for that stage;
|
||||
- run `go test ./...`;
|
||||
- run `git status --short`;
|
||||
- update this roadmap to mark the completed stage implemented only after code, tests, and documentation are aligned.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- This roadmap is a cleanup plan, not a feature plan.
|
||||
- Stages may be implemented as separate prompts/commits.
|
||||
- `internal/artifactpolicy` is the chosen home for shared source policy.
|
||||
- Shared object-store temp download helpers must not learn Narratio session semantics.
|
||||
- Public behavior must remain stable unless a stage explicitly says it is internal terminology cleanup.
|
||||
@@ -1,105 +0,0 @@
|
||||
# Documentation Pass: Stage 1 Audit
|
||||
|
||||
Status: Completed (2026-05-23)
|
||||
|
||||
## Scope Reviewed
|
||||
|
||||
- All non-policy documentation files under `docs/`
|
||||
- `README.md`
|
||||
- Documentation references to maintained `examples/` files
|
||||
- Documentation-related expectations in tests under `internal/**`
|
||||
|
||||
## File Inventory and Canonical Scope
|
||||
|
||||
| File | Intended audience | Canonical scope (per policy) | Primary source-of-truth anchors |
|
||||
| --- | --- | --- | --- |
|
||||
| `README.md` | Users, operators | Project orientation and links | `cmd/narratio`, `internal/app/commands.go`, docs index files |
|
||||
| `docs/cli.md` | Users, operators | CLI syntax, flags, command workflows | `internal/app/*.go`, `internal/app/*_test.go` |
|
||||
| `docs/config.md` | Operators, advanced users | Config discovery, schema, defaults, examples | `internal/config/*.go`, `internal/config/*_test.go`, `examples/*` |
|
||||
| `docs/operations.md` | Operators | Run/resume/publish/restore/cleanup workflows | `internal/app/runner.go`, `internal/app/restore*.go`, `internal/stage/archive.go`, `internal/artifacts/*.go` |
|
||||
| `docs/troubleshooting.md` | Operators | Failure diagnosis and safe fixes | `internal/app`, `internal/stage`, related tests |
|
||||
| `docs/internal/README.md` | Developers, LLM coding agents | Internal docs index and scope boundaries | `docs/internal/*.md`, policy docs |
|
||||
| `docs/internal/adapters.md` | Developers, LLM coding agents | Adapter boundaries and ownership | `internal/adapters/*`, `internal/stage/*` |
|
||||
| `docs/internal/artifacts.md` | Developers, LLM coding agents | Artifact catalog and source resolution contracts | `internal/artifacts/*`, `internal/stage/analyze.go`, `internal/stage/prepare_previous.go` |
|
||||
| `docs/internal/command-restore.md` | Developers, LLM coding agents | Restore command architecture and contracts | `internal/app/restore*.go`, `internal/app/restore*_test.go` |
|
||||
| `docs/internal/manifest.md` | Developers, LLM coding agents | Session/run manifest contracts and transitions | `internal/manifest/*`, `internal/app/runner.go`, `internal/stage/*` |
|
||||
| `docs/internal/stage-prepare.md` | Developers, LLM coding agents | Prepare stage IO and invariants | `internal/stage/prepare.go`, `internal/stage/prepare*_test.go` |
|
||||
| `docs/internal/stage-transcribe.md` | Developers, LLM coding agents | Transcribe stage IO and invariants | `internal/stage/transcribe.go`, `internal/stage/transcribe_test.go` |
|
||||
| `docs/internal/stage-merge.md` | Developers, LLM coding agents | Merge stage IO and invariants | `internal/stage/merge.go`, `internal/stage/merge_test.go` |
|
||||
| `docs/internal/stage-polish.md` | Developers, LLM coding agents | Polish stage IO and invariants | `internal/stage/polish.go`, `internal/stage/polish_test.go` |
|
||||
| `docs/internal/stage-normalize.md` | Developers, LLM coding agents | Normalize stage IO and invariants | `internal/stage/normalize.go`, `internal/stage/normalize_test.go` |
|
||||
| `docs/internal/stage-trim.md` | Developers, LLM coding agents | Trim stage IO and invariants | `internal/stage/trim.go`, `internal/stage/trim_test.go` |
|
||||
| `docs/internal/stage-analyze.md` | Developers, LLM coding agents | Analyze stage artifact execution and selection | `internal/stage/analyze.go`, `internal/stage/analyze_test.go` |
|
||||
| `docs/internal/stage-publish.md` | Developers, LLM coding agents | Publish-stage commit/upload invariants | `internal/stage/archive.go`, `internal/stage/archive_test.go` |
|
||||
| `docs/internal/storage.md` | Developers, LLM coding agents | Storage adapter contracts and semantics | `internal/adapters/storage/*`, `internal/app/object_store.go` |
|
||||
| `docs/internal/workspace.md` | Developers, LLM coding agents | Local workspace/session/run path model | `internal/artifacts/*`, `internal/app/runner.go`, `internal/stage/run_local.go` |
|
||||
| `docs/integrations/README.md` | Developers, LLM coding agents | Integration docs index | `docs/integrations/*.md` |
|
||||
| `docs/integrations/audita.md` | Developers, integration maintainers | Audita adapter contract | `internal/adapters/audita/*`, `internal/stage/polish.go` |
|
||||
| `docs/integrations/seriatim.md` | Developers, integration maintainers | Seriatim adapter contract | `internal/adapters/seriatim/*`, `internal/stage/merge.go`, `internal/stage/normalize.go`, `internal/stage/trim.go` |
|
||||
| `docs/integrations/scriptorium.md` | Developers, integration maintainers | Scriptorium adapter contract | `internal/adapters/scriptorium/*`, `internal/stage/analyze.go`, `internal/stage/trim.go` |
|
||||
| `docs/roadmap/documentation.md` | Developers, maintainers | Planning and implementation sequencing for documentation pass | N/A (planning artifact) |
|
||||
| `docs/roadmap/documentation-stage1-audit.md` | Developers, maintainers | Stage-1 inventory and source-of-truth audit record | N/A (planning artifact) |
|
||||
|
||||
## Source-of-Truth Mapping Summary
|
||||
|
||||
- CLI behaviors and command names are grounded in `internal/app/commands.go` and command handlers in `internal/app/*.go`.
|
||||
- Stage order and canonical stage names are grounded in `internal/stage/placeholders.go` (`prepare` -> `transcribe` -> `merge` -> `polish` -> `normalize` -> `trim` -> `analyze` -> `publish` -> `notify`).
|
||||
- Publish behavior and current-pointer commit semantics are grounded in `internal/stage/archive.go`.
|
||||
- Config schema/defaults/validation are grounded in `internal/config/*`.
|
||||
- Local/remote paths, publish keys, and workspace layout are grounded in `internal/artifacts/*`.
|
||||
- Restore behavior and report contracts are grounded in `internal/app/restore*.go`.
|
||||
- Maintained examples and schema compatibility are grounded in `examples/*` plus `internal/config/load_validate_test.go` (`TestExamplesLoadAndValidate`).
|
||||
|
||||
## Findings
|
||||
|
||||
### Broken or stale references
|
||||
|
||||
1. `README.md` linked to non-existent files:
|
||||
- `docs/development.md`
|
||||
- `docs/architecture.md`
|
||||
2. `docs/internal/README.md` and `docs/integrations/README.md` linked to non-existent path:
|
||||
- `docs/documentation/policy.md`
|
||||
|
||||
Stage-1 fix applied:
|
||||
- Updated those links to existing policy docs under `docs/policy/`.
|
||||
|
||||
### Stale terminology sweep
|
||||
|
||||
Sweep terms used: `archive`, `promote`, `promoted`, `promote_artifacts`, `run-stage archive`.
|
||||
|
||||
Findings:
|
||||
- User-facing docs in scope did not show obvious stale command examples requiring immediate correction.
|
||||
- Internal code and tests still contain historical `archive` identifiers while user-facing command/stage naming is `publish` (for example, `internal/stage/archive.go` type names). This is acceptable for now but should be normalized deliberately, not incidentally.
|
||||
|
||||
Stage-1 fix applied:
|
||||
- Updated clearly stale publish-related wording in test expectation messages/comments:
|
||||
- `internal/app/commands_test.go`
|
||||
- `internal/app/operator_helpers_test.go`
|
||||
|
||||
### Example path validation
|
||||
|
||||
- All `examples/...` paths referenced from non-policy docs resolve to existing files.
|
||||
- `internal/config/load_validate_test.go` includes `TestExamplesLoadAndValidate` and points to current example files.
|
||||
|
||||
### Roadmap leakage into current-behavior docs
|
||||
|
||||
- No obvious roadmap-only behavior leakage found in non-roadmap docs during this sweep.
|
||||
|
||||
### Duplicate content and scope drift
|
||||
|
||||
- No severe duplication requiring immediate rewrite in this stage.
|
||||
- Existing docs still need full content rewrite for 1.0 readiness in later stages (user/operator first, then internal/integrations), as planned.
|
||||
|
||||
### Canonical-home inconsistency to resolve in rewrite stages
|
||||
|
||||
- Policy canonical-home language names `docs/architecture.md` and `docs/development.md`, while current repository stores those policy documents under `docs/policy/`.
|
||||
- Stage 1 preserves repository behavior by fixing broken links to existing files. Later rewrite stages should converge canonical-home paths and references consistently across docs.
|
||||
|
||||
## Stage-1 Completion Check
|
||||
|
||||
Completed for this stage:
|
||||
- Full non-policy file inventory with audience and scope mapping.
|
||||
- Source-of-truth crosswalk to code/tests.
|
||||
- Stale-term, link, and example-path sweeps.
|
||||
- Documentation-related stale test wording corrections.
|
||||
- Minimal fixes only; broad rewrites intentionally deferred.
|
||||
@@ -1,237 +0,0 @@
|
||||
# Roadmap: 1.0 Documentation Pass
|
||||
|
||||
Status: Completed (2026-05-23)
|
||||
|
||||
## Goal
|
||||
|
||||
Prepare Narratio documentation for a 1.0 release by reviewing and rewriting
|
||||
every non-policy document under `docs/` against the implemented codebase.
|
||||
|
||||
The finished documentation set should be accurate, concise, complete for its
|
||||
audience, and compliant with:
|
||||
|
||||
- `docs/policy/documentation.md`
|
||||
- `docs/policy/architecture.md`
|
||||
- `docs/policy/development.md`
|
||||
|
||||
Do not modify files under `docs/policy/` during this pass.
|
||||
|
||||
Current behavior belongs in canonical docs. Future, planned, aspirational, or
|
||||
unimplemented behavior belongs only under `docs/roadmap/`.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- `docs/*.md`
|
||||
- `docs/internal/*.md`
|
||||
- `docs/integrations/*.md`
|
||||
- `docs/roadmap/*.md`
|
||||
- documentation references to files under `examples/`
|
||||
- test expectation fixes when the documentation review exposes stale or
|
||||
incorrect doc/example/path expectations
|
||||
|
||||
Out of scope:
|
||||
|
||||
- product/runtime code changes
|
||||
- feature implementation
|
||||
- edits under `docs/policy/`
|
||||
- adding roadmap behavior to current-behavior docs before that behavior is
|
||||
implemented
|
||||
|
||||
## Implementation Stages
|
||||
|
||||
### Stage 1: Inventory and Source-of-Truth Audit
|
||||
|
||||
Status: Completed (2026-05-23)
|
||||
|
||||
Create a file-by-file inventory of all non-policy docs before rewriting.
|
||||
|
||||
Implementation requirements:
|
||||
|
||||
- List every non-policy documentation file and assign its intended audience.
|
||||
- Identify each document's canonical scope using `docs/policy/documentation.md`.
|
||||
- Compare docs against the current code and tests, especially:
|
||||
- `internal/app`
|
||||
- `internal/config`
|
||||
- `internal/stage`
|
||||
- `internal/artifacts`
|
||||
- `examples`
|
||||
- relevant tests under `internal/**`
|
||||
- Record stale terminology, broken links, stale example paths, duplicate
|
||||
content, and roadmap-only behavior that leaked into current-behavior docs.
|
||||
- Record stale test expectations related to docs, examples, paths, or command
|
||||
text.
|
||||
- Do not rewrite content in this stage except obvious broken links or test
|
||||
corrections needed to make documentation validation meaningful.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The rewrite has a concrete file inventory and source-of-truth map.
|
||||
- The team knows which docs are canonical and which should link elsewhere.
|
||||
- Known stale terms and broken references are identified before broad edits.
|
||||
|
||||
### Stage 2: User and Operator Docs
|
||||
|
||||
Status: Completed (2026-05-23)
|
||||
|
||||
Rewrite the user-facing and operator-facing docs first.
|
||||
|
||||
Implementation requirements:
|
||||
|
||||
- Rewrite these docs as fresh, concise current-behavior references:
|
||||
- `README.md`, if present
|
||||
- `docs/cli.md`
|
||||
- `docs/config.md`
|
||||
- `docs/operations.md`
|
||||
- `docs/troubleshooting.md`
|
||||
- Verify every command, flag, config field, discovery rule, path, and workflow
|
||||
against implemented behavior.
|
||||
- Cover implemented 1.0 behavior, including:
|
||||
- campaign registry selection;
|
||||
- concrete session loading and template-driven `session init`;
|
||||
- session-oriented helper commands;
|
||||
- clean, restore, analyze, and publish workflows;
|
||||
- artifact selection behavior;
|
||||
- locks and published output behavior;
|
||||
- workspace, spool, and cache behavior;
|
||||
- secrets loading and S3-backed operation.
|
||||
- Keep examples short and link to maintained files under `examples/` instead
|
||||
of duplicating large config blocks.
|
||||
- Fix tests only when they assert stale doc paths, example paths, command
|
||||
names, or current-behavior text.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- User/operator docs are task-oriented and match actual CLI/config behavior.
|
||||
- Current-behavior docs do not depend on roadmaps for normal usage.
|
||||
- No current-behavior doc describes unimplemented roadmap items.
|
||||
|
||||
### Stage 3: Internal Developer Docs
|
||||
|
||||
Status: Completed (2026-05-23)
|
||||
|
||||
Rewrite implemented internal component docs after public docs stabilize.
|
||||
|
||||
Implementation requirements:
|
||||
|
||||
- Rewrite:
|
||||
- `docs/internal/README.md`
|
||||
- `docs/internal/adapters.md`
|
||||
- `docs/internal/artifacts.md`
|
||||
- `docs/internal/command-restore.md`
|
||||
- `docs/internal/manifest.md`
|
||||
- `docs/internal/stage-*.md`
|
||||
- `docs/internal/storage.md`
|
||||
- `docs/internal/workspace.md`
|
||||
- Verify stage docs against current stage names, stage ordering, manifest
|
||||
records, declared inputs/outputs, adapters, path helpers, storage behavior,
|
||||
publish/current-state behavior, restore behavior, cache behavior, and
|
||||
workspace cleanup.
|
||||
- Keep implementation details in `docs/internal/`, not in user-facing docs.
|
||||
- Avoid turning internal docs into duplicate config or CLI references; link to
|
||||
canonical docs when needed.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Internal docs are accurate enough for developers and LLM coding agents to
|
||||
change the system safely.
|
||||
- Stage and adapter boundaries match `docs/policy/architecture.md`.
|
||||
- Manifest, path, storage, and publish invariants are explicit and current.
|
||||
|
||||
### Stage 4: Integrations and Examples
|
||||
|
||||
Status: Completed (2026-05-23)
|
||||
|
||||
Review integration docs and maintained examples after core docs are rewritten.
|
||||
|
||||
Implementation requirements:
|
||||
|
||||
- Rewrite:
|
||||
- `docs/integrations/README.md`
|
||||
- `docs/integrations/audita.md`
|
||||
- `docs/integrations/scriptorium.md`
|
||||
- `docs/integrations/seriatim.md`
|
||||
- Verify integration docs against current adapter contracts and expected
|
||||
downstream tool behavior.
|
||||
- Confirm every referenced example file exists.
|
||||
- Confirm examples match current schema and command usage.
|
||||
- Run or rely on example validation tests.
|
||||
- Fix tests when they reference moved, renamed, or intentionally retired
|
||||
examples.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Integration docs describe only implemented adapter expectations.
|
||||
- Maintained examples are valid, secret-free, and linked from canonical docs.
|
||||
- Example validation tests reflect the documented example set.
|
||||
|
||||
### Stage 5: Roadmap Cleanup and Final Sweep
|
||||
|
||||
Status: Completed (2026-05-23)
|
||||
|
||||
Clean up roadmap state and run final documentation validation.
|
||||
|
||||
Implementation requirements:
|
||||
|
||||
- Review `docs/roadmap/**` for implemented items that should be marked
|
||||
implemented, retired, or left planned.
|
||||
- Keep historical and planned behavior in roadmaps only.
|
||||
- Run final link/path/term sweeps.
|
||||
- Run validation commands:
|
||||
- `go test ./internal/config -run TestExamplesLoadAndValidate -v`
|
||||
- `go test ./internal/app -run TestExecute -v`
|
||||
- `go test ./...`
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- All non-policy docs are current for the 1.0 release.
|
||||
- Roadmaps do not serve as required user/operator documentation.
|
||||
- Tests pass after allowed documentation-related test expectation fixes.
|
||||
|
||||
## Required Checks
|
||||
|
||||
Run searches for stale terminology and references during the pass.
|
||||
|
||||
Stale terminology:
|
||||
|
||||
- `archive`
|
||||
- `promote`
|
||||
- `promoted`
|
||||
- `promote_artifacts`
|
||||
- legacy campaign path/discovery language
|
||||
- old transcript names and paths
|
||||
- removed CLI commands or aliases
|
||||
|
||||
Broken or stale references:
|
||||
|
||||
- missing local doc links;
|
||||
- stale `examples/` paths;
|
||||
- stale internal doc filenames;
|
||||
- references to `docs/policy/**` as editable targets;
|
||||
- command examples that no longer match the CLI.
|
||||
|
||||
Policy checks:
|
||||
|
||||
- Current-behavior docs mention only implemented behavior.
|
||||
- Planned behavior appears only under `docs/roadmap/`.
|
||||
- Docs do not expose raw secrets or recommend storing secrets in config.
|
||||
- Docs use canonical homes:
|
||||
- `docs/config.md` for config schema;
|
||||
- `docs/cli.md` for command syntax;
|
||||
- `docs/operations.md` for operator workflows;
|
||||
- `docs/troubleshooting.md` for failure diagnosis;
|
||||
- `docs/internal/` for implementation contracts;
|
||||
- `docs/integrations/` for downstream tool integration notes;
|
||||
- `docs/roadmap/` for future work.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `docs/policy/**` is read-only for this documentation pass.
|
||||
- This pass is for 1.0 release readiness, not feature implementation.
|
||||
- Product and runtime code changes are out of scope.
|
||||
- Test fixes are in scope when they correct stale documentation, example, path,
|
||||
command, or current-behavior expectations uncovered during the review.
|
||||
- Roadmap files may remain as planning and historical records.
|
||||
- Current-behavior docs must be sufficient for normal use without requiring
|
||||
readers to consult roadmaps.
|
||||
@@ -217,6 +217,33 @@ Safe fix:
|
||||
- correct publish source/destination rules;
|
||||
- retry after storage failure is resolved.
|
||||
|
||||
## Render markdown source missing
|
||||
|
||||
Symptom:
|
||||
|
||||
- analyze or publish fails because `narratio.transcript.final_markdown` or `narratio.transcript.final_trimmed_markdown` is unavailable.
|
||||
|
||||
Likely causes:
|
||||
|
||||
- render stage was not executed after transcript changes;
|
||||
- render stage failed before producing canonical markdown outputs.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session status 2026-04-04
|
||||
narratio run-stage render 2026-04-04 --force
|
||||
```
|
||||
|
||||
Safe fix:
|
||||
|
||||
- rerun render and then retry downstream stage(s):
|
||||
|
||||
```bash
|
||||
narratio run-stage render 2026-04-04 --force
|
||||
narratio run-stage analyze 2026-04-04 --force
|
||||
```
|
||||
|
||||
## Secrets or storage credential failure
|
||||
|
||||
Symptom:
|
||||
|
||||
@@ -48,6 +48,12 @@ publish:
|
||||
- source: narratio.transcript.final_trimmed
|
||||
dest: transcripts/final.trimmed.json
|
||||
required: true
|
||||
- source: narratio.transcript.final_markdown
|
||||
dest: transcripts/final.md
|
||||
required: true
|
||||
- source: narratio.transcript.final_trimmed_markdown
|
||||
dest: transcripts/final.trimmed.md
|
||||
required: true
|
||||
- source: narratio.artifact.session_recap
|
||||
dest: artifacts/session_recap.md
|
||||
required: true
|
||||
|
||||
@@ -26,6 +26,12 @@ publish:
|
||||
- source: narratio.transcript.final_trimmed
|
||||
dest: transcripts/final.trimmed.json
|
||||
required: true
|
||||
- source: narratio.transcript.final_markdown
|
||||
dest: transcripts/final.md
|
||||
required: true
|
||||
- source: narratio.transcript.final_trimmed_markdown
|
||||
dest: transcripts/final.trimmed.md
|
||||
required: true
|
||||
- source: narratio.artifact.session_recap
|
||||
dest: artifacts/session_recap.md
|
||||
required: true
|
||||
|
||||
@@ -68,6 +68,26 @@ func (n *NoopRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Render returns the requested output path with placeholder metadata.
|
||||
func (n *NoopRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RenderResult{}, err
|
||||
}
|
||||
if err := materializeRenderPlaceholders(req); err != nil {
|
||||
return RenderResult{}, err
|
||||
}
|
||||
return RenderResult{
|
||||
OutputRenderedPath: req.OutputRenderedPath,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||
InvokedBinary: "noop",
|
||||
Format: req.Format,
|
||||
Title: req.Title,
|
||||
Metadata: map[string]any{"placeholder": true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FakeRunner captures merge requests and returns deterministic responses.
|
||||
type FakeRunner struct {
|
||||
Requests []MergeRequest
|
||||
@@ -79,6 +99,9 @@ type FakeRunner struct {
|
||||
TrimRequests []TrimRequest
|
||||
TrimErr error
|
||||
TrimResult TrimResult
|
||||
RenderRequests []RenderRequest
|
||||
RenderErr error
|
||||
RenderResult RenderResult
|
||||
}
|
||||
|
||||
// Run records request and returns configured response.
|
||||
@@ -195,6 +218,46 @@ func (f *FakeRunner) Normalize(ctx context.Context, req NormalizeRequest) (Norma
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Render records request and returns configured response.
|
||||
func (f *FakeRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RenderResult{}, err
|
||||
}
|
||||
f.RenderRequests = append(f.RenderRequests, req)
|
||||
if f.RenderErr != nil {
|
||||
return RenderResult{}, f.RenderErr
|
||||
}
|
||||
if err := materializeRenderPlaceholders(req); err != nil {
|
||||
return RenderResult{}, err
|
||||
}
|
||||
res := f.RenderResult
|
||||
if res.OutputRenderedPath == "" {
|
||||
res.OutputRenderedPath = req.OutputRenderedPath
|
||||
}
|
||||
if res.StdoutLogPath == "" {
|
||||
res.StdoutLogPath = req.StdoutLogPath
|
||||
}
|
||||
if res.StderrLogPath == "" {
|
||||
res.StderrLogPath = req.StderrLogPath
|
||||
}
|
||||
if res.GeneratedConfigPath == "" {
|
||||
res.GeneratedConfigPath = req.GeneratedConfigPath
|
||||
}
|
||||
if res.InvokedBinary == "" {
|
||||
res.InvokedBinary = "fake"
|
||||
}
|
||||
if res.Format == "" {
|
||||
res.Format = req.Format
|
||||
}
|
||||
if res.Title == "" {
|
||||
res.Title = req.Title
|
||||
}
|
||||
if res.Metadata == nil {
|
||||
res.Metadata = map[string]any{"fake": true}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func materializePlaceholders(req MergeRequest) error {
|
||||
if req.OutputMergedTranscriptPath != "" {
|
||||
if err := subprocess.WriteFileAtomic(req.OutputMergedTranscriptPath, []byte(`{"schema":"seriatim.intermediate.v1","segments":[]}`), 0o644); err != nil {
|
||||
@@ -301,3 +364,39 @@ func materializeNormalizePlaceholders(req NormalizeRequest) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func materializeRenderPlaceholders(req RenderRequest) error {
|
||||
if req.OutputRenderedPath != "" {
|
||||
if err := subprocess.WriteFileAtomic(req.OutputRenderedPath, []byte("# Transcript\n\nRendered markdown placeholder.\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write rendered transcript %q: %w", req.OutputRenderedPath, err)
|
||||
}
|
||||
}
|
||||
if req.GeneratedConfigPath != "" {
|
||||
payload := map[string]any{
|
||||
"schema": "seriatim.generated.v1",
|
||||
"placeholder": true,
|
||||
"command": "render",
|
||||
"input_path": req.InputTranscriptPath,
|
||||
"output_path": req.OutputRenderedPath,
|
||||
"format": req.Format,
|
||||
"title": req.Title,
|
||||
"include_timestamps": req.IncludeTimestamps,
|
||||
"include_segment_ids": req.IncludeSegmentIDs,
|
||||
"include_metadata": req.IncludeMetadata,
|
||||
}
|
||||
if err := subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644); err != nil {
|
||||
return fmt.Errorf("write generated config %q: %w", req.GeneratedConfigPath, err)
|
||||
}
|
||||
}
|
||||
if req.StdoutLogPath != "" {
|
||||
if err := subprocess.WriteFileAtomic(req.StdoutLogPath, []byte("seriatim noop/fake render stdout placeholder\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write stdout log %q: %w", req.StdoutLogPath, err)
|
||||
}
|
||||
}
|
||||
if req.StderrLogPath != "" {
|
||||
if err := subprocess.WriteFileAtomic(req.StderrLogPath, []byte("seriatim noop/fake render stderr placeholder\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write stderr log %q: %w", req.StderrLogPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -148,3 +148,58 @@ func TestFakeRunnerNormalizeError(t *testing.T) {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeRunnerRenderCapturesRequestAndReturnsPath(t *testing.T) {
|
||||
fake := &FakeRunner{}
|
||||
dir := t.TempDir()
|
||||
req := RenderRequest{
|
||||
GeneratedConfigPath: filepath.Join(dir, "config", "seriatim.render.yml"),
|
||||
InputTranscriptPath: filepath.Join(dir, "transcripts", "final.trimmed.json"),
|
||||
OutputRenderedPath: filepath.Join(dir, "transcripts", "final.trimmed.md"),
|
||||
Format: "markdown",
|
||||
Title: "Session render",
|
||||
IncludeTimestamps: true,
|
||||
IncludeSegmentIDs: false,
|
||||
IncludeMetadata: true,
|
||||
StdoutLogPath: filepath.Join(dir, "logs", "seriatim.render.stdout.log"),
|
||||
StderrLogPath: filepath.Join(dir, "logs", "seriatim.render.stderr.log"),
|
||||
}
|
||||
|
||||
res, err := fake.Render(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
if len(fake.RenderRequests) != 1 || fake.RenderRequests[0].GeneratedConfigPath == "" {
|
||||
t.Fatalf("render requests = %#v, want captured request", fake.RenderRequests)
|
||||
}
|
||||
if res.OutputRenderedPath != req.OutputRenderedPath {
|
||||
t.Fatalf("rendered path = %q, want %q", res.OutputRenderedPath, req.OutputRenderedPath)
|
||||
}
|
||||
if res.Format != req.Format {
|
||||
t.Fatalf("format = %q, want %q", res.Format, req.Format)
|
||||
}
|
||||
if res.Title != req.Title {
|
||||
t.Fatalf("title = %q, want %q", res.Title, req.Title)
|
||||
}
|
||||
|
||||
cfgData, err := os.ReadFile(req.GeneratedConfigPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read generated config: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(cfgData), "command: render") {
|
||||
t.Fatalf("generated config = %q, want render command marker", string(cfgData))
|
||||
}
|
||||
for _, path := range []string{req.StdoutLogPath, req.StderrLogPath, req.OutputRenderedPath} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected file %q to exist: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeRunnerRenderError(t *testing.T) {
|
||||
fake := &FakeRunner{RenderErr: errors.New("boom")}
|
||||
_, err := fake.Render(context.Background(), RenderRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package seriatim declares the adapter contract for transcript merge/normalize/trim execution.
|
||||
// Package seriatim declares the adapter contract for transcript merge/normalize/trim/render execution.
|
||||
package seriatim
|
||||
|
||||
import (
|
||||
@@ -6,11 +6,12 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Runner is the adapter boundary for seriatim merge/normalize/trim invocations.
|
||||
// Runner is the adapter boundary for seriatim merge/normalize/trim/render invocations.
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, req MergeRequest) (MergeResult, error)
|
||||
Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error)
|
||||
Trim(ctx context.Context, req TrimRequest) (TrimResult, error)
|
||||
Render(ctx context.Context, req RenderRequest) (RenderResult, error)
|
||||
}
|
||||
|
||||
// MergeRequest describes a seriatim merge invocation.
|
||||
@@ -90,3 +91,33 @@ type TrimResult struct {
|
||||
KeepSelector string
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
// RenderRequest describes a seriatim render invocation.
|
||||
type RenderRequest struct {
|
||||
Binary string
|
||||
InputTranscriptPath string
|
||||
OutputRenderedPath string
|
||||
Format string
|
||||
Title string
|
||||
IncludeTimestamps bool
|
||||
IncludeSegmentIDs bool
|
||||
IncludeMetadata bool
|
||||
StdoutLogPath string
|
||||
StderrLogPath string
|
||||
GeneratedConfigPath string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// RenderResult describes a render output.
|
||||
type RenderResult struct {
|
||||
OutputRenderedPath string
|
||||
StdoutLogPath string
|
||||
StderrLogPath string
|
||||
GeneratedConfigPath string
|
||||
ExitCode int
|
||||
Duration time.Duration
|
||||
InvokedBinary string
|
||||
Format string
|
||||
Title string
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||
)
|
||||
@@ -384,6 +385,96 @@ func (r *SubprocessRunner) Normalize(ctx context.Context, req NormalizeRequest)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Render executes Seriatim render with deterministic flags and validates non-empty text output.
|
||||
func (r *SubprocessRunner) Render(ctx context.Context, req RenderRequest) (RenderResult, error) {
|
||||
if r == nil {
|
||||
return RenderResult{}, fmt.Errorf("seriatim subprocess runner is nil")
|
||||
}
|
||||
if strings.TrimSpace(req.InputTranscriptPath) == "" {
|
||||
return RenderResult{}, fmt.Errorf("seriatim render input path is required")
|
||||
}
|
||||
if strings.TrimSpace(req.OutputRenderedPath) == "" {
|
||||
return RenderResult{}, fmt.Errorf("seriatim render output path is required")
|
||||
}
|
||||
format := strings.TrimSpace(req.Format)
|
||||
if format == "" {
|
||||
format = "markdown"
|
||||
}
|
||||
if format != "markdown" {
|
||||
return RenderResult{}, fmt.Errorf("seriatim render format %q is unsupported", req.Format)
|
||||
}
|
||||
|
||||
binary := r.binary
|
||||
if strings.TrimSpace(req.Binary) != "" {
|
||||
binary = strings.TrimSpace(req.Binary)
|
||||
}
|
||||
|
||||
timeout := r.timeout
|
||||
if req.Timeout < 0 {
|
||||
return RenderResult{}, fmt.Errorf("seriatim render timeout must be >= 0")
|
||||
}
|
||||
if req.Timeout > 0 {
|
||||
timeout = req.Timeout
|
||||
}
|
||||
|
||||
args := buildRenderArgs(req, format)
|
||||
if req.GeneratedConfigPath != "" {
|
||||
if err := writeRenderInvocationConfig(req, args, binary, timeout, format); err != nil {
|
||||
return RenderResult{}, fmt.Errorf("write seriatim render invocation config %q: %w", req.GeneratedConfigPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
runRes, err := subprocess.Run(ctx, subprocess.RunRequest{
|
||||
Executable: binary,
|
||||
Args: args,
|
||||
Timeout: timeout,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
})
|
||||
if err != nil {
|
||||
return RenderResult{
|
||||
OutputRenderedPath: req.OutputRenderedPath,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||
ExitCode: runRes.ExitCode,
|
||||
Duration: runRes.Duration,
|
||||
InvokedBinary: binary,
|
||||
Format: format,
|
||||
Title: req.Title,
|
||||
}, fmt.Errorf("run seriatim render (binary=%q): %w", binary, err)
|
||||
}
|
||||
|
||||
if err := validateNonEmptyTextFile(req.OutputRenderedPath); err != nil {
|
||||
return RenderResult{
|
||||
OutputRenderedPath: req.OutputRenderedPath,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||
ExitCode: runRes.ExitCode,
|
||||
Duration: runRes.Duration,
|
||||
InvokedBinary: binary,
|
||||
Format: format,
|
||||
Title: req.Title,
|
||||
}, fmt.Errorf("validate seriatim rendered output %q: %w", req.OutputRenderedPath, err)
|
||||
}
|
||||
|
||||
return RenderResult{
|
||||
OutputRenderedPath: req.OutputRenderedPath,
|
||||
StdoutLogPath: req.StdoutLogPath,
|
||||
StderrLogPath: req.StderrLogPath,
|
||||
GeneratedConfigPath: req.GeneratedConfigPath,
|
||||
ExitCode: runRes.ExitCode,
|
||||
Duration: runRes.Duration,
|
||||
InvokedBinary: binary,
|
||||
Format: format,
|
||||
Title: req.Title,
|
||||
Metadata: map[string]any{
|
||||
"adapter": "seriatim_subprocess",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *SubprocessRunner) buildMergeArgs(req MergeRequest) []string {
|
||||
args := []string{"merge"}
|
||||
|
||||
@@ -480,6 +571,22 @@ func buildNormalizeArgs(req NormalizeRequest, outputSchema string) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func buildRenderArgs(req RenderRequest, format string) []string {
|
||||
args := []string{
|
||||
"render",
|
||||
"--input-file", req.InputTranscriptPath,
|
||||
"--output-file", req.OutputRenderedPath,
|
||||
"--format", format,
|
||||
"--include-timestamps", strconv.FormatBool(req.IncludeTimestamps),
|
||||
"--include-segment-ids", strconv.FormatBool(req.IncludeSegmentIDs),
|
||||
"--include-metadata", strconv.FormatBool(req.IncludeMetadata),
|
||||
}
|
||||
if strings.TrimSpace(req.Title) != "" {
|
||||
args = append(args, "--title", req.Title)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func writeTrimInvocationConfig(req TrimRequest, args []string, binary string, timeout time.Duration) error {
|
||||
payload := map[string]any{
|
||||
"schema": "seriatim.generated.v1",
|
||||
@@ -509,6 +616,24 @@ func writeNormalizeInvocationConfig(req NormalizeRequest, args []string, binary
|
||||
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
|
||||
}
|
||||
|
||||
func writeRenderInvocationConfig(req RenderRequest, args []string, binary string, timeout time.Duration, format string) error {
|
||||
payload := map[string]any{
|
||||
"schema": "seriatim.generated.v1",
|
||||
"command": "render",
|
||||
"binary": binary,
|
||||
"args": args,
|
||||
"timeout": timeout.String(),
|
||||
"input_path": req.InputTranscriptPath,
|
||||
"output_path": req.OutputRenderedPath,
|
||||
"format": format,
|
||||
"title": req.Title,
|
||||
"include_timestamps": req.IncludeTimestamps,
|
||||
"include_segment_ids": req.IncludeSegmentIDs,
|
||||
"include_metadata": req.IncludeMetadata,
|
||||
}
|
||||
return subprocess.WriteYAMLAtomic(req.GeneratedConfigPath, payload, 0o644)
|
||||
}
|
||||
|
||||
func validateJSONFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -541,3 +666,20 @@ func validateJSONFileWithSegments(path string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNonEmptyTextFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return fmt.Errorf("file is empty")
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return fmt.Errorf("file is not valid utf-8 text")
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return fmt.Errorf("file has no non-whitespace content")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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", "false",
|
||||
"--include-metadata", "true",
|
||||
"--title", req.Title,
|
||||
}
|
||||
if strings.Join(rec.Args, "\n") != strings.Join(wantArgs, "\n") {
|
||||
t.Fatalf("args = %#v, want %#v", rec.Args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRenderWithoutTitleOmitsTitleArg(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("helper wrapper script uses /bin/sh")
|
||||
}
|
||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||
t.Setenv("SERIATIM_HELPER_MODE", "render_success")
|
||||
recordPath := filepath.Join(t.TempDir(), "record.json")
|
||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", recordPath)
|
||||
|
||||
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||
req := renderReqForTest(t)
|
||||
req.Title = ""
|
||||
if _, err := runner.Render(context.Background(), req); err != nil {
|
||||
t.Fatalf("Render() error = %v", err)
|
||||
}
|
||||
|
||||
rec := readHelperRecord(t, recordPath)
|
||||
for i := 0; i < len(rec.Args); i++ {
|
||||
if rec.Args[i] == "--title" {
|
||||
t.Fatalf("args = %#v, did not expect --title", rec.Args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRenderSubprocessFailure(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("helper wrapper script uses /bin/sh")
|
||||
}
|
||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||
t.Setenv("SERIATIM_HELPER_MODE", "fail")
|
||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||
|
||||
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||
req := renderReqForTest(t)
|
||||
_, err := runner.Render(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Render() error = nil, want non-nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "run seriatim render") {
|
||||
t.Fatalf("error = %q, want subprocess context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRenderMissingOutputFails(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("helper wrapper script uses /bin/sh")
|
||||
}
|
||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||
t.Setenv("SERIATIM_HELPER_MODE", "missing_output")
|
||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||
|
||||
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||
req := renderReqForTest(t)
|
||||
_, err := runner.Render(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Render() error = nil, want non-nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "validate seriatim rendered output") {
|
||||
t.Fatalf("error = %q, want output validation context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRenderEmptyOutputFails(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("helper wrapper script uses /bin/sh")
|
||||
}
|
||||
t.Setenv("GO_WANT_SERIATIM_HELPER", "1")
|
||||
t.Setenv("SERIATIM_HELPER_MODE", "render_empty_output")
|
||||
t.Setenv("SERIATIM_HELPER_RECORD_PATH", filepath.Join(t.TempDir(), "record.json"))
|
||||
|
||||
runner := mustRunner(t, writeHelperWrapper(t), false)
|
||||
req := renderReqForTest(t)
|
||||
_, err := runner.Render(context.Background(), req)
|
||||
if err == nil {
|
||||
t.Fatal("Render() error = nil, want non-nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file is empty") {
|
||||
t.Fatalf("error = %q, want empty-file validation", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerConstructorValidation(t *testing.T) {
|
||||
_, err := NewSubprocessRunnerFromConfigValues("", "10m", "seriatim-intermediate", nil, true, EnvConfig{})
|
||||
if err == nil {
|
||||
@@ -702,6 +852,14 @@ func TestSeriatimSubprocessHelper(t *testing.T) {
|
||||
case "normalize_report_missing":
|
||||
writeSeriatimHelperFile(outputPath, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
|
||||
os.Exit(0)
|
||||
case "render_success":
|
||||
writeSeriatimHelperFile(outputPath, "# Rendered transcript\n\nHello.\n")
|
||||
_, _ = os.Stdout.WriteString("seriatim helper render stdout\n")
|
||||
_, _ = os.Stderr.WriteString("seriatim helper render stderr\n")
|
||||
os.Exit(0)
|
||||
case "render_empty_output":
|
||||
writeSeriatimHelperFile(outputPath, "")
|
||||
os.Exit(0)
|
||||
default:
|
||||
_, _ = os.Stderr.WriteString(fmt.Sprintf("unknown helper mode %q\n", mode))
|
||||
os.Exit(2)
|
||||
@@ -777,6 +935,25 @@ func normalizeReqForTest(t *testing.T, withReport bool) NormalizeRequest {
|
||||
return req
|
||||
}
|
||||
|
||||
func renderReqForTest(t *testing.T) RenderRequest {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
input := filepath.Join(dir, "final.trimmed.json")
|
||||
writeSeriatimFile(t, input, `{"schema":"seriatim.intermediate.v1","segments":[]}`)
|
||||
return RenderRequest{
|
||||
InputTranscriptPath: input,
|
||||
OutputRenderedPath: filepath.Join(dir, "final.trimmed.md"),
|
||||
Format: "markdown",
|
||||
Title: "Session 42",
|
||||
IncludeTimestamps: true,
|
||||
IncludeSegmentIDs: false,
|
||||
IncludeMetadata: true,
|
||||
GeneratedConfigPath: filepath.Join(dir, "seriatim.render.generated.yml"),
|
||||
StdoutLogPath: filepath.Join(dir, "seriatim.render.stdout.log"),
|
||||
StderrLogPath: filepath.Join(dir, "seriatim.render.stderr.log"),
|
||||
}
|
||||
}
|
||||
|
||||
func mustRunner(t *testing.T, binary string, report bool) *SubprocessRunner {
|
||||
t.Helper()
|
||||
coalesce := 3.0
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
// Package storage declares archive/storage backend adapter boundaries.
|
||||
package storage
|
||||
|
||||
import "context"
|
||||
|
||||
// TODO: implement remote storage/archive backends (S3/SFTP/etc.).
|
||||
|
||||
// Backend is the adapter boundary for archive/storage operations.
|
||||
type Backend interface {
|
||||
Archive(ctx context.Context, req ArchiveRequest) (ArchiveResult, error)
|
||||
}
|
||||
|
||||
// ArchiveItem describes one item to archive.
|
||||
type ArchiveItem struct {
|
||||
Kind string
|
||||
LocalPath string
|
||||
RemoteKey string
|
||||
}
|
||||
|
||||
// ArchiveRequest describes one archive operation.
|
||||
type ArchiveRequest struct {
|
||||
SessionID string
|
||||
ManifestPath string
|
||||
Items []ArchiveItem
|
||||
}
|
||||
|
||||
// ArchiveResult describes archive operation output.
|
||||
type ArchiveResult struct {
|
||||
Archived []ArchiveItem
|
||||
Metadata map[string]any
|
||||
}
|
||||
@@ -10,23 +10,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// NoopBackend is a deterministic no-op archive/storage adapter.
|
||||
type NoopBackend struct{}
|
||||
|
||||
// Archive returns the requested items as archived with placeholder metadata.
|
||||
func (n *NoopBackend) Archive(ctx context.Context, req ArchiveRequest) (ArchiveResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ArchiveResult{}, err
|
||||
}
|
||||
return ArchiveResult{Archived: append([]ArchiveItem(nil), req.Items...), Metadata: map[string]any{"placeholder": true}}, nil
|
||||
}
|
||||
|
||||
// FakeBackend captures archive requests and returns deterministic responses.
|
||||
// FakeBackend provides a deterministic in-memory object store for tests.
|
||||
type FakeBackend struct {
|
||||
Requests []ArchiveRequest
|
||||
Err error
|
||||
Result ArchiveResult
|
||||
|
||||
Objects map[string]FakeObject
|
||||
Uploads []FakeUploadCall
|
||||
Downloads []FakeDownloadCall
|
||||
@@ -50,25 +35,6 @@ type FakeDownloadCall struct {
|
||||
LocalPath string
|
||||
}
|
||||
|
||||
// Archive records request and returns configured response.
|
||||
func (f *FakeBackend) Archive(ctx context.Context, req ArchiveRequest) (ArchiveResult, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ArchiveResult{}, err
|
||||
}
|
||||
f.Requests = append(f.Requests, req)
|
||||
if f.Err != nil {
|
||||
return ArchiveResult{}, f.Err
|
||||
}
|
||||
res := f.Result
|
||||
if res.Archived == nil {
|
||||
res.Archived = append([]ArchiveItem(nil), req.Items...)
|
||||
}
|
||||
if res.Metadata == nil {
|
||||
res.Metadata = map[string]any{"fake": true}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// FakeObject is a deterministic fake object-store record.
|
||||
type FakeObject struct {
|
||||
Key string
|
||||
|
||||
@@ -9,30 +9,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFakeBackendCapturesRequestAndReturnsItems(t *testing.T) {
|
||||
fake := &FakeBackend{}
|
||||
req := ArchiveRequest{SessionID: "s1", Items: []ArchiveItem{{Kind: "artifact", LocalPath: "artifacts/log.md"}}}
|
||||
|
||||
res, err := fake.Archive(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Archive() error = %v", err)
|
||||
}
|
||||
if len(fake.Requests) != 1 || fake.Requests[0].SessionID != "s1" {
|
||||
t.Fatalf("requests = %#v, want captured request", fake.Requests)
|
||||
}
|
||||
if len(res.Archived) != 1 {
|
||||
t.Fatalf("archived len = %d, want 1", len(res.Archived))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeBackendError(t *testing.T) {
|
||||
fake := &FakeBackend{Err: errors.New("boom")}
|
||||
_, err := fake.Archive(context.Background(), ArchiveRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeBackendListPrefixFiltering(t *testing.T) {
|
||||
fake := &FakeBackend{}
|
||||
fake.SeedObject(FakeObject{Key: "dnd/campaigns/forsaken/audio/a.flac", Data: []byte("a")})
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ObjectStore is a remote object storage boundary used by future prepare/archive work.
|
||||
// ObjectStore is a remote object storage boundary used by prepare, restore, and publish work.
|
||||
//
|
||||
// Key invariant:
|
||||
// callers pass full bucket-relative object keys. Backend implementations do not
|
||||
|
||||
@@ -145,7 +145,7 @@ func TestHTTPClientDoesNotRetryOnNonRetryableStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientInvalidJSONFailsAndDoesNotPromote(t *testing.T) {
|
||||
func TestHTTPClientInvalidJSONFailsAndDoesNotInstallOutput(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`not-json`))
|
||||
}))
|
||||
|
||||
@@ -120,14 +120,14 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
||||
func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||
@@ -135,16 +135,16 @@ func TestResumeArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Resume(
|
||||
err := Run(
|
||||
context.Background(),
|
||||
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
&out,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume() error = %v", err)
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "has no remaining stages") {
|
||||
t.Fatalf("output = %q, want no remaining stages", out.String())
|
||||
if !strings.Contains(out.String(), "executed=0 skipped=10") {
|
||||
t.Fatalf("output = %q, want all stages skipped", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var supportedCommands = []string{"run", "run-stage", "resume", "analyze", "publish", "clean", "session"}
|
||||
var supportedCommands = []string{"run", "run-stage", "analyze", "publish", "clean", "session"}
|
||||
|
||||
// Execute dispatches CLI commands and returns a process exit code.
|
||||
func Execute(args []string, stdout, stderr io.Writer) int {
|
||||
@@ -24,8 +24,6 @@ func Execute(args []string, stdout, stderr io.Writer) int {
|
||||
switch cmd {
|
||||
case "run":
|
||||
err = Run(ctx, cmdArgs, stdout)
|
||||
case "resume":
|
||||
err = Resume(ctx, cmdArgs, stdout)
|
||||
case "run-stage":
|
||||
err = RunStage(ctx, cmdArgs, stdout)
|
||||
case "analyze":
|
||||
|
||||
@@ -31,10 +31,9 @@ func TestExecuteValidCommands(t *testing.T) {
|
||||
args []string
|
||||
wantOut string
|
||||
}{
|
||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=9 skipped=0; manifest="},
|
||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
|
||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=10 skipped=0; manifest="},
|
||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nrender: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
|
||||
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
||||
{name: "resume", args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio resume: session 2026-05-03 has no remaining stages"},
|
||||
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
||||
}
|
||||
|
||||
@@ -66,7 +65,7 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
||||
{name: "run missing session", args: []string{"run"}, want: "run: session_id is required"},
|
||||
{name: "plan old top-level removed", args: []string{"plan"}, want: `unknown command: "plan"`},
|
||||
{name: "status old top-level removed", args: []string{"status"}, want: `unknown command: "status"`},
|
||||
{name: "resume missing session", args: []string{"resume"}, want: "resume: session_id is required"},
|
||||
{name: "resume removed", args: []string{"resume"}, want: `unknown command: "resume"`},
|
||||
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected stage name and session_id"},
|
||||
{name: "run-stage missing session", args: []string{"run-stage", "polish"}, want: "run-stage: expected stage name and session_id"},
|
||||
{name: "run missing config uses defaults", args: []string{"run", "2026-05-03", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||
@@ -332,7 +331,7 @@ func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=9 skipped=0; manifest=") {
|
||||
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=10 skipped=0; manifest=") {
|
||||
t.Fatalf("stdout = %q, want successful run output", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
@@ -59,7 +58,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
if err != nil {
|
||||
return nil, missingSessionConfigError(discoveredSession.Searched, err.Error())
|
||||
}
|
||||
sessionTempPath, err := downloadRemoteSessionConfig(ctx, store, remoteKey)
|
||||
sessionTempPath, err := storage.DownloadObjectToTemp(ctx, store, remoteKey, "narratio-session-*.yml")
|
||||
if err != nil {
|
||||
return nil, missingSessionConfigError(discoveredSession.Searched, fmt.Sprintf("remote session %q download failed: %v", remoteKey, err))
|
||||
}
|
||||
@@ -134,21 +133,6 @@ func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, ses
|
||||
return storage.ObjectInfo{}, fmt.Errorf("remote session %q not found", remoteKey)
|
||||
}
|
||||
|
||||
func downloadRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, remoteKey string) (string, error) {
|
||||
f, err := os.CreateTemp("", "narratio-session-*.yml")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
path := f.Name()
|
||||
if err := f.Close(); err != nil {
|
||||
return "", fmt.Errorf("close temp file %q: %w", path, err)
|
||||
}
|
||||
if err := store.Download(ctx, remoteKey, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(path), nil
|
||||
}
|
||||
|
||||
func s3BucketName(cfg *config.PipelineConfig) string {
|
||||
if cfg == nil || cfg.Storage.S3 == nil {
|
||||
return ""
|
||||
|
||||
@@ -32,15 +32,10 @@ func buildHelperArtifactCatalog(cfg *config.Config) (*artifacts.ArtifactCatalog,
|
||||
func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.ArtifactCatalog, locks *effectiveLocks, publishedRemoteState map[string]string) {
|
||||
lockSet := lockSourceSet(locks.All)
|
||||
fmt.Fprintln(out, "Built-in:")
|
||||
for _, id := range []string{
|
||||
artifacts.ArtifactTranscriptBase,
|
||||
artifacts.ArtifactTranscriptPolished,
|
||||
artifacts.ArtifactTranscriptFinal,
|
||||
artifacts.ArtifactTranscriptFinalTrimmed,
|
||||
artifacts.ArtifactBoundsSession,
|
||||
} {
|
||||
writeArtifactLine(out, id, lockSet)
|
||||
for _, transcript := range artifacts.RuntimeTranscriptArtifacts() {
|
||||
writeArtifactLine(out, transcript.SourceID, lockSet)
|
||||
}
|
||||
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
||||
fmt.Fprintln(out, "Configured:")
|
||||
for _, entry := range catalog.ListConfigured() {
|
||||
writeArtifactLine(out, entry.SourceID, lockSet)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
@@ -64,26 +63,18 @@ func sessionSourceSummary(cfg *config.Config) string {
|
||||
}
|
||||
|
||||
func validateStableInputFindings(cfg *config.Config) []finding {
|
||||
items := []struct {
|
||||
name string
|
||||
in config.ResolvedInputFile
|
||||
}{
|
||||
{"speakers", cfg.StableInputs.SpeakersFile},
|
||||
{"autocorrect", cfg.StableInputs.AutocorrectFile},
|
||||
{"glossary", cfg.StableInputs.GlossaryFile},
|
||||
}
|
||||
out := make([]finding, 0, len(items))
|
||||
for _, item := range items {
|
||||
path, err := resolveHelperConfigRelativePath(item.in)
|
||||
if err != nil {
|
||||
out = append(out, errorFinding("inputs", item.name+": "+err.Error()))
|
||||
checks := inspectStableInputs(cfg)
|
||||
out := make([]finding, 0, len(checks))
|
||||
for _, check := range checks {
|
||||
if check.Err != nil {
|
||||
msg := check.Name + ": " + check.Err.Error()
|
||||
if strings.TrimSpace(check.Path) != "" {
|
||||
msg = fmt.Sprintf("%s missing: %v", check.Name, check.Err)
|
||||
}
|
||||
out = append(out, errorFinding("inputs", msg))
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
out = append(out, errorFinding("inputs", fmt.Sprintf("%s missing: %v", item.name, err)))
|
||||
} else {
|
||||
out = append(out, okFinding("inputs", item.name+": "+path))
|
||||
}
|
||||
out = append(out, okFinding("inputs", check.Name+": "+check.Path))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -103,76 +94,22 @@ func resolveHelperConfigRelativePath(input config.ResolvedInputFile) (string, er
|
||||
}
|
||||
|
||||
func validateLocalAudioFindings(cfg *config.Config) []finding {
|
||||
if cfg.Session.Inputs.AudioS3 != nil {
|
||||
check := inspectLocalAudioPresence(cfg)
|
||||
if !check.Checked {
|
||||
return nil
|
||||
}
|
||||
audioDir := strings.TrimSpace(cfg.Session.Inputs.AudioDir)
|
||||
if audioDir == "" && len(cfg.Session.Inputs.AudioFiles) == 0 {
|
||||
return []finding{errorFinding("audio", "audio_dir, audio_files, or audio_s3 is required")}
|
||||
if check.Err != nil {
|
||||
return []finding{errorFinding("audio", check.Err.Error())}
|
||||
}
|
||||
base := filepath.Dir(cfg.SessionPath)
|
||||
paths := []string{}
|
||||
if audioDir != "" {
|
||||
dir := audioDir
|
||||
if !filepath.IsAbs(dir) {
|
||||
dir = filepath.Join(base, dir)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "*.flac"))
|
||||
if err != nil || len(matches) == 0 {
|
||||
return []finding{errorFinding("audio", "no .flac files found in "+dir)}
|
||||
}
|
||||
paths = append(paths, matches...)
|
||||
}
|
||||
for _, file := range cfg.Session.Inputs.AudioFiles {
|
||||
p := file
|
||||
if !filepath.IsAbs(p) {
|
||||
p = filepath.Join(base, p)
|
||||
}
|
||||
paths = append(paths, p)
|
||||
}
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return []finding{errorFinding("audio", fmt.Sprintf("audio file missing: %v", err))}
|
||||
}
|
||||
}
|
||||
return []finding{okFinding("audio", fmt.Sprintf("%d local audio file(s)", len(paths)))}
|
||||
return []finding{okFinding("audio", fmt.Sprintf("%d local audio file(s)", len(check.Paths)))}
|
||||
}
|
||||
|
||||
func validateRemoteAudioFinding(ctx context.Context, cfg *config.Config, store storage.ObjectStore) finding {
|
||||
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, cfg.Session.Inputs.AudioS3.Prefix)
|
||||
objects, err := store.List(ctx, audioPrefix)
|
||||
if err != nil {
|
||||
return errorFinding("audio", err.Error())
|
||||
check := inspectRemoteAudioPresence(ctx, cfg, store)
|
||||
if check.Err != nil {
|
||||
return errorFinding("audio", check.Err.Error())
|
||||
}
|
||||
count := 0
|
||||
for _, obj := range objects {
|
||||
if strings.HasSuffix(strings.ToLower(obj.Key), ".flac") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return errorFinding("audio", "no remote .flac objects found under "+audioPrefix)
|
||||
}
|
||||
return okFinding("audio", fmt.Sprintf("%d remote .flac object(s)", count))
|
||||
}
|
||||
|
||||
func validatePreviousArtifactFindings(ctx context.Context, cfg *config.Config, store storage.ObjectStore, requirements []artifacts.PreviousArtifactRequirement) []finding {
|
||||
out := []finding{}
|
||||
prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
||||
_, err := artifacts.LoadCurrentState(ctx, store, prefix, artifacts.CurrentStateValidation{
|
||||
ExpectedSessionID: strings.TrimSpace(cfg.Session.PreviousSessionID),
|
||||
ExpectedCampaign: strings.TrimSpace(cfg.Session.Campaign),
|
||||
ValidateRunID: true,
|
||||
})
|
||||
if err != nil {
|
||||
out = append(out, errorFinding("previous", fmt.Sprintf("remote %v", err)))
|
||||
return out
|
||||
}
|
||||
for _, req := range requirements {
|
||||
out = append(out, okFinding("previous", fmt.Sprintf("%s required=%t", req.Name, req.Required)))
|
||||
}
|
||||
return out
|
||||
return okFinding("audio", fmt.Sprintf("%d remote .flac object(s)", len(check.Keys)))
|
||||
}
|
||||
|
||||
func loadLocalManifest(ctx context.Context, path string) (*manifest.Manifest, error) {
|
||||
|
||||
@@ -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) {
|
||||
workspaceRoot := t.TempDir()
|
||||
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) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||
@@ -925,11 +997,13 @@ func TestExecutePublishLoadsRemoteLocks(t *testing.T) {
|
||||
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||
|
||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||
// The publish stage only checks the manifest statuses and source files.
|
||||
_ = stageName
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[]}`)
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.md"), "# final\n")
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.md"), "# final trimmed\n")
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
@@ -993,7 +1067,7 @@ func writeValidPublishRunConfigFiles(t *testing.T, workspaceRoot string) (string
|
||||
m := manifest.New("2026-05-03", nowUTC())
|
||||
m.Campaign = "sample-campaign"
|
||||
m.RunID = "20260521T160000Z-test"
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||
m.MarkStageSucceeded(name, nowUTC(), nil)
|
||||
}
|
||||
path := artifacts.SessionManifestPathForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
|
||||
274
internal/app/operator_inspection.go
Normal file
274
internal/app/operator_inspection.go
Normal 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")
|
||||
}
|
||||
@@ -48,13 +48,6 @@ func LocksList(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
// LocksAdd adds or updates one remote lock.
|
||||
func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
|
||||
var positionalSessionID string
|
||||
var source string
|
||||
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
|
||||
positionalSessionID = strings.TrimSpace(args[0])
|
||||
source = strings.TrimSpace(args[1])
|
||||
args = append([]string(nil), args[2:]...)
|
||||
}
|
||||
fs := flag.NewFlagSet("locks add", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var flags commonConfigFlags
|
||||
@@ -63,26 +56,8 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
|
||||
addCommonConfigFlags(fs, &flags)
|
||||
fs.StringVar(&reason, "reason", "", "lock reason")
|
||||
fs.BoolVar(&force, "force", false, "update existing remote lock")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return fmt.Errorf("locks add: invalid flags: %w", err)
|
||||
}
|
||||
if source == "" {
|
||||
switch fs.NArg() {
|
||||
case 2:
|
||||
positionalSessionID = strings.TrimSpace(fs.Arg(0))
|
||||
source = strings.TrimSpace(fs.Arg(1))
|
||||
case 1:
|
||||
if strings.TrimSpace(flags.sessionID) == "" {
|
||||
return fmt.Errorf("locks add: expected session_id and source id")
|
||||
}
|
||||
source = strings.TrimSpace(fs.Arg(0))
|
||||
default:
|
||||
return fmt.Errorf("locks add: expected session_id and source id")
|
||||
}
|
||||
} else if fs.NArg() != 0 {
|
||||
return fmt.Errorf("locks add: unexpected positional arguments")
|
||||
}
|
||||
if err := applyPositionalSessionID("locks add", positionalSessionID, &flags.sessionID); err != nil {
|
||||
source, err := parseSessionIDAndOnePositionalArg("locks add", "source id", fs, args, &flags.sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(flags.sessionID) == "" {
|
||||
@@ -116,37 +91,12 @@ func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
// LocksRemove removes one remote lock.
|
||||
func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
|
||||
var positionalSessionID string
|
||||
var source string
|
||||
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
|
||||
positionalSessionID = strings.TrimSpace(args[0])
|
||||
source = strings.TrimSpace(args[1])
|
||||
args = append([]string(nil), args[2:]...)
|
||||
}
|
||||
fs := flag.NewFlagSet("locks remove", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var flags commonConfigFlags
|
||||
addCommonConfigFlags(fs, &flags)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return fmt.Errorf("locks remove: invalid flags: %w", err)
|
||||
}
|
||||
if source == "" {
|
||||
switch fs.NArg() {
|
||||
case 2:
|
||||
positionalSessionID = strings.TrimSpace(fs.Arg(0))
|
||||
source = strings.TrimSpace(fs.Arg(1))
|
||||
case 1:
|
||||
if strings.TrimSpace(flags.sessionID) == "" {
|
||||
return fmt.Errorf("locks remove: expected session_id and source id")
|
||||
}
|
||||
source = strings.TrimSpace(fs.Arg(0))
|
||||
default:
|
||||
return fmt.Errorf("locks remove: expected session_id and source id")
|
||||
}
|
||||
} else if fs.NArg() != 0 {
|
||||
return fmt.Errorf("locks remove: unexpected positional arguments")
|
||||
}
|
||||
if err := applyPositionalSessionID("locks remove", positionalSessionID, &flags.sessionID); err != nil {
|
||||
source, err := parseSessionIDAndOnePositionalArg("locks remove", "source id", fs, args, &flags.sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(flags.sessionID) == "" {
|
||||
|
||||
@@ -54,23 +54,26 @@ func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
|
||||
}
|
||||
|
||||
requirements := artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg))
|
||||
if len(requirements) == 0 {
|
||||
previous := inspectPreviousArtifactReadiness(ctx, cfg, store, requirements)
|
||||
if len(previous.Requirements) == 0 {
|
||||
findings = append(findings, okFinding("previous", "no previous-session artifacts required"))
|
||||
} else if strings.TrimSpace(cfg.Session.PreviousSessionID) == "" {
|
||||
} else if previous.MissingID {
|
||||
findings = append(findings, errorFinding("previous", "previous_session_id is required by configured previous-session artifacts"))
|
||||
} else if storeErr != nil {
|
||||
findings = append(findings, errorFinding("previous", "previous-session artifacts cannot be checked because storage is unavailable"))
|
||||
} else if previous.Err != nil {
|
||||
findings = append(findings, errorFinding("previous", previous.Err.Error()))
|
||||
} else {
|
||||
findings = append(findings, validatePreviousArtifactFindings(ctx, cfg, store, requirements)...)
|
||||
for _, req := range previous.Requirements {
|
||||
findings = append(findings, okFinding("previous", fmt.Sprintf("%s required=%t", req.Name, req.Required)))
|
||||
}
|
||||
}
|
||||
|
||||
locks, lockErr := loadEffectiveLocks(ctx, cfg, store)
|
||||
if lockErr != nil {
|
||||
findings = append(findings, errorFinding("locks", lockErr.Error()))
|
||||
} else if len(locks.All) == 0 {
|
||||
locks := inspectEffectiveLocks(ctx, cfg, store)
|
||||
if locks.Err != nil {
|
||||
findings = append(findings, errorFinding("locks", locks.Err.Error()))
|
||||
} else if len(locks.Locks.All) == 0 {
|
||||
findings = append(findings, okFinding("locks", "no effective publish locks"))
|
||||
} else {
|
||||
for _, lock := range locks.All {
|
||||
for _, lock := range locks.Locks.All {
|
||||
findings = append(findings, warnFinding("locks", fmt.Sprintf("%s locked: %s", lock.Source, strings.TrimSpace(lock.Reason))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
@@ -35,6 +37,8 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
||||
fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign)
|
||||
fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
|
||||
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
|
||||
writeStatusStableInputs(out, inspectStableInputs(cfg))
|
||||
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
|
||||
|
||||
if m, err := loadLocalManifest(ctx, paths.ManifestPath); err != nil {
|
||||
fmt.Fprintf(out, "Local manifest: error: %v\n", err)
|
||||
@@ -49,21 +53,30 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
||||
if storeErr != nil {
|
||||
fmt.Fprintf(out, "Remote publish: unavailable: %v\n", storeErr)
|
||||
} else if store != nil {
|
||||
current, err := discoverRemoteCurrentStateFn(ctx, cfg, store)
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", err)
|
||||
current := inspectRemoteCurrentState(ctx, cfg, store)
|
||||
if current.Err != nil {
|
||||
fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", current.Err)
|
||||
} else {
|
||||
fmt.Fprintf(out, "Remote publish: current run %s\n", current.RunID)
|
||||
fmt.Fprintf(out, "Remote manifest: %s\n", current.CurrentManifestKey)
|
||||
fmt.Fprintf(out, "Remote publish: current run %s\n", current.State.RunID)
|
||||
fmt.Fprintf(out, "Remote manifest: %s\n", current.State.CurrentManifestKey)
|
||||
}
|
||||
}
|
||||
writeStatusRemoteAudio(ctx, out, cfg, store, storeErr)
|
||||
writeStatusPreviousArtifacts(out, inspectPreviousArtifactReadiness(
|
||||
ctx,
|
||||
cfg,
|
||||
store,
|
||||
artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)),
|
||||
))
|
||||
|
||||
locks, err := loadEffectiveLocks(ctx, cfg, store)
|
||||
lockChecks := inspectEffectiveLocks(ctx, cfg, store)
|
||||
locks := lockChecks.Locks
|
||||
lockErr := lockChecks.Err
|
||||
if catalog, catalogErr := buildHelperArtifactCatalog(cfg); catalogErr != nil {
|
||||
fmt.Fprintf(out, "Remote outputs: error: %v\n", catalogErr)
|
||||
} else if storeErr == nil {
|
||||
catalogLocks := locks
|
||||
if err != nil {
|
||||
if lockErr != nil {
|
||||
catalogLocks = &effectiveLocks{
|
||||
Static: staticPublishLocks(cfg),
|
||||
All: staticPublishLocks(cfg),
|
||||
@@ -76,8 +89,8 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
||||
fmt.Fprintln(out, "Remote outputs:")
|
||||
writeArtifactList(out, cfg, catalog, catalogLocks, publishedRemoteState)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(out, "Publish locks: error: %v\n", err)
|
||||
if lockErr != nil {
|
||||
fmt.Fprintf(out, "Publish locks: error: %v\n", lockErr)
|
||||
} else {
|
||||
writeLocks(out, cfg, locks)
|
||||
}
|
||||
@@ -86,3 +99,68 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
||||
fmt.Fprintf(out, "- narratio session restore %s --dry-run\n", cfg.Session.SessionID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeStatusStableInputs(out io.Writer, checks []stableInputCheck) {
|
||||
if len(checks) == 0 {
|
||||
return
|
||||
}
|
||||
for _, check := range checks {
|
||||
if check.Err != nil {
|
||||
if strings.TrimSpace(check.Path) != "" {
|
||||
fmt.Fprintf(out, "Stable input %s: unavailable: %v\n", check.Name, check.Err)
|
||||
} else {
|
||||
fmt.Fprintf(out, "Stable input %s: unavailable: %s\n", check.Name, check.Err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(out, "Stable input %s: %s\n", check.Name, check.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func writeStatusLocalAudio(out io.Writer, check localAudioCheck) {
|
||||
if !check.Checked {
|
||||
return
|
||||
}
|
||||
if check.Err != nil {
|
||||
fmt.Fprintf(out, "Local audio: unavailable: %v\n", check.Err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, "Local audio: %d file(s)\n", len(check.Paths))
|
||||
}
|
||||
|
||||
func writeStatusRemoteAudio(ctx context.Context, out io.Writer, cfg *config.Config, store storage.ObjectStore, storeErr error) {
|
||||
if cfg.Session.Inputs.AudioS3 == nil {
|
||||
return
|
||||
}
|
||||
if storeErr != nil {
|
||||
fmt.Fprintf(out, "Remote audio: unavailable: %v\n", storeErr)
|
||||
return
|
||||
}
|
||||
check := inspectRemoteAudioPresence(ctx, cfg, store)
|
||||
if check.Err != nil {
|
||||
fmt.Fprintf(out, "Remote audio: unavailable: %v\n", check.Err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, "Remote audio: %d .flac object(s)\n", len(check.Keys))
|
||||
}
|
||||
|
||||
func writeStatusPreviousArtifacts(out io.Writer, readiness previousArtifactReadiness) {
|
||||
if len(readiness.Requirements) == 0 {
|
||||
fmt.Fprintln(out, "Previous-session artifacts: not required")
|
||||
return
|
||||
}
|
||||
if readiness.MissingID {
|
||||
fmt.Fprintln(out, "Previous-session artifacts: unavailable: previous_session_id is required by configured previous-session artifacts")
|
||||
return
|
||||
}
|
||||
if readiness.Err != nil {
|
||||
fmt.Fprintf(out, "Previous-session artifacts: unavailable: %v\n", readiness.Err)
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(readiness.Requirements))
|
||||
for _, req := range readiness.Requirements {
|
||||
names = append(names, fmt.Sprintf("%s(required=%t)", req.Name, req.Required))
|
||||
}
|
||||
sort.Strings(names)
|
||||
fmt.Fprintf(out, "Previous-session artifacts: ready: %s\n", strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
@@ -27,12 +27,12 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
||||
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
|
||||
t.Fatalf("first output = %q, want workdir prepared", got)
|
||||
}
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||
if !strings.Contains(got, name+": run") {
|
||||
t.Fatalf("first output = %q, missing stage %q", got, name)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got, "totals: run=9 skip=0") {
|
||||
if !strings.Contains(got, "totals: run=10 skip=0") {
|
||||
t.Fatalf("first output = %q, want totals", got)
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
|
||||
if !strings.Contains(got, "trim: run") {
|
||||
t.Fatalf("output = %q, want trim run", got)
|
||||
}
|
||||
if !strings.Contains(got, "totals: run=7 skip=2") {
|
||||
t.Fatalf("output = %q, want totals run=7 skip=2", got)
|
||||
if !strings.Contains(got, "totals: run=8 skip=2") {
|
||||
t.Fatalf("output = %q, want totals run=8 skip=2", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import "testing"
|
||||
|
||||
func TestBuildFullPlanOrder(t *testing.T) {
|
||||
got := BuildFullPlan()
|
||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"}
|
||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
}
|
||||
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
|
||||
@@ -34,7 +34,7 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
|
||||
addCommonConfigFlags(fs, &flags)
|
||||
fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files")
|
||||
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
|
||||
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
|
||||
fs.BoolVar(&includeAudio, "include-audio", false, "include remote session-level audio objects")
|
||||
fs.Usage = func() {
|
||||
_, _ = fmt.Fprintln(out, "Usage: narratio session restore <session_id> [--config <path>] [--campaign <id>] [--campaign-file <path>] [--session <path>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]")
|
||||
_, _ = fmt.Fprintln(out)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/audio"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
@@ -114,10 +115,7 @@ func executeRestoreDownloadAction(
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpPath, 0o644); err != nil {
|
||||
return fmt.Errorf("set file permissions: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, safeLocalPath); err != nil {
|
||||
if err := fileops.InstallDownloadedTempFile(tmpPath, safeLocalPath, 0o644); err != nil {
|
||||
return fmt.Errorf("install file atomically: %w", err)
|
||||
}
|
||||
removeTmp = false
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Restored session archive for sample-campaign/2026-05-03") {
|
||||
if !strings.Contains(stdout.String(), "Restored session state for sample-campaign/2026-05-03") {
|
||||
t.Fatalf("stdout = %q, want completion summary", stdout.String())
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/previouscache"
|
||||
)
|
||||
|
||||
@@ -215,19 +217,17 @@ func joinWithinSessionRoot(sessionRoot, relative string) (string, error) {
|
||||
if strings.TrimSpace(sessionRoot) == "" {
|
||||
return "", fmt.Errorf("session root is required")
|
||||
}
|
||||
cleanRel := path.Clean(strings.TrimSpace(relative))
|
||||
if cleanRel == "." || cleanRel == "" {
|
||||
return "", fmt.Errorf("relative path is required")
|
||||
joined, err := pathsafe.JoinSlashRelativeUnderRoot(sessionRoot, filepath.ToSlash(strings.TrimSpace(relative)))
|
||||
if err != nil {
|
||||
if errors.Is(err, pathsafe.ErrRelativePathRequired) {
|
||||
return "", fmt.Errorf("relative path is required")
|
||||
}
|
||||
if errors.Is(err, pathsafe.ErrRelativePathEscape) || errors.Is(err, pathsafe.ErrRelativePathAbsolute) {
|
||||
return "", fmt.Errorf("relative path escapes session root")
|
||||
}
|
||||
return "", fmt.Errorf("join relative path under session root: %w", err)
|
||||
}
|
||||
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") || strings.HasPrefix(cleanRel, "/") {
|
||||
return "", fmt.Errorf("relative path escapes session root")
|
||||
}
|
||||
abs := filepath.Clean(filepath.Join(sessionRoot, filepath.FromSlash(cleanRel)))
|
||||
root := filepath.Clean(sessionRoot)
|
||||
if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("resolved local path escapes session root")
|
||||
}
|
||||
return abs, nil
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
func buildPreviousCacheRestoreActions(
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestRestorePlanDefaultScope(t *testing.T) {
|
||||
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
|
||||
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
|
||||
seedRestoreObject(store, current.SessionPrefix+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}"))
|
||||
seedRestoreObject(store, current.SessionPrefix+"logs/archive.log", []byte("log"))
|
||||
seedRestoreObject(store, current.SessionPrefix+"logs/publish.log", []byte("log"))
|
||||
|
||||
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
|
||||
if err != nil {
|
||||
|
||||
@@ -201,7 +201,7 @@ func writeRestoreSuccessSummary(out io.Writer, report *RestoreReport) error {
|
||||
if report == nil {
|
||||
return fmt.Errorf("restore report is required")
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "Restored session archive for %s/%s\n", report.Campaign, report.SessionID); err != nil {
|
||||
if _, err := fmt.Fprintf(out, "Restored session state for %s/%s\n", report.Campaign, report.SessionID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "Remote run: %s\n", report.RunID); err != nil {
|
||||
|
||||
@@ -369,7 +369,7 @@ func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Restored session archive for sample-campaign/2026-05-03") {
|
||||
if !strings.Contains(stdout.String(), "Restored session state for sample-campaign/2026-05-03") {
|
||||
t.Fatalf("stdout = %q, want completion summary", stdout.String())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
@@ -43,13 +47,25 @@ func stageSucceeded(m *manifest.Manifest, name string) bool {
|
||||
return sr != nil && sr.Status == manifest.StatusSucceeded
|
||||
}
|
||||
|
||||
func firstNonSucceededIndex(stages []stage.Stage, m *manifest.Manifest) int {
|
||||
for i, s := range stages {
|
||||
if !stageSucceeded(m, s.Name()) {
|
||||
return i
|
||||
}
|
||||
func loadManifestIfPresent(ctx context.Context, cfg *config.Config) (*manifest.Manifest, error) {
|
||||
path := artifacts.SessionManifestPathForCampaign(
|
||||
cfg.Pipeline.Workspace.Root,
|
||||
cfg.Session.Campaign,
|
||||
cfg.Session.SessionID,
|
||||
)
|
||||
exists, err := fileExists(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check manifest %q: %w", path, err)
|
||||
}
|
||||
return len(stages)
|
||||
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
|
||||
}
|
||||
|
||||
func canonicalStageNames() []string {
|
||||
|
||||
@@ -8,18 +8,6 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func TestFirstNonSucceededIndex(t *testing.T) {
|
||||
stages := BuildFullPlan()
|
||||
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
||||
m.MarkStageSucceeded("transcribe", time.Now().UTC(), nil)
|
||||
|
||||
got := firstNonSucceededIndex(stages, m)
|
||||
if got != 2 {
|
||||
t.Fatalf("firstNonSucceededIndex() = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideStageActions(t *testing.T) {
|
||||
stages := BuildFullPlan()[:2]
|
||||
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||
@@ -44,7 +32,7 @@ func TestDecideStageActions(t *testing.T) {
|
||||
|
||||
func TestDownstreamStageNames(t *testing.T) {
|
||||
got := downstreamStageNames("polish")
|
||||
want := []string{"normalize", "trim", "analyze", "publish", "notify"}
|
||||
want := []string{"normalize", "trim", "render", "analyze", "publish", "notify"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
||||
}
|
||||
@@ -64,12 +52,13 @@ func TestInvalidateDownstreamSucceededStages(t *testing.T) {
|
||||
m.MarkStageSucceeded("polish", now, nil)
|
||||
m.MarkStageSucceeded("normalize", now, nil)
|
||||
m.MarkStageSucceeded("trim", now, nil)
|
||||
m.MarkStageSucceeded("render", now, nil)
|
||||
m.MarkStageFailed("analyze", now, "analysis failed")
|
||||
m.MarkStageSucceeded("publish", now, nil)
|
||||
m.MarkStageSucceeded("notify", now, nil)
|
||||
|
||||
got := invalidateDownstreamSucceededStages(m, "polish", now.Add(1*time.Second))
|
||||
want := []string{"normalize", "trim", "publish", "notify"}
|
||||
want := []string{"normalize", "trim", "render", "publish", "notify"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("invalidateDownstreamSucceededStages() = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func TestResumeStartsAfterCompletedStages(t *testing.T) {
|
||||
func TestRunContinuesAfterCompletedStages(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||
@@ -32,12 +32,12 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume() error = %v", err)
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "executed=7 skipped=0") {
|
||||
t.Fatalf("output = %q, want executed=7 skipped=0", out.String())
|
||||
if !strings.Contains(out.String(), "executed=8 skipped=2") {
|
||||
t.Fatalf("output = %q, want executed=8 skipped=2", out.String())
|
||||
}
|
||||
|
||||
loaded, err := store.Load(context.Background(), manifestPath)
|
||||
@@ -49,14 +49,14 @@ func TestResumeStartsAfterCompletedStages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeNoRemainingStages(t *testing.T) {
|
||||
func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||
@@ -64,20 +64,20 @@ func TestResumeNoRemainingStages(t *testing.T) {
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume() error = %v", err)
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "has no remaining stages") {
|
||||
t.Fatalf("output = %q, want no remaining stages", out.String())
|
||||
if !strings.Contains(out.String(), "executed=0 skipped=10") {
|
||||
t.Fatalf("output = %q, want executed=0 skipped=10", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeForceRerunsSucceeded(t *testing.T) {
|
||||
func TestRunForceRerunsSucceeded(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"source":"resume-force-test","segments":[{"speaker":"alice"}]}`))
|
||||
_, _ = w.Write([]byte(`{"source":"run-force-test","segments":[{"speaker":"alice"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
||||
@@ -85,7 +85,7 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||
@@ -93,11 +93,11 @@ func TestResumeForceRerunsSucceeded(t *testing.T) {
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume() error = %v", err)
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "executed=9 skipped=0") {
|
||||
if !strings.Contains(out.String(), "executed=10 skipped=0") {
|
||||
t.Fatalf("output = %q, want forced full rerun", out.String())
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing.T) {
|
||||
func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||
@@ -176,7 +176,7 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||
@@ -196,19 +196,19 @@ func TestRunStageForceMarksDownstreamStaleAndResumeContinuesFromStale(t *testing
|
||||
if err != nil {
|
||||
t.Fatalf("load manifest after force: %v", err)
|
||||
}
|
||||
for _, name := range []string{"normalize", "trim", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
||||
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
||||
}
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
err = Resume(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
err = Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Resume() error = %v", err)
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "executed=5 skipped=0") {
|
||||
t.Fatalf("output = %q, want resume to execute normalize..notify", out.String())
|
||||
if !strings.Contains(out.String(), "executed=6 skipped=4") {
|
||||
t.Fatalf("output = %q, want run to execute stale downstream stages", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,3 +266,30 @@ func TestRunStageNormalizeExecutes(t *testing.T) {
|
||||
t.Fatalf("normalize stage = %#v, want succeeded", m.Stages["normalize"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStageRenderExecutes(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.json"), `{"segments":[{"id":1}]}`)
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "final.trimmed.json"), `{"segments":[{"id":2}]}`)
|
||||
|
||||
var out bytes.Buffer
|
||||
err := RunStage(context.Background(), []string{"render", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage(render) error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "stage=render executed=1 skipped=0") {
|
||||
t.Fatalf("output = %q, want stage=render executed", out.String())
|
||||
}
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
m, err := store.Load(context.Background(), manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load manifest: %v", err)
|
||||
}
|
||||
if m.Stages["render"] == nil || m.Stages["render"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("render stage = %#v, want succeeded", m.Stages["render"])
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notify"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/logging"
|
||||
@@ -83,9 +83,6 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
if env.Scriptorium == nil {
|
||||
env.Scriptorium = scriptorium.NewSubprocessRunner()
|
||||
}
|
||||
if env.Storage == nil {
|
||||
env.Storage = &storage.NoopBackend{}
|
||||
}
|
||||
if env.ObjectStore == nil && needsObjectStoreForRun(env.Config, stages) {
|
||||
objectStore, err := newCommandObjectStore(ctx, env.Config, nil)
|
||||
if err != nil {
|
||||
@@ -414,6 +411,8 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
|
||||
if stageName == "analyze" {
|
||||
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
|
||||
kind = "scriptorium_artifact"
|
||||
} else {
|
||||
sourceID = sourceIDForOutputKind(kind)
|
||||
}
|
||||
out = append(out, manifest.ArtifactRecord{
|
||||
Kind: kind,
|
||||
@@ -428,6 +427,22 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string)
|
||||
return out
|
||||
}
|
||||
|
||||
func sourceIDForOutputKind(kind string) string {
|
||||
trimmed := strings.TrimSpace(kind)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if trimmed == "session_bounds" {
|
||||
return artifacts.ArtifactBoundsSession
|
||||
}
|
||||
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {
|
||||
if spec.OutputKind == trimmed {
|
||||
return spec.SourceID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *stage.StageResult) {
|
||||
if m == nil || result == nil {
|
||||
return
|
||||
|
||||
@@ -273,7 +273,7 @@ func TestExecuteStagesPublishSkipsRequiredUnselectedConfiguredOutput(t *testing.
|
||||
manifestPath := manifestPathFor(cfg)
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
seed.Campaign = cfg.Session.Campaign
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"} {
|
||||
seed.MarkStageSucceeded(stageName, time.Now().UTC(), nil)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||
@@ -332,8 +332,8 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if len(summary.StageNames) != 9 || len(summary.Executed) != 9 || len(summary.Skipped) != 0 {
|
||||
t.Fatalf("summary = %#v, want all 9 executed", summary)
|
||||
if len(summary.StageNames) != 10 || len(summary.Executed) != 10 || len(summary.Skipped) != 0 {
|
||||
t.Fatalf("summary = %#v, want all 10 executed", summary)
|
||||
}
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
@@ -342,7 +342,7 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
||||
t.Fatalf("Load manifest error = %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||
sr := m.Stages[name]
|
||||
if sr == nil {
|
||||
t.Fatalf("missing stage record %q", name)
|
||||
@@ -425,6 +425,15 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if name == "render" {
|
||||
if sr.Metadata == nil || sr.Metadata["stage"] != "render" {
|
||||
t.Fatalf("render metadata missing stage=render: %#v", sr.Metadata)
|
||||
}
|
||||
if len(sr.Outputs) == 0 {
|
||||
t.Fatalf("render outputs missing")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if name == "publish" {
|
||||
if sr.Metadata == nil || sr.Metadata["stage"] != "publish" {
|
||||
t.Fatalf("publish metadata missing stage=publish: %#v", sr.Metadata)
|
||||
@@ -522,7 +531,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
|
||||
store := &manifest.LocalStore{}
|
||||
|
||||
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "publish", "notify"} {
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "publish", "notify"} {
|
||||
existing.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure")
|
||||
@@ -553,7 +562,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
|
||||
if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"])
|
||||
}
|
||||
for _, stageName := range []string{"normalize", "trim", "publish", "notify"} {
|
||||
for _, stageName := range []string{"normalize", "trim", "render", "publish", "notify"} {
|
||||
if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale {
|
||||
t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName])
|
||||
}
|
||||
@@ -977,7 +986,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
|
||||
seed.S3Bucket = "my-dnd-archive"
|
||||
seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/"
|
||||
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
}
|
||||
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
|
||||
@@ -59,3 +59,37 @@ func parseSessionAwareFlags(command string, fs *flag.FlagSet, args []string, ses
|
||||
}
|
||||
return resolveParsedSessionID(command, positionalSessionID, fs, sessionID)
|
||||
}
|
||||
|
||||
func parseSessionIDAndOnePositionalArg(command, argName string, fs *flag.FlagSet, args []string, sessionID *string) (string, error) {
|
||||
var positionalSessionID string
|
||||
value := ""
|
||||
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
|
||||
positionalSessionID = strings.TrimSpace(args[0])
|
||||
value = strings.TrimSpace(args[1])
|
||||
args = append([]string(nil), args[2:]...)
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return "", fmt.Errorf("%s: invalid flags: %w", command, err)
|
||||
}
|
||||
if value == "" {
|
||||
switch fs.NArg() {
|
||||
case 2:
|
||||
positionalSessionID = strings.TrimSpace(fs.Arg(0))
|
||||
value = strings.TrimSpace(fs.Arg(1))
|
||||
case 1:
|
||||
if strings.TrimSpace(*sessionID) == "" {
|
||||
return "", fmt.Errorf("%s: expected session_id and %s", command, argName)
|
||||
}
|
||||
value = strings.TrimSpace(fs.Arg(0))
|
||||
default:
|
||||
return "", fmt.Errorf("%s: expected session_id and %s", command, argName)
|
||||
}
|
||||
} else if fs.NArg() != 0 {
|
||||
return "", fmt.Errorf("%s: unexpected positional arguments", command)
|
||||
}
|
||||
if err := applyPositionalSessionID(command, positionalSessionID, sessionID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -162,8 +162,8 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
|
||||
wantForce bool
|
||||
}{
|
||||
{
|
||||
name: "resume",
|
||||
args: []string{"resume", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
name: "run",
|
||||
args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
wantStage: "prepare",
|
||||
wantForce: false,
|
||||
},
|
||||
|
||||
@@ -3,24 +3,30 @@ package artifactmodel
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
SourceTranscriptBase = "narratio.transcript.base"
|
||||
SourceTranscriptPolished = "narratio.transcript.polished"
|
||||
SourceTranscriptFinal = "narratio.transcript.final"
|
||||
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
|
||||
SourceTranscriptBase = "narratio.transcript.base"
|
||||
SourceTranscriptPolished = "narratio.transcript.polished"
|
||||
SourceTranscriptFinal = "narratio.transcript.final"
|
||||
SourceTranscriptFinalTrimmed = "narratio.transcript.final_trimmed"
|
||||
SourceTranscriptFinalMarkdown = "narratio.transcript.final_markdown"
|
||||
SourceTranscriptFinalTrimmedMarkdown = "narratio.transcript.final_trimmed_markdown"
|
||||
)
|
||||
|
||||
const (
|
||||
TranscriptPathBase = "transcripts/base.json"
|
||||
TranscriptPathPolished = "transcripts/polished.json"
|
||||
TranscriptPathFinal = "transcripts/final.json"
|
||||
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
|
||||
TranscriptPathBase = "transcripts/base.json"
|
||||
TranscriptPathPolished = "transcripts/polished.json"
|
||||
TranscriptPathFinal = "transcripts/final.json"
|
||||
TranscriptPathFinalTrimmed = "transcripts/final.trimmed.json"
|
||||
TranscriptPathFinalMarkdown = "transcripts/final.md"
|
||||
TranscriptPathFinalTrimmedMarkdown = "transcripts/final.trimmed.md"
|
||||
)
|
||||
|
||||
const (
|
||||
TranscriptOutputKindBase = "transcript_base"
|
||||
TranscriptOutputKindPolished = "transcript_polished"
|
||||
TranscriptOutputKindFinal = "transcript_final"
|
||||
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
|
||||
TranscriptOutputKindBase = "transcript_base"
|
||||
TranscriptOutputKindPolished = "transcript_polished"
|
||||
TranscriptOutputKindFinal = "transcript_final"
|
||||
TranscriptOutputKindFinalTrimmed = "transcript_final_trimmed"
|
||||
TranscriptOutputKindFinalMarkdown = "transcript_final_markdown"
|
||||
TranscriptOutputKindFinalTrimmedMarkdown = "transcript_final_trimmed_markdown"
|
||||
)
|
||||
|
||||
// TranscriptArtifactSpec describes one built-in transcript artifact mapping.
|
||||
@@ -56,6 +62,18 @@ var runtimeTranscriptArtifacts = []TranscriptArtifactSpec{
|
||||
ProducerStage: "trim",
|
||||
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||
},
|
||||
{
|
||||
SourceID: SourceTranscriptFinalMarkdown,
|
||||
CanonicalRelPath: TranscriptPathFinalMarkdown,
|
||||
ProducerStage: "render",
|
||||
OutputKind: TranscriptOutputKindFinalMarkdown,
|
||||
},
|
||||
{
|
||||
SourceID: SourceTranscriptFinalTrimmedMarkdown,
|
||||
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
|
||||
ProducerStage: "render",
|
||||
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
|
||||
},
|
||||
}
|
||||
|
||||
// RuntimeTranscriptArtifacts returns transcript mappings in pipeline order.
|
||||
|
||||
60
internal/artifactmodel/transcripts_test.go
Normal file
60
internal/artifactmodel/transcripts_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package artifactpolicy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -19,6 +20,11 @@ const (
|
||||
var configuredSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||
var previousSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||
|
||||
var (
|
||||
ErrUnsupportedScriptoriumInputSource = errors.New("unsupported scriptorium input source")
|
||||
ErrInvalidPreviousSessionSource = errors.New("invalid previous-session source format")
|
||||
)
|
||||
|
||||
type SourceKind string
|
||||
|
||||
const (
|
||||
@@ -34,6 +40,28 @@ type Source struct {
|
||||
ConfiguredKey string
|
||||
}
|
||||
|
||||
// ScriptoriumInputSourceDescriptor describes one validated Scriptorium input source.
|
||||
type ScriptoriumInputSourceDescriptor struct {
|
||||
Source Source
|
||||
PreviousSession *PreviousSessionSourceDescriptor
|
||||
}
|
||||
|
||||
// PreviousSessionSourceDescriptor describes one canonical previous-session input source.
|
||||
type PreviousSessionSourceDescriptor struct {
|
||||
SourceID string
|
||||
ConfiguredKey string
|
||||
ConfiguredSourceID string
|
||||
}
|
||||
|
||||
// UnknownConfiguredArtifactError reports a source that references an undefined configured artifact key.
|
||||
type UnknownConfiguredArtifactError struct {
|
||||
ConfiguredKey string
|
||||
}
|
||||
|
||||
func (e *UnknownConfiguredArtifactError) Error() string {
|
||||
return fmt.Sprintf("references unknown artifact %q", e.ConfiguredKey)
|
||||
}
|
||||
|
||||
// ConfiguredSourceID converts a configured artifact key into source id form.
|
||||
func ConfiguredSourceID(key string) string {
|
||||
return configuredSourcePrefix + strings.TrimSpace(key)
|
||||
@@ -83,6 +111,70 @@ func ClassifySource(source string) (Source, error) {
|
||||
return Source{}, fmt.Errorf("unsupported artifact source %q", source)
|
||||
}
|
||||
|
||||
// DescribeScriptoriumInputSource classifies one input source and returns descriptor
|
||||
// metadata used by config validation, analyze input resolution, and previous-cache planning.
|
||||
func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescriptor, error) {
|
||||
trimmed := strings.TrimSpace(source)
|
||||
if trimmed == "" {
|
||||
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
|
||||
}
|
||||
if 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.
|
||||
func ValidatePublishSource(source string, configured map[string]string) (Source, error) {
|
||||
classified, err := ClassifySource(source)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package artifactpolicy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -66,6 +67,14 @@ func TestResolvePublishedDestination(t *testing.T) {
|
||||
t.Fatalf("built-in destination = %q, want transcripts/final.trimmed.json", got)
|
||||
}
|
||||
|
||||
got, err = ResolvePublishedDestination("narratio.transcript.final_markdown", "", configured)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePublishedDestination(markdown built-in) error = %v", err)
|
||||
}
|
||||
if got != "transcripts/final.md" {
|
||||
t.Fatalf("markdown built-in destination = %q, want transcripts/final.md", got)
|
||||
}
|
||||
|
||||
got, err = ResolvePublishedDestination("narratio.artifact.session_recap", "", configured)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePublishedDestination(configured) error = %v", err)
|
||||
@@ -90,3 +99,102 @@ func TestResolvePublishedDestinationRejectsTraversal(t *testing.T) {
|
||||
t.Fatal("ResolvePublishedDestination() error = nil, want traversal rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeScriptoriumInputSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
source string
|
||||
wantKind SourceKind
|
||||
wantKey string
|
||||
wantPrev bool
|
||||
wantErr error
|
||||
wantErrLike string
|
||||
}{
|
||||
{name: "built in", source: "narratio.transcript.final_trimmed", wantKind: SourceKindBuiltIn},
|
||||
{name: "built in markdown", source: "narratio.transcript.final_markdown", wantKind: SourceKindBuiltIn},
|
||||
{name: "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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,28 +14,34 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
|
||||
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
|
||||
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
|
||||
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
|
||||
ArtifactBoundsSession = "narratio.bounds.session"
|
||||
ArtifactTranscriptBase = artifactmodel.SourceTranscriptBase
|
||||
ArtifactTranscriptPolished = artifactmodel.SourceTranscriptPolished
|
||||
ArtifactTranscriptFinal = artifactmodel.SourceTranscriptFinal
|
||||
ArtifactTranscriptFinalTrimmed = artifactmodel.SourceTranscriptFinalTrimmed
|
||||
ArtifactTranscriptFinalMarkdown = artifactmodel.SourceTranscriptFinalMarkdown
|
||||
ArtifactTranscriptFinalTrimmedMarkdown = artifactmodel.SourceTranscriptFinalTrimmedMarkdown
|
||||
ArtifactBoundsSession = "narratio.bounds.session"
|
||||
|
||||
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
|
||||
ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache"
|
||||
)
|
||||
|
||||
const (
|
||||
TranscriptPathBase = artifactmodel.TranscriptPathBase
|
||||
TranscriptPathPolished = artifactmodel.TranscriptPathPolished
|
||||
TranscriptPathFinal = artifactmodel.TranscriptPathFinal
|
||||
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
|
||||
TranscriptPathBase = artifactmodel.TranscriptPathBase
|
||||
TranscriptPathPolished = artifactmodel.TranscriptPathPolished
|
||||
TranscriptPathFinal = artifactmodel.TranscriptPathFinal
|
||||
TranscriptPathFinalTrimmed = artifactmodel.TranscriptPathFinalTrimmed
|
||||
TranscriptPathFinalMarkdown = artifactmodel.TranscriptPathFinalMarkdown
|
||||
TranscriptPathFinalTrimmedMarkdown = artifactmodel.TranscriptPathFinalTrimmedMarkdown
|
||||
)
|
||||
|
||||
const (
|
||||
TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase
|
||||
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
|
||||
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
|
||||
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
|
||||
TranscriptOutputKindBase = artifactmodel.TranscriptOutputKindBase
|
||||
TranscriptOutputKindPolished = artifactmodel.TranscriptOutputKindPolished
|
||||
TranscriptOutputKindFinal = artifactmodel.TranscriptOutputKindFinal
|
||||
TranscriptOutputKindFinalTrimmed = artifactmodel.TranscriptOutputKindFinalTrimmed
|
||||
TranscriptOutputKindFinalMarkdown = artifactmodel.TranscriptOutputKindFinalMarkdown
|
||||
TranscriptOutputKindFinalTrimmedMarkdown = artifactmodel.TranscriptOutputKindFinalTrimmedMarkdown
|
||||
)
|
||||
|
||||
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
||||
@@ -67,7 +73,7 @@ func buildArtifactRegistry() map[string]artifactSpec {
|
||||
CanonicalRelPath: transcript.CanonicalRelPath,
|
||||
ProducerStage: transcript.ProducerStage,
|
||||
OutputKind: transcript.OutputKind,
|
||||
ContentKind: contentTranscriptJSON,
|
||||
ContentKind: transcriptContentKind(transcript),
|
||||
}
|
||||
}
|
||||
registry[ArtifactBoundsSession] = artifactSpec{
|
||||
@@ -80,6 +86,15 @@ func buildArtifactRegistry() map[string]artifactSpec {
|
||||
return registry
|
||||
}
|
||||
|
||||
func transcriptContentKind(transcript TranscriptArtifactSpec) artifactContentKind {
|
||||
switch transcript.SourceID {
|
||||
case ArtifactTranscriptFinalMarkdown, ArtifactTranscriptFinalTrimmedMarkdown:
|
||||
return contentText
|
||||
default:
|
||||
return contentTranscriptJSON
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvedSessionArtifact describes one session-level artifact lookup result.
|
||||
type ResolvedSessionArtifact struct {
|
||||
ID string
|
||||
|
||||
@@ -211,6 +211,29 @@ func TestResolveSessionArtifactFallsBackToCanonicalPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionArtifactFallsBackToCanonicalMarkdownPath(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.md")
|
||||
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(canonicalPath, []byte("# Final transcript\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
resolved, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalMarkdown)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSessionArtifact() error = %v", err)
|
||||
}
|
||||
if resolved.Path != canonicalPath {
|
||||
t.Fatalf("resolved path = %q, want %q", resolved.Path, canonicalPath)
|
||||
}
|
||||
if resolved.Provenance != "fallback.canonical_path" {
|
||||
t.Fatalf("provenance = %q, want fallback.canonical_path", resolved.Provenance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionArtifactMissingReturnsTypedError(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||
@@ -244,6 +267,26 @@ func TestResolveSessionArtifactValidatesTranscriptShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionArtifactRejectsEmptyMarkdownContent(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||
canonicalPath := filepath.Join(paths.TranscriptsDir, "final.trimmed.md")
|
||||
if err := os.MkdirAll(filepath.Dir(canonicalPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(canonicalPath, []byte{}, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := ResolveSessionArtifact(paths, nil, ArtifactTranscriptFinalTrimmedMarkdown)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "file is empty") {
|
||||
t.Fatalf("error = %q, want empty file validation", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionArtifactWithCatalogBuiltInBehaviorUnchanged(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||
|
||||
@@ -216,11 +216,10 @@ func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
||||
}
|
||||
|
||||
func runtimeBuiltInArtifactIDs() []string {
|
||||
return []string{
|
||||
ArtifactTranscriptBase,
|
||||
ArtifactTranscriptPolished,
|
||||
ArtifactTranscriptFinal,
|
||||
ArtifactTranscriptFinalTrimmed,
|
||||
ArtifactBoundsSession,
|
||||
ids := make([]string, 0, len(RuntimeTranscriptArtifacts())+1)
|
||||
for _, transcript := range RuntimeTranscriptArtifacts() {
|
||||
ids = append(ids, transcript.SourceID)
|
||||
}
|
||||
ids = append(ids, ArtifactBoundsSession)
|
||||
return ids
|
||||
}
|
||||
|
||||
@@ -23,6 +23,26 @@ func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCatalogRegisterBuiltInsIncludesMarkdownSources(t *testing.T) {
|
||||
catalog := NewArtifactCatalog()
|
||||
if err := catalog.RegisterBuiltIns(); err != nil {
|
||||
t.Fatalf("RegisterBuiltIns() error = %v", err)
|
||||
}
|
||||
|
||||
for _, sourceID := range []string{
|
||||
ArtifactTranscriptFinalMarkdown,
|
||||
ArtifactTranscriptFinalTrimmedMarkdown,
|
||||
} {
|
||||
entry, ok := catalog.Lookup(sourceID)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) ok=false, want true", sourceID)
|
||||
}
|
||||
if !entry.Planned {
|
||||
t.Fatalf("%s planned=false, want true", sourceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCatalogRegisterConfiguredArtifactsDefaultsToEnabled(t *testing.T) {
|
||||
catalog := NewArtifactCatalog()
|
||||
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
// ErrLockConflict is returned when a session lock already exists.
|
||||
@@ -101,7 +104,7 @@ func (s *LocalStore) copyInputWithPaths(paths SessionPaths, sessionID, srcPath,
|
||||
return Ref{}, fmt.Errorf("copy input: %w", err)
|
||||
}
|
||||
|
||||
if err := copyFileAtomic(srcPath, destAbs, 0o644); err != nil {
|
||||
if err := fileops.CopyFileAtomic(srcPath, destAbs, 0o644); err != nil {
|
||||
return Ref{}, fmt.Errorf("copy input %q -> %q: %w", srcPath, destAbs, err)
|
||||
}
|
||||
|
||||
@@ -145,45 +148,9 @@ func (s *LocalStore) WriteFileAtomic(path string, data []byte, perm os.FileMode)
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("write file atomic: path is required")
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("write file atomic: create parent dir %q: %w", dir, err)
|
||||
if err := fileops.WriteFileAtomic(path, data, perm); err != nil {
|
||||
return fmt.Errorf("write file atomic: %w", err)
|
||||
}
|
||||
|
||||
base := filepath.Base(path)
|
||||
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("write file atomic: create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
removeTmp := true
|
||||
defer func() {
|
||||
if removeTmp {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write file atomic: write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write file atomic: sync temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("write file atomic: close temp file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpName, perm); err != nil {
|
||||
return fmt.Errorf("write file atomic: chmod temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("write file atomic: rename temp file: %w", err)
|
||||
}
|
||||
removeTmp = false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -256,62 +223,21 @@ func (s *LocalStore) ReleaseSessionLock(lock *LockHandle) error {
|
||||
}
|
||||
|
||||
func resolveInRoot(root, relative string) (string, error) {
|
||||
rel := filepath.Clean(relative)
|
||||
if rel == "." || rel == "" {
|
||||
joined, err := pathsafe.JoinSlashRelativeUnderRoot(root, filepath.ToSlash(relative))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, pathsafe.ErrRelativePathRequired):
|
||||
return "", fmt.Errorf("relative destination path is required")
|
||||
case errors.Is(err, pathsafe.ErrRelativePathAbsolute):
|
||||
return "", fmt.Errorf("relative destination must not be absolute: %q", relative)
|
||||
case errors.Is(err, pathsafe.ErrRelativePathEscape):
|
||||
return "", fmt.Errorf("relative destination escapes root: %q", relative)
|
||||
default:
|
||||
return "", fmt.Errorf("resolve destination in root: %w", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(joined) == "" {
|
||||
return "", fmt.Errorf("relative destination path is required")
|
||||
}
|
||||
if filepath.IsAbs(rel) {
|
||||
return "", fmt.Errorf("relative destination must not be absolute: %q", relative)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("relative destination escapes root: %q", relative)
|
||||
}
|
||||
return filepath.Join(root, rel), nil
|
||||
}
|
||||
|
||||
func copyFileAtomic(srcPath, dstPath string, perm os.FileMode) error {
|
||||
src, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := filepath.Dir(dstPath)
|
||||
base := filepath.Base(dstPath)
|
||||
tmp, err := os.CreateTemp(dir, "."+base+".tmp-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
removeTmp := true
|
||||
defer func() {
|
||||
if removeTmp {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := io.Copy(tmp, src); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpName, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
removeTmp = false
|
||||
|
||||
return nil
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
@@ -36,10 +37,11 @@ func CollectPreviousArtifactRequirements(
|
||||
inputNames := sortedScriptoriumInputKeys(artifactCfg.Inputs)
|
||||
for _, inputName := range inputNames {
|
||||
inputCfg := artifactCfg.Inputs[inputName]
|
||||
previousName, ok := PreviousSessionArtifactName(inputCfg.Source)
|
||||
if !ok {
|
||||
descriptor, err := artifactpolicy.DescribeScriptoriumInputSource(inputCfg.Source)
|
||||
if err != nil || descriptor.PreviousSession == nil {
|
||||
continue
|
||||
}
|
||||
previousName := descriptor.PreviousSession.ConfiguredKey
|
||||
|
||||
location := fmt.Sprintf(
|
||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source",
|
||||
|
||||
@@ -31,6 +31,18 @@ func TestRuntimeTranscriptArtifacts(t *testing.T) {
|
||||
ProducerStage: "trim",
|
||||
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||
},
|
||||
{
|
||||
SourceID: ArtifactTranscriptFinalMarkdown,
|
||||
CanonicalRelPath: TranscriptPathFinalMarkdown,
|
||||
ProducerStage: "render",
|
||||
OutputKind: TranscriptOutputKindFinalMarkdown,
|
||||
},
|
||||
{
|
||||
SourceID: ArtifactTranscriptFinalTrimmedMarkdown,
|
||||
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
|
||||
ProducerStage: "render",
|
||||
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
|
||||
},
|
||||
}
|
||||
|
||||
got := RuntimeTranscriptArtifacts()
|
||||
@@ -75,6 +87,18 @@ func TestPlannedTranscriptArtifacts(t *testing.T) {
|
||||
ProducerStage: "trim",
|
||||
OutputKind: TranscriptOutputKindFinalTrimmed,
|
||||
},
|
||||
{
|
||||
SourceID: ArtifactTranscriptFinalMarkdown,
|
||||
CanonicalRelPath: TranscriptPathFinalMarkdown,
|
||||
ProducerStage: "render",
|
||||
OutputKind: TranscriptOutputKindFinalMarkdown,
|
||||
},
|
||||
{
|
||||
SourceID: ArtifactTranscriptFinalTrimmedMarkdown,
|
||||
CanonicalRelPath: TranscriptPathFinalTrimmedMarkdown,
|
||||
ProducerStage: "render",
|
||||
OutputKind: TranscriptOutputKindFinalTrimmedMarkdown,
|
||||
},
|
||||
}
|
||||
|
||||
got := PlannedTranscriptArtifacts()
|
||||
@@ -113,10 +137,14 @@ func TestRuntimeArtifactRegistryUsesTranscriptSpecs(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("artifactRegistry missing %q", transcript.SourceID)
|
||||
}
|
||||
wantContentKind := contentTranscriptJSON
|
||||
if transcript.SourceID == ArtifactTranscriptFinalMarkdown || transcript.SourceID == ArtifactTranscriptFinalTrimmedMarkdown {
|
||||
wantContentKind = contentText
|
||||
}
|
||||
if spec.CanonicalRelPath != transcript.CanonicalRelPath ||
|
||||
spec.ProducerStage != transcript.ProducerStage ||
|
||||
spec.OutputKind != transcript.OutputKind ||
|
||||
spec.ContentKind != contentTranscriptJSON {
|
||||
spec.ContentKind != wantContentKind {
|
||||
t.Fatalf("artifactRegistry[%q] = %#v, want transcript spec %#v", transcript.SourceID, spec, transcript)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@ package audio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
)
|
||||
|
||||
// S3MaterializeRequest describes one S3-backed audio materialization.
|
||||
@@ -61,7 +59,7 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
|
||||
if ok, err := validCachedAudio(cachePath, req.Object.Size); err != nil {
|
||||
return S3MaterializeResult{}, err
|
||||
} else if ok {
|
||||
checksum, err := copyFileAtomicWithChecksum(cachePath, req.DestPath, 0o644)
|
||||
checksum, err := fileops.CopyFileAtomicWithChecksum(cachePath, req.DestPath, 0o644)
|
||||
if err != nil {
|
||||
return S3MaterializeResult{}, fmt.Errorf("materialize cached audio %q: %w", cachePath, err)
|
||||
}
|
||||
@@ -82,7 +80,7 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
|
||||
return S3MaterializeResult{}, fmt.Errorf("validate downloaded audio %q: %w", spoolPath, err)
|
||||
}
|
||||
|
||||
checksum, err := copyFileAtomicWithChecksum(spoolPath, req.DestPath, 0o644)
|
||||
checksum, err := fileops.CopyFileAtomicWithChecksum(spoolPath, req.DestPath, 0o644)
|
||||
if err != nil {
|
||||
return S3MaterializeResult{}, fmt.Errorf("materialize downloaded audio %q: %w", filepath.Base(req.DestPath), err)
|
||||
}
|
||||
@@ -91,7 +89,7 @@ func MaterializeS3Audio(ctx context.Context, req S3MaterializeRequest) (S3Materi
|
||||
result.Downloaded = true
|
||||
|
||||
if result.CachePath != "" {
|
||||
if _, err := copyFileAtomicWithChecksum(spoolPath, result.CachePath, 0o644); err != nil {
|
||||
if _, err := fileops.CopyFileAtomicWithChecksum(spoolPath, result.CachePath, 0o644); err != nil {
|
||||
return S3MaterializeResult{}, fmt.Errorf("populate audio cache %q: %w", result.CachePath, err)
|
||||
}
|
||||
}
|
||||
@@ -164,60 +162,9 @@ func downloadObjectAtomic(ctx context.Context, store storage.ObjectStore, key, d
|
||||
if err := store.Download(ctx, key, tmpPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpPath, 0o644); err != nil {
|
||||
return fmt.Errorf("set temp file permissions: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, destPath); err != nil {
|
||||
return fmt.Errorf("install downloaded file: %w", err)
|
||||
if err := fileops.InstallDownloadedTempFile(tmpPath, destPath, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
removeTmp = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, error) {
|
||||
if strings.TrimSpace(src) == "" || strings.TrimSpace(dst) == "" {
|
||||
return "", fmt.Errorf("source and destination paths are required")
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return "", fmt.Errorf("create destination directory: %w", err)
|
||||
}
|
||||
base := filepath.Base(dst)
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), "."+base+".tmp-*")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
removeTmp := true
|
||||
defer func() {
|
||||
if removeTmp {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
digest := sha256.New()
|
||||
if _, err := io.Copy(io.MultiWriter(tmp, digest), in); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", fmt.Errorf("copy file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", fmt.Errorf("sync temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||
return "", fmt.Errorf("chmod temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, dst); err != nil {
|
||||
return "", fmt.Errorf("install temp file: %w", err)
|
||||
}
|
||||
removeTmp = false
|
||||
return hex.EncodeToString(digest.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type PipelineConfig struct {
|
||||
Audita AuditaConfig `yaml:"audita"`
|
||||
Normalize *NormalizeConfig `yaml:"normalize"`
|
||||
Trim *TrimConfig `yaml:"trim"`
|
||||
Render *RenderConfig `yaml:"render"`
|
||||
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
||||
Notification NotificationConfig `yaml:"notification"`
|
||||
}
|
||||
@@ -209,6 +210,16 @@ type TrimSeriatimConfig struct {
|
||||
Report *bool `yaml:"report"`
|
||||
}
|
||||
|
||||
// RenderConfig configures render-stage output formatting behavior.
|
||||
type RenderConfig struct {
|
||||
Enabled *bool `yaml:"enabled"`
|
||||
Format string `yaml:"format"`
|
||||
Title string `yaml:"title"`
|
||||
IncludeTimestamps *bool `yaml:"include_timestamps"`
|
||||
IncludeSegmentIDs bool `yaml:"include_segment_ids"`
|
||||
IncludeMetadata bool `yaml:"include_metadata"`
|
||||
}
|
||||
|
||||
// ScriptoriumConfig configures Scriptorium-backed artifact generation.
|
||||
type ScriptoriumConfig struct {
|
||||
Binary string `yaml:"binary"`
|
||||
|
||||
@@ -40,6 +40,12 @@ const (
|
||||
|
||||
DefaultTrimBoundsTimeout = "10m"
|
||||
DefaultTrimSeriatimReport = false
|
||||
DefaultRenderEnabled = true
|
||||
DefaultRenderFormat = "markdown"
|
||||
DefaultRenderTitle = ""
|
||||
DefaultRenderTimestamps = true
|
||||
DefaultRenderSegmentIDs = false
|
||||
DefaultRenderMetadata = false
|
||||
|
||||
DefaultNormalizeOutputPath = artifactmodel.TranscriptPathFinal
|
||||
DefaultNormalizeOutputSchema = "seriatim-intermediate"
|
||||
@@ -80,6 +86,8 @@ const (
|
||||
// Callers should copy this slice before mutating.
|
||||
var DefaultPublishOutputs = []PublishOutputRule{
|
||||
{Source: artifactmodel.SourceTranscriptFinalTrimmed, Dest: PathTranscriptFinalTrimmed},
|
||||
{Source: artifactmodel.SourceTranscriptFinalMarkdown, Dest: artifactmodel.TranscriptPathFinalMarkdown},
|
||||
{Source: artifactmodel.SourceTranscriptFinalTrimmedMarkdown, Dest: artifactmodel.TranscriptPathFinalTrimmedMarkdown},
|
||||
}
|
||||
|
||||
// DefaultPipelineConfigSearchPaths defines the default search order for
|
||||
|
||||
@@ -337,6 +337,7 @@ func applyPipelineDefaults(cfg *PipelineConfig) {
|
||||
}
|
||||
applyNormalizeDefaults(cfg.Normalize)
|
||||
applyTrimDefaults(cfg.Trim)
|
||||
applyRenderDefaults(&cfg.Render)
|
||||
applyScriptoriumDefaults(cfg.Scriptorium)
|
||||
}
|
||||
|
||||
@@ -507,6 +508,27 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func applyNormalizeDefaults(cfg *NormalizeConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
|
||||
128
internal/config/render_test.go
Normal file
128
internal/config/render_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
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 {
|
||||
t.Fatalf("render.include_segment_ids = true, want false")
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("render.include_segment_ids = false, want true")
|
||||
}
|
||||
if !cfg.Pipeline.Render.IncludeMetadata {
|
||||
t.Fatalf("render.include_metadata = false, want true")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,21 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
||||
transcript:
|
||||
source: narratio.transcript.final_trimmed
|
||||
required: true
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "markdown built in artifact source is accepted",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
inputs:
|
||||
transcript_markdown:
|
||||
source: narratio.transcript.final_markdown
|
||||
required: true
|
||||
`,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -179,18 +179,29 @@ func TestSpoolAndPublishDefaults(t *testing.T) {
|
||||
if cfg.Pipeline.Publish.UploadRun == nil || !*cfg.Pipeline.Publish.UploadRun {
|
||||
t.Fatalf("publish.upload_run = %#v, want true", cfg.Pipeline.Publish.UploadRun)
|
||||
}
|
||||
if len(cfg.Pipeline.Publish.Outputs) != 1 {
|
||||
t.Fatalf("publish.outputs len = %d, want 1 default", len(cfg.Pipeline.Publish.Outputs))
|
||||
if len(cfg.Pipeline.Publish.Outputs) != 3 {
|
||||
t.Fatalf("publish.outputs len = %d, want 3 defaults", len(cfg.Pipeline.Publish.Outputs))
|
||||
}
|
||||
item := cfg.Pipeline.Publish.Outputs[0]
|
||||
if item.Required == nil || !*item.Required {
|
||||
t.Fatalf("publish.outputs[0].required = %#v, want true", item.Required)
|
||||
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",
|
||||
}
|
||||
if item.Source != "narratio.transcript.final_trimmed" {
|
||||
t.Fatalf("publish.outputs[0].source = %q, want narratio.transcript.final_trimmed", item.Source)
|
||||
for i, item := range cfg.Pipeline.Publish.Outputs {
|
||||
if item.Required == nil || !*item.Required {
|
||||
t.Fatalf("publish.outputs[%d].required = %#v, want true", i, item.Required)
|
||||
}
|
||||
wantDest, ok := wantBySource[item.Source]
|
||||
if !ok {
|
||||
t.Fatalf("publish.outputs[%d].source = %q, want known default source", i, item.Source)
|
||||
}
|
||||
if item.Dest != wantDest {
|
||||
t.Fatalf("publish.outputs[%d].dest = %q, want %q", i, item.Dest, wantDest)
|
||||
}
|
||||
delete(wantBySource, item.Source)
|
||||
}
|
||||
if item.Dest != "transcripts/final.trimmed.json" {
|
||||
t.Fatalf("publish.outputs[0].dest = %q, want transcripts/final.trimmed.json", item.Dest)
|
||||
if len(wantBySource) != 0 {
|
||||
t.Fatalf("missing default publish outputs for sources: %#v", wantBySource)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +348,15 @@ publish:
|
||||
`,
|
||||
wantDest: "artifacts/session_recap.md",
|
||||
},
|
||||
{
|
||||
name: "markdown built in derives canonical destination",
|
||||
pipelineYML: testPipelineBaseYAML + `
|
||||
publish:
|
||||
outputs:
|
||||
- source: narratio.transcript.final_markdown
|
||||
`,
|
||||
wantDest: "transcripts/final.md",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
@@ -89,6 +88,9 @@ func validatePipeline(cfg *PipelineConfig) error {
|
||||
if err := validateTrim(cfg.Trim); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRender(cfg.Render); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateScriptorium(cfg.Scriptorium); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -300,6 +302,26 @@ func validateTrim(cfg *TrimConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRender(cfg *RenderConfig) error {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
if cfg.Enabled == nil {
|
||||
return fmt.Errorf("pipeline.render.enabled must be set (defaults should populate this)")
|
||||
}
|
||||
if cfg.IncludeTimestamps == nil {
|
||||
return fmt.Errorf("pipeline.render.include_timestamps must be set (defaults should populate this)")
|
||||
}
|
||||
format := strings.TrimSpace(cfg.Format)
|
||||
if format != "markdown" {
|
||||
return fmt.Errorf("pipeline.render.format must be markdown")
|
||||
}
|
||||
if cfg.Title != "" && strings.TrimSpace(cfg.Title) == "" {
|
||||
return fmt.Errorf("pipeline.render.title must be non-empty when provided")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWhisperX(cfg WhisperXConfig) error {
|
||||
if strings.TrimSpace(cfg.TranscribeURL) == "" {
|
||||
return fmt.Errorf("pipeline.whisperx.transcribe_url is required")
|
||||
@@ -639,13 +661,9 @@ var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||
|
||||
func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) {
|
||||
trimmedSource := strings.TrimSpace(source)
|
||||
if isStaticSupportedScriptoriumInputSource(trimmedSource) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmedSource, "narratio.previous_session.artifact") {
|
||||
referenced, ok := artifactpolicy.ParsePreviousSessionSource(trimmedSource)
|
||||
if !ok {
|
||||
descriptor, err := artifactpolicy.DescribeScriptoriumInputSource(trimmedSource)
|
||||
if err != nil {
|
||||
if errors.Is(err, artifactpolicy.ErrInvalidPreviousSessionSource) {
|
||||
return "", fmt.Errorf(
|
||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$",
|
||||
artifactName,
|
||||
@@ -653,20 +671,6 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
|
||||
source,
|
||||
)
|
||||
}
|
||||
if _, ok := configuredArtifacts[referenced]; !ok {
|
||||
return "", fmt.Errorf(
|
||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
|
||||
artifactName,
|
||||
inputName,
|
||||
source,
|
||||
referenced,
|
||||
)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
referenced, ok := artifactpolicy.ParseConfiguredSource(trimmedSource)
|
||||
if !ok {
|
||||
return "", fmt.Errorf(
|
||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
|
||||
artifactName,
|
||||
@@ -674,28 +678,28 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
|
||||
source,
|
||||
)
|
||||
}
|
||||
if _, ok := configuredArtifacts[referenced]; !ok {
|
||||
if err := artifactpolicy.ValidateInputConfiguredReference(descriptor, configuredArtifacts); err != nil {
|
||||
var unknownConfigured *artifactpolicy.UnknownConfiguredArtifactError
|
||||
if errors.As(err, &unknownConfigured) {
|
||||
return "", fmt.Errorf(
|
||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
|
||||
artifactName,
|
||||
inputName,
|
||||
source,
|
||||
unknownConfigured.ConfiguredKey,
|
||||
)
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
|
||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
|
||||
artifactName,
|
||||
inputName,
|
||||
source,
|
||||
referenced,
|
||||
)
|
||||
}
|
||||
return referenced, nil
|
||||
}
|
||||
|
||||
func isStaticSupportedScriptoriumInputSource(source string) bool {
|
||||
if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(source); ok {
|
||||
return true
|
||||
}
|
||||
switch source {
|
||||
case "narratio.bounds.session":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact {
|
||||
return descriptor.Source.ConfiguredKey, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func validateEnvVarNameField(fieldName, value string) error {
|
||||
|
||||
128
internal/fileops/fileops.go
Normal file
128
internal/fileops/fileops.go
Normal 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
|
||||
}
|
||||
125
internal/fileops/fileops_test.go
Normal file
125
internal/fileops/fileops_test.go
Normal 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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package pathsafe
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -41,3 +43,76 @@ func TestNormalizeRelativeDestination(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinSlashRelativeUnderRoot(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "session")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "valid relative", input: "artifacts/session_recap.md", want: filepath.Join(root, "artifacts", "session_recap.md")},
|
||||
{name: "windows separators normalized", input: `artifacts\session_recap.md`, want: filepath.Join(root, "artifacts", "session_recap.md")},
|
||||
{name: "reject empty", input: "", wantErr: ErrRelativePathRequired},
|
||||
{name: "reject absolute", input: "/artifacts/session_recap.md", wantErr: ErrRelativePathAbsolute},
|
||||
{name: "reject traversal", input: "../artifacts/session_recap.md", wantErr: ErrRelativePathEscape},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := JoinSlashRelativeUnderRoot(root, tt.input)
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("JoinSlashRelativeUnderRoot() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("JoinSlashRelativeUnderRoot() error = %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("JoinSlashRelativeUnderRoot() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashRelativeFromRoot(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "session")
|
||||
target := filepath.Join(root, "transcripts", "full.json")
|
||||
|
||||
got, err := SlashRelativeFromRoot(root, target)
|
||||
if err != nil {
|
||||
t.Fatalf("SlashRelativeFromRoot() error = %v", err)
|
||||
}
|
||||
if got != "transcripts/full.json" {
|
||||
t.Fatalf("SlashRelativeFromRoot() = %q, want transcripts/full.json", got)
|
||||
}
|
||||
|
||||
got, err = SlashRelativeFromRoot(root, `transcripts\full.json`)
|
||||
if err != nil {
|
||||
t.Fatalf("SlashRelativeFromRoot(relative with windows separators) error = %v", err)
|
||||
}
|
||||
if got != "transcripts/full.json" {
|
||||
t.Fatalf("SlashRelativeFromRoot(relative with windows separators) = %q, want transcripts/full.json", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashRelativeFromRootRejectsOutsideRoot(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "session")
|
||||
outside := filepath.Join(filepath.Dir(root), "outside", "file.txt")
|
||||
|
||||
_, err := SlashRelativeFromRoot(root, outside)
|
||||
if !errors.Is(err, ErrRelativePathEscape) {
|
||||
t.Fatalf("SlashRelativeFromRoot() error = %v, want %v", err, ErrRelativePathEscape)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinSlashRelativeUnderRootRequiresRoot(t *testing.T) {
|
||||
_, err := JoinSlashRelativeUnderRoot("", "artifacts/session_recap.md")
|
||||
if err == nil || !strings.Contains(err.Error(), "root path is required") {
|
||||
t.Fatalf("JoinSlashRelativeUnderRoot() error = %v, want root-required error", err)
|
||||
}
|
||||
}
|
||||
|
||||
58
internal/pathsafe/root_scoped.go
Normal file
58
internal/pathsafe/root_scoped.go
Normal 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
|
||||
}
|
||||
@@ -229,7 +229,11 @@ func artifactRelativePathCandidates(
|
||||
candidates = append(candidates, normalized)
|
||||
}
|
||||
|
||||
sourceDescriptor, err := artifactpolicy.PreviousSessionSourceDescriptorForConfiguredKey(artifactName)
|
||||
sourceID := artifactpolicy.ConfiguredSourceID(artifactName)
|
||||
if err == nil {
|
||||
sourceID = sourceDescriptor.ConfiguredSourceID
|
||||
}
|
||||
if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok {
|
||||
appendCandidate(rel)
|
||||
base := path.Base(rel)
|
||||
@@ -309,11 +313,7 @@ func deriveManifestRelativePath(previousManifest *manifest.Manifest, localPath s
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rel, err := filepath.Rel(sessionRoot, trimmed)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
normalized, err := pathsafe.NormalizeRelativeDestination(filepath.ToSlash(rel))
|
||||
normalized, err := pathsafe.SlashRelativeFromRoot(sessionRoot, trimmed)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -375,11 +375,7 @@ func relativeToSession(paths artifacts.SessionPaths, localPath string) (string,
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return "", fmt.Errorf("session root is required")
|
||||
}
|
||||
rel, err := filepath.Rel(root, filepath.Clean(localPath))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve previous-cache relative path: %w", err)
|
||||
}
|
||||
normalized, err := pathsafe.NormalizeRelativeDestination(filepath.ToSlash(rel))
|
||||
normalized, err := pathsafe.SlashRelativeFromRoot(root, localPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve previous-cache relative path: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func TestBuildPlanResolvesPromotedArtifactFromPreviousManifest(t *testing.T) {
|
||||
func TestBuildPlanResolvesPublishedArtifactFromPreviousManifest(t *testing.T) {
|
||||
cfg, paths := previousCacheTestConfig(t)
|
||||
store := &storage.FakeBackend{}
|
||||
seedPreviousCurrent(t, store, cfg, previousManifestWithOutput(t, cfg, "artifacts/session_recap.md", []string{"artifacts/session_recap.md"}))
|
||||
|
||||
@@ -628,11 +628,11 @@ func resolveScriptoriumInput(
|
||||
runtimeCatalog *artifacts.ArtifactCatalog,
|
||||
) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
|
||||
source := strings.TrimSpace(inputCfg.Source)
|
||||
classified, classifyErr := artifactpolicy.ClassifySource(source)
|
||||
if classifyErr != nil {
|
||||
return "", false, nil, classifyErr
|
||||
descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source)
|
||||
if describeErr != nil {
|
||||
return "", false, nil, describeErr
|
||||
}
|
||||
if classified.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
||||
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
||||
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
|
||||
if err == nil {
|
||||
copy := resolved
|
||||
@@ -657,19 +657,25 @@ func resolveScriptoriumInput(
|
||||
return resolved.Path, true, ©, nil
|
||||
}
|
||||
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
|
||||
if classified.Kind == artifactpolicy.SourceKindConfiguredArtifact {
|
||||
if descriptor.Source.Kind == artifactpolicy.SourceKindConfiguredArtifact {
|
||||
if inputCfg.Required {
|
||||
return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source)
|
||||
}
|
||||
return "", false, nil, nil
|
||||
}
|
||||
switch classified.ID {
|
||||
switch descriptor.Source.ID {
|
||||
case artifacts.ArtifactTranscriptPolished:
|
||||
return "", false, nil, nil
|
||||
case artifacts.ArtifactTranscriptFinal:
|
||||
return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
|
||||
case artifacts.ArtifactTranscriptFinalTrimmed:
|
||||
return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first")
|
||||
case artifacts.ArtifactTranscriptFinalMarkdown, artifacts.ArtifactTranscriptFinalTrimmedMarkdown:
|
||||
return "", false, nil, fmt.Errorf(
|
||||
"rendered markdown transcript input is unavailable for source %q; run narratio run-stage render %s --force",
|
||||
descriptor.Source.ID,
|
||||
paths.SessionID,
|
||||
)
|
||||
default:
|
||||
return "", false, nil, nil
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ func TestAnalyzeOmitsOptionalCanonicalPreviousRecapWhenUnavailable(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeUsesRunLocalPathsAndPromotesCanonical(t *testing.T) {
|
||||
func TestAnalyzeUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) {
|
||||
env, m, fake := setupAnalyzeEnv(t)
|
||||
env.Config.Session.Campaign = "sample-campaign"
|
||||
m.Campaign = "sample-campaign"
|
||||
@@ -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) {
|
||||
env, m, fake := setupAnalyzeEnv(t)
|
||||
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) {
|
||||
env, m, _ := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
|
||||
@@ -156,7 +156,7 @@ func (mergeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Sta
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge: promote merged transcript: %w", err)
|
||||
return nil, fmt.Errorf("merge: materialize canonical base transcript: %w", err)
|
||||
}
|
||||
outputs := []artifacts.Ref{materializedMerged}
|
||||
if reportEnabled {
|
||||
@@ -166,7 +166,7 @@ func (mergeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*Sta
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge: promote report: %w", err)
|
||||
return nil, fmt.Errorf("merge: materialize canonical report: %w", err)
|
||||
}
|
||||
outputs = append(outputs, materializedReport)
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ func TestMergeStageFailsWhenNormalizedOutputInvalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeStageUsesRunLocalPathsAndPromotesCanonical(t *testing.T) {
|
||||
func TestMergeStageUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) {
|
||||
env, m := setupMergeEnv(t)
|
||||
env.Config.Session.Campaign = "sample-campaign"
|
||||
m.Campaign = "sample-campaign"
|
||||
|
||||
@@ -99,7 +99,7 @@ func (normalizeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (
|
||||
stderrPath = filepath.Join(runLayout.LogsDir, "seriatim.normalize.stderr.log")
|
||||
generatedConfigPath = filepath.Join(runLayout.ConfigDir, "seriatim.normalize.generated.yml")
|
||||
}
|
||||
timeout, err := resolveTrimSeriatimTimeout(env.Config.Pipeline.Seriatim.Timeout)
|
||||
timeout, err := resolveSeriatimStageTimeout(env.Config.Pipeline.Seriatim.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("normalize: resolve seriatim timeout: %w", err)
|
||||
}
|
||||
@@ -139,7 +139,7 @@ func (normalizeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("normalize: promote normalized transcript: %w", err)
|
||||
return nil, fmt.Errorf("normalize: materialize canonical final transcript: %w", err)
|
||||
}
|
||||
outputs := []artifacts.Ref{materializedNormalized}
|
||||
if reportEnabled {
|
||||
@@ -149,7 +149,7 @@ func (normalizeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("normalize: promote report: %w", err)
|
||||
return nil, fmt.Errorf("normalize: materialize canonical report: %w", err)
|
||||
}
|
||||
outputs = append(outputs, materializedReport)
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ func TestNormalizeStageReportEnabledFailsWhenReportMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStageUsesRunLocalPathsAndPromotesCanonical(t *testing.T) {
|
||||
func TestNormalizeStageUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) {
|
||||
env, m, ser := setupNormalizeEnv(t)
|
||||
env.Config.Session.Campaign = "sample-campaign"
|
||||
m.Campaign = "sample-campaign"
|
||||
|
||||
@@ -74,6 +74,7 @@ func All() []Stage {
|
||||
polishStage{},
|
||||
normalizeStage{},
|
||||
trimStage{},
|
||||
renderStage{},
|
||||
analyzeStage{},
|
||||
publishStage{},
|
||||
placeholderStage{name: "notify"},
|
||||
|
||||
@@ -96,13 +96,12 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
||||
Seriatim: sf,
|
||||
Audita: af,
|
||||
Scriptorium: sc,
|
||||
Storage: st,
|
||||
ObjectStore: st,
|
||||
Notifier: nf,
|
||||
}
|
||||
|
||||
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "analyze"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
}
|
||||
m.RunID = "20260516T000000Z-abcdef12"
|
||||
@@ -195,6 +194,15 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.Name() == "render" {
|
||||
if result.Metadata["stage"] != "render" {
|
||||
t.Fatalf("render metadata = %#v, want stage=render", result.Metadata)
|
||||
}
|
||||
if len(result.Outputs) != 2 {
|
||||
t.Fatalf("render outputs = %#v, want 2 markdown outputs", result.Outputs)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.Name() == "publish" {
|
||||
if result.Metadata["stage"] != "publish" {
|
||||
t.Fatalf("publish metadata = %#v, want stage=publish", result.Metadata)
|
||||
@@ -221,9 +229,6 @@ func TestStagesReturnExpectedMetadata(t *testing.T) {
|
||||
if len(sc.RunRequests) != 0 {
|
||||
t.Fatalf("scriptorium run calls = %d, want 0 when scriptorium config is absent", len(sc.RunRequests))
|
||||
}
|
||||
if len(st.Requests) != 0 {
|
||||
t.Fatalf("storage publish calls = %d, want 0", len(st.Requests))
|
||||
}
|
||||
if _, ok := st.Objects["dnd/campaigns/sample-campaign/sessions/2026-05-03/runs/"+m.RunID+"/manifest.json"]; !ok {
|
||||
t.Fatalf("publish upload missing manifest key in fake object store")
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("polish: promote processed transcript: %w", err)
|
||||
return nil, fmt.Errorf("polish: materialize canonical polished transcript: %w", err)
|
||||
}
|
||||
outputs := []artifacts.Ref{materializedProcessed}
|
||||
if reportEnabled {
|
||||
@@ -165,7 +165,7 @@ func (polishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*St
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("polish: promote report: %w", err)
|
||||
return nil, fmt.Errorf("polish: materialize canonical report: %w", err)
|
||||
}
|
||||
outputs = append(outputs, materializedReport)
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ func TestPolishStageFailsWhenReportInvalid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolishStageUsesRunLocalPathsAndPromotesCanonical(t *testing.T) {
|
||||
func TestPolishStageUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) {
|
||||
env, m := setupPolishEnv(t)
|
||||
env.Config.Session.Campaign = "sample-campaign"
|
||||
m.Campaign = "sample-campaign"
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/previouscache"
|
||||
)
|
||||
@@ -54,8 +55,35 @@ func hydratePreviousSessionArtifacts(
|
||||
if err := os.MkdirAll(filepath.Dir(record.LocalPath), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create previous-session path directory for %q: %w", record.LocalPath, err)
|
||||
}
|
||||
if err := env.ObjectStore.Download(ctx, record.RemoteKey, record.LocalPath); err != nil {
|
||||
return nil, fmt.Errorf("download previous-session object %q to %q: %w", record.RemoteKey, record.LocalPath, err)
|
||||
|
||||
base := filepath.Base(record.LocalPath)
|
||||
tmp, err := os.CreateTemp(filepath.Dir(record.LocalPath), "."+base+".prepare-previous-*.tmp")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create previous-session temp file for %q: %w", record.LocalPath, err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if err := tmp.Close(); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return nil, fmt.Errorf("close previous-session temp file for %q: %w", record.LocalPath, err)
|
||||
}
|
||||
if err := func() error {
|
||||
removeTmp := true
|
||||
defer func() {
|
||||
if removeTmp {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := env.ObjectStore.Download(ctx, record.RemoteKey, tmpPath); err != nil {
|
||||
return fmt.Errorf("download previous-session object %q to temp file: %w", record.RemoteKey, err)
|
||||
}
|
||||
if err := fileops.InstallDownloadedTempFile(tmpPath, record.LocalPath, 0o644); err != nil {
|
||||
return fmt.Errorf("install previous-session object %q at %q: %w", record.RemoteKey, record.LocalPath, err)
|
||||
}
|
||||
removeTmp = false
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record.Kind == preparePreviousInputKindArtifact {
|
||||
if err := requireNonEmptyFile(record.LocalPath, "previous-session artifact "+record.RequirementName); err != nil {
|
||||
|
||||
@@ -32,6 +32,7 @@ var publishPrerequisiteStages = []string{
|
||||
"polish",
|
||||
"normalize",
|
||||
"trim",
|
||||
"render",
|
||||
"analyze",
|
||||
}
|
||||
|
||||
|
||||
288
internal/stage/render.go
Normal file
288
internal/stage/render.go
Normal file
@@ -0,0 +1,288 @@
|
||||
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 := 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
|
||||
return &config.RenderConfig{
|
||||
Enabled: &enabled,
|
||||
Format: config.DefaultRenderFormat,
|
||||
IncludeTimestamps: &includeTimestamps,
|
||||
IncludeSegmentIDs: config.DefaultRenderSegmentIDs,
|
||||
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, ¬Found) {
|
||||
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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user