Compare commits
6 Commits
3e79cf4724
...
v0.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c5c35cd3b4 | |||
| 574b1cde6c | |||
| ebb21b9201 | |||
| 958f446387 | |||
| 86caf4b222 | |||
| e38ed8ba97 |
@@ -19,4 +19,4 @@ This command requires discoverable `pipeline.yml` and `session.yml` files (or ex
|
||||
- [Development Guide](docs/development.md)
|
||||
- [Architecture Principles](docs/architecture.md)
|
||||
- [Internal Component Contracts](docs/internal/README.md)
|
||||
- [Config Examples](docs/examples/)
|
||||
- [Config Examples](examples/)
|
||||
|
||||
120
docs/cli.md
120
docs/cli.md
@@ -6,44 +6,46 @@
|
||||
narratio run --session-id 2026-04-04
|
||||
```
|
||||
|
||||
This uses default config discovery for `pipeline.yml` and `session.yml`; both files must be discoverable for this command to run.
|
||||
This command uses default config discovery for `pipeline.yml` and `session.yml`; both files must be discoverable unless you pass explicit `--config` and `--session` paths.
|
||||
|
||||
## Command Overview
|
||||
|
||||
Implemented commands:
|
||||
|
||||
- `run`: execute the full stage plan and persist manifest state.
|
||||
- `plan`: validate config, prepare workdir, and print run/skip decisions.
|
||||
- `resume`: continue from the first non-succeeded stage in the manifest.
|
||||
- `status`: read and print stage statuses from an existing manifest file.
|
||||
- `run-stage`: execute exactly one selected stage.
|
||||
- `run`: execute pipeline stages and persist manifest state.
|
||||
- `plan`: validate config, prepare workspace layout, and print stage run/skip decisions.
|
||||
- `resume`: continue from first non-succeeded stage unless forced.
|
||||
- `status`: read and print stage statuses from an existing manifest.
|
||||
- `run-stage`: execute exactly one stage.
|
||||
|
||||
Unknown commands print usage (`Usage: narratio <run|plan|status|resume|run-stage>`) and exit non-zero.
|
||||
Unknown commands print usage and exit non-zero.
|
||||
|
||||
For configuration field details, see [docs/config.md](./config.md). For operational lifecycle details, see [docs/operations.md](./operations.md).
|
||||
For config semantics, see [docs/config.md](./config.md). For operator lifecycle and recovery, see [docs/operations.md](./operations.md).
|
||||
|
||||
## Complete Flag Reference
|
||||
|
||||
### `run`
|
||||
|
||||
- `--config <path>`: optional explicit `pipeline.yml` path; if omitted, default locations are searched.
|
||||
- `--session <path>`: optional explicit `session.yml` path; if omitted, default locations are searched.
|
||||
- `--session-id <value>`: session template variable value for `session.yml` rendering.
|
||||
- `--force`: force stage execution (prevents skip of already-succeeded stages).
|
||||
- `--config <path>`: optional explicit `pipeline.yml` path.
|
||||
- `--session <path>`: optional explicit `session.yml` path.
|
||||
- `--session-id <value>`: session template variable value.
|
||||
- `--force`: force stage execution.
|
||||
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
|
||||
|
||||
### `plan`
|
||||
|
||||
- `--config <path>`
|
||||
- `--session <path>`
|
||||
- `--session-id <value>`
|
||||
- `--force`: show forced run decisions instead of normal skip behavior.
|
||||
- `--force`
|
||||
|
||||
### `resume`
|
||||
|
||||
- `--config <path>`
|
||||
- `--session <path>`
|
||||
- `--session-id <value>`
|
||||
- `--force`: run full stage order rather than starting at first non-succeeded stage.
|
||||
- `--force`
|
||||
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
|
||||
|
||||
### `run-stage`
|
||||
|
||||
@@ -51,6 +53,7 @@ For configuration field details, see [docs/config.md](./config.md). For operatio
|
||||
- `--session <path>`
|
||||
- `--session-id <value>`
|
||||
- `--force`
|
||||
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
|
||||
- positional `<stage>`: required stage name.
|
||||
|
||||
Valid stage names:
|
||||
@@ -74,31 +77,27 @@ Valid stage names:
|
||||
### `run`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Validate configuration and execute all stages in canonical order.
|
||||
- Execute configured stages in canonical order.
|
||||
|
||||
Syntax:
|
||||
|
||||
```bash
|
||||
narratio run [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force]
|
||||
narratio run [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>]
|
||||
```
|
||||
|
||||
Success output:
|
||||
|
||||
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
|
||||
|
||||
Common failure cases:
|
||||
|
||||
- no pipeline config found in default search paths when `--config` is omitted.
|
||||
- no session config found in default search paths when `--session` is omitted.
|
||||
- invalid flags or unexpected positional arguments.
|
||||
- config/template/validation errors.
|
||||
- missing default config/session paths when flags omitted.
|
||||
- invalid template/rendered session mismatch.
|
||||
- unknown/invalid `--artifacts` value.
|
||||
- `--artifacts` with unknown configured artifact key.
|
||||
|
||||
### `plan`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Validate config, load secrets (if configured), prepare workspace layout, and print per-stage run/skip decisions.
|
||||
- Validate config, load secrets (if configured), prepare workdir, and print stage run/skip decisions.
|
||||
|
||||
Syntax:
|
||||
|
||||
@@ -107,43 +106,38 @@ narratio plan [--config <pipeline.yml>] [--session <session.yml>] [--session-id
|
||||
```
|
||||
|
||||
Success output includes:
|
||||
|
||||
- `narratio plan: workdir prepared at <path>`
|
||||
- one line per stage (`<stage>: run|skip`)
|
||||
- `totals: run=<n> skip=<n>`
|
||||
|
||||
Common failure cases:
|
||||
|
||||
- same discovery, template, and validation failures as `run`.
|
||||
- same config/session discovery and validation failures as `run`.
|
||||
- secrets directory read failures when `pipeline.secrets.env_dir` is configured.
|
||||
|
||||
### `resume`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Continue execution from manifest state for the same session.
|
||||
- Continue from session-manifest stage status.
|
||||
|
||||
Syntax:
|
||||
|
||||
```bash
|
||||
narratio resume [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force]
|
||||
narratio resume [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>]
|
||||
```
|
||||
|
||||
Success output:
|
||||
|
||||
- either `narratio resume: session <session_id> has no remaining stages`
|
||||
- `narratio resume: session <session_id> has no remaining stages`
|
||||
- or `narratio resume: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
|
||||
|
||||
Common failure cases:
|
||||
|
||||
- same discovery/template/validation failures as `run`.
|
||||
- manifest load errors when an existing manifest is unreadable.
|
||||
- manifest load errors when existing manifest is unreadable.
|
||||
- invalid or unknown artifact selections.
|
||||
|
||||
### `status`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Inspect an existing manifest file without running stages.
|
||||
- Inspect one manifest file without executing stages.
|
||||
|
||||
Syntax:
|
||||
|
||||
@@ -152,37 +146,37 @@ narratio status --manifest <manifest.json>
|
||||
```
|
||||
|
||||
Success output includes:
|
||||
|
||||
- `session_id: <id>`
|
||||
- `updated_at: <timestamp>`
|
||||
- `stages:` section with `- <stage>: <status>` entries.
|
||||
- `stages:` entries (`- <stage>: <status>`)
|
||||
|
||||
Common failure cases:
|
||||
|
||||
- missing `--manifest`.
|
||||
- manifest path unreadable or invalid JSON shape.
|
||||
- unreadable or invalid manifest path.
|
||||
|
||||
### `run-stage`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Execute exactly one stage from the supported stage set.
|
||||
- Execute exactly one stage.
|
||||
|
||||
Syntax:
|
||||
|
||||
```bash
|
||||
narratio run-stage [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] <stage>
|
||||
narratio run-stage [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>] <stage>
|
||||
```
|
||||
|
||||
Success output:
|
||||
|
||||
- `narratio run-stage: stage=<name> executed=<n> skipped=<n> force=<true|false>; manifest=<path>`
|
||||
|
||||
Common failure cases:
|
||||
`--artifacts` behavior:
|
||||
- accepted only when `<stage>` is `analyze`.
|
||||
- names are normalized (trimmed, deduplicated, sorted).
|
||||
- unknown configured artifact keys fail.
|
||||
|
||||
- missing stage positional argument.
|
||||
Common failure cases:
|
||||
- missing stage positional arg.
|
||||
- unknown stage name.
|
||||
- same discovery/template/validation failures as `run`.
|
||||
- using `--artifacts` with any non-`analyze` stage.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
@@ -192,39 +186,37 @@ Default-discovery run:
|
||||
narratio run --session-id 2026-04-04
|
||||
```
|
||||
|
||||
Explicit config/session run:
|
||||
Run only selected analyze artifacts:
|
||||
|
||||
```bash
|
||||
narratio run --config /etc/narratio/pipeline.yml --session ./session.yml --session-id 2026-04-04
|
||||
narratio run --session-id 2026-04-04 --artifacts session_recap,player_handout
|
||||
```
|
||||
|
||||
Plan before run:
|
||||
Resume with selected analyze artifacts:
|
||||
|
||||
```bash
|
||||
narratio plan --config /etc/narratio/pipeline.yml --session ./session.yml --session-id 2026-04-04
|
||||
narratio resume --session-id 2026-04-04 --artifacts player_handout
|
||||
```
|
||||
|
||||
Resume interrupted work:
|
||||
Run only analyze stage with selected artifacts:
|
||||
|
||||
```bash
|
||||
narratio resume --config /etc/narratio/pipeline.yml --session ./session.yml --session-id 2026-04-04
|
||||
```
|
||||
|
||||
Run one stage:
|
||||
|
||||
```bash
|
||||
narratio run-stage --config /etc/narratio/pipeline.yml --session ./session.yml --session-id 2026-04-04 polish
|
||||
narratio run-stage --session-id 2026-04-04 --artifacts player_handout analyze
|
||||
```
|
||||
|
||||
## Diagnostic / Recovery Commands
|
||||
|
||||
Read stage status from a manifest:
|
||||
Inspect stage status:
|
||||
|
||||
```bash
|
||||
narratio status --manifest <manifest.json>
|
||||
```
|
||||
|
||||
How to get manifest path:
|
||||
Get manifest path from previous output:
|
||||
- `run`, `resume`, and `run-stage` print `manifest=<path>` on success.
|
||||
|
||||
- `run`, `resume`, and `run-stage` success output includes `manifest=<path>`.
|
||||
- use that path with `status` for direct inspection.
|
||||
## `--artifacts` and `--force`
|
||||
|
||||
- `--artifacts` filters which configured artifacts are executable when analyze runs.
|
||||
- `--artifacts` does not imply `--force`.
|
||||
- If analyze is already `succeeded` and `--force` is not set, runner-level skip still applies.
|
||||
|
||||
106
docs/config.md
106
docs/config.md
@@ -14,45 +14,42 @@ These commands load and validate both files before running:
|
||||
- `narratio resume`
|
||||
- `narratio run-stage`
|
||||
|
||||
Configuration behavior:
|
||||
Behavior:
|
||||
|
||||
- strict YAML decode is enabled (`KnownFields(true)`): unknown fields fail.
|
||||
- session templates are rendered before session YAML decode.
|
||||
- defaults are applied for many optional pipeline fields.
|
||||
- session templates render before session YAML decode.
|
||||
- defaults are applied for optional pipeline fields.
|
||||
- validation enforces required fields, value formats, and cross-field constraints.
|
||||
|
||||
## 2. Config file discovery
|
||||
|
||||
Pipeline config lookup for `run`, `plan`, `resume`, and `run-stage`:
|
||||
|
||||
- If `--config <path>` is provided, that explicit path is used.
|
||||
- If `--config` is omitted, Narratio searches in order:
|
||||
- If `--config <path>` is provided, that path is used.
|
||||
- If omitted, Narratio searches in order:
|
||||
1. `/usr/local/etc/narratio/pipeline.yml`
|
||||
2. `/etc/narratio/pipeline.yml`
|
||||
- The first existing file wins.
|
||||
- If none exist, the command fails with a searched-paths error.
|
||||
- First existing file wins.
|
||||
|
||||
## 3. Session file discovery and templating
|
||||
|
||||
Session config lookup for `run`, `plan`, `resume`, and `run-stage`:
|
||||
|
||||
- If `--session <path>` is provided, that explicit path is used.
|
||||
- If `--session` is omitted, Narratio searches in order:
|
||||
- If `--session <path>` is provided, that path is used.
|
||||
- If omitted, Narratio searches in order:
|
||||
1. `./session.yml`
|
||||
2. `/usr/local/etc/narratio/session.yml`
|
||||
3. `/etc/narratio/session.yml`
|
||||
- The first existing file wins.
|
||||
- If none exist, the command fails and asks you to pass `--session`.
|
||||
- First existing file wins.
|
||||
|
||||
Session templating:
|
||||
Template behavior:
|
||||
|
||||
- Supported placeholders:
|
||||
- `{{session_id}}`
|
||||
- `{{ session_id }}`
|
||||
- `--session-id <value>` supplies the template value.
|
||||
- Unresolved placeholders fail with a template-rendering error.
|
||||
- If `--session-id` is provided and rendered `session_id` differs, load fails with a mismatch error.
|
||||
- Strict YAML decode still applies after template rendering.
|
||||
- `--session-id <value>` supplies the placeholder value.
|
||||
- unresolved placeholders fail load.
|
||||
- if rendered `session_id` mismatches `--session-id`, load fails.
|
||||
|
||||
## 4. Minimal pipeline config
|
||||
|
||||
@@ -64,9 +61,8 @@ whisperx:
|
||||
Why this is sufficient:
|
||||
|
||||
- `whisperx.transcribe_url` is required.
|
||||
- `workspace.root` is optional and defaults to `/var/lib/narratio`.
|
||||
- Seriatim and Audita sections may be omitted; defaults are applied.
|
||||
- Archive, storage, spool, normalize, and other optional sections get defaults when omitted.
|
||||
- `workspace.root` defaults to `/var/lib/narratio`.
|
||||
- optional sections (`seriatim`, `audita`, `archive`, `scriptorium`, `trim`, `normalize`, etc.) receive defaults or stay inactive.
|
||||
|
||||
## 5. Minimal session template
|
||||
|
||||
@@ -119,18 +115,26 @@ archive:
|
||||
|
||||
whisperx:
|
||||
transcribe_url: "https://transcription.example.com/transcribe"
|
||||
|
||||
scriptorium:
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
inputs:
|
||||
transcript:
|
||||
source: narratio.transcript.trimmed
|
||||
required: true
|
||||
```
|
||||
|
||||
Operational notes:
|
||||
|
||||
- `workspace.cleanup_after_archive` controls run-scoped workspace cleanup after successful archive commit.
|
||||
- `spool.delete_audio_after_archive` controls run-scoped spool-audio cleanup after successful archive commit.
|
||||
- S3 archive/session-audio workflows require `storage.s3.bucket`.
|
||||
- archive promotion is explicit and path-based via `archive.promote_artifacts`.
|
||||
- Narratio does not auto-promote all generated analyze artifacts.
|
||||
|
||||
## 7. Full pipeline reference
|
||||
|
||||
Defaults listed here are effective runtime defaults after load.
|
||||
|
||||
| Path | Type | Required | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| `pipeline.workspace.root` | string | No | `/var/lib/narratio` |
|
||||
@@ -150,7 +154,7 @@ Defaults listed here are effective runtime defaults after load.
|
||||
| `pipeline.spool.delete_audio_after_archive` | bool | No | `false` |
|
||||
| `pipeline.archive.enabled` | bool | No | `true` |
|
||||
| `pipeline.archive.upload_run` | bool | No | `true` |
|
||||
| `pipeline.archive.promote_artifacts[]` | list | No | two default rules |
|
||||
| `pipeline.archive.promote_artifacts[]` | list | No | trimmed + session_recap rules |
|
||||
| `pipeline.archive.promote_artifacts[].from` | string | Yes (per rule) | none |
|
||||
| `pipeline.archive.promote_artifacts[].to` | string | Yes (per rule) | none |
|
||||
| `pipeline.archive.promote_artifacts[].required` | bool | No | `true` |
|
||||
@@ -203,6 +207,7 @@ Defaults listed here are effective runtime defaults after load.
|
||||
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
|
||||
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
||||
| `pipeline.scriptorium.artifacts.<name>.enabled` | bool | No | `false` |
|
||||
| `pipeline.scriptorium.artifacts.<name>.depends_on[]` | list[string] | No | empty |
|
||||
| `pipeline.scriptorium.artifacts.<name>.render_debug` | bool | No | unset |
|
||||
| `pipeline.scriptorium.artifacts.<name>.prompt_id` | string | Conditional | none |
|
||||
| `pipeline.scriptorium.artifacts.<name>.profile_id` | string | No | empty |
|
||||
@@ -221,6 +226,18 @@ Defaults listed here are effective runtime defaults after load.
|
||||
| `pipeline.notification.recipient` | string | No | empty |
|
||||
| `pipeline.notification.timeout` | duration string | No | empty |
|
||||
|
||||
Scriptorium artifact-key and dependency rules:
|
||||
|
||||
- artifact keys must match `^[a-z][a-z0-9_]*$`.
|
||||
- enabled artifacts require `prompt_id` and `output_path`.
|
||||
- `output_path` must be relative, traversal-safe, and under `artifacts/`.
|
||||
- configured artifact input sources use `narratio.artifact.<name>`.
|
||||
- if input source references `narratio.artifact.<name>`, artifact `<name>` must exist and must be listed in `depends_on`.
|
||||
- every `depends_on` entry must be a configured artifact key.
|
||||
- self-dependency is rejected.
|
||||
- enabled dependency cycles are rejected.
|
||||
- any artifact referenced by `depends_on` or `narratio.artifact.<name>` source must define `output_path` (even if not enabled).
|
||||
|
||||
Allowed `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` values:
|
||||
|
||||
- `previous_session_artifact`
|
||||
@@ -229,7 +246,7 @@ Allowed `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` values:
|
||||
- `narratio.transcript.full`
|
||||
- `narratio.transcript.trimmed`
|
||||
- `narratio.bounds.session`
|
||||
- `narratio.artifact.session_recap`
|
||||
- `narratio.artifact.<configured_artifact_key>`
|
||||
|
||||
## 8. Full session reference
|
||||
|
||||
@@ -248,11 +265,11 @@ Allowed `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` values:
|
||||
|
||||
Audio-source rule:
|
||||
|
||||
- You must configure exactly one audio source mode:
|
||||
- configure exactly one mode:
|
||||
- `audio_dir`, or
|
||||
- `audio_files` (at least one), or
|
||||
- `audio_s3.prefix`
|
||||
- `audio_s3` cannot be combined with `audio_dir` or `audio_files`.
|
||||
- `audio_s3` cannot be combined with local audio fields.
|
||||
|
||||
## 9. Secrets
|
||||
|
||||
@@ -261,28 +278,27 @@ Narratio supports filesystem-based secret injection via `pipeline.secrets.env_di
|
||||
Behavior:
|
||||
|
||||
- `env_dir` may be absolute or relative.
|
||||
- Relative `env_dir` is resolved from Narratio’s current working directory.
|
||||
- Each top-level file with a valid env-var filename (`[A-Za-z_][A-Za-z0-9_]*`) is loaded.
|
||||
- File contents become env-var values, with trailing `\n` / `\r\n` trimmed.
|
||||
- Existing process environment variables are preserved and not overwritten.
|
||||
- Invalid names and directories inside `env_dir` are skipped.
|
||||
- Missing/unreadable `env_dir` fails command execution.
|
||||
- relative `env_dir` resolves from current working directory.
|
||||
- files with valid env-var names (`[A-Za-z_][A-Za-z0-9_]*`) are loaded.
|
||||
- values are loaded from file contents with trailing newline trimming.
|
||||
- existing process env vars are preserved.
|
||||
- invalid names and subdirectories are skipped.
|
||||
- missing/unreadable `env_dir` fails command execution.
|
||||
|
||||
Guidance:
|
||||
|
||||
- Store secret values in secret files or pre-set environment variables.
|
||||
- Do not put secret values directly in `pipeline.yml` or `session.yml`.
|
||||
- Use config fields like `llm_api_key_env` and S3 credential env names to reference secret variable names, not secret data.
|
||||
- do not put secret values directly in YAML.
|
||||
- configure env var names in config and provide values via env/secrets files.
|
||||
|
||||
## 10. Examples
|
||||
|
||||
Maintained config examples:
|
||||
Maintained examples:
|
||||
|
||||
- `docs/examples/pipeline.minimal.yml`
|
||||
- `docs/examples/pipeline.production.yml`
|
||||
- `docs/examples/pipeline.full.annotated.yml`
|
||||
- `docs/examples/session.template.yml`
|
||||
- `docs/examples/session.local-audio.yml`
|
||||
- `docs/examples/session.s3-audio.yml`
|
||||
- `examples/pipeline.minimal.yml`
|
||||
- `examples/pipeline.production.yml`
|
||||
- `examples/pipeline.full.annotated.yml`
|
||||
- `examples/session.template.yml`
|
||||
- `examples/session.local-audio.yml`
|
||||
- `examples/session.s3-audio.yml`
|
||||
|
||||
These examples are covered by configuration load/validate tests in `internal/config`.
|
||||
These examples are validated by `internal/config` tests.
|
||||
|
||||
@@ -13,7 +13,7 @@ Canonical contributor workflow and engineering conventions for implemented Narra
|
||||
- `internal/manifest/`: session/run manifest types and persistence.
|
||||
- `internal/artifacts/`: canonical local/remote path helpers and local artifact store.
|
||||
- `docs/`: canonical documentation set.
|
||||
- `docs/examples/`: maintained config examples used by tests.
|
||||
- `examples/`: maintained config examples used by tests.
|
||||
|
||||
## Build and test commands
|
||||
|
||||
@@ -61,7 +61,7 @@ For design principles and invariants, see [docs/architecture.md](./architecture.
|
||||
4. Add or update load/validate tests in `internal/config/*_test.go`.
|
||||
5. Update canonical config docs and examples:
|
||||
- [docs/config.md](./config.md)
|
||||
- relevant files under `docs/examples/`
|
||||
- relevant files under `examples/`
|
||||
|
||||
### Add CLI flags or commands
|
||||
|
||||
@@ -79,7 +79,7 @@ For design principles and invariants, see [docs/architecture.md](./architecture.
|
||||
|
||||
### Update examples
|
||||
|
||||
1. Keep canonical examples only in `docs/examples/`.
|
||||
1. Keep canonical examples only in `examples/`.
|
||||
2. Ensure examples load and validate through runtime config paths.
|
||||
3. Update `internal/config/load_validate_test.go` as needed.
|
||||
4. Update links in `docs/config.md` if example filenames change.
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
Developers and LLM coding agents changing Narratio internals.
|
||||
|
||||
## Scope
|
||||
Implementation-accurate contracts for workspace/state, stages, and external adapter boundaries.
|
||||
Implementation-accurate contracts for workspace/state, manifests, stages, artifact resolution, and adapter boundaries.
|
||||
|
||||
## Component Docs
|
||||
- `adapters.md`: external adapter map, runtime wiring, and boundary ownership.
|
||||
- `storage.md`: remote storage backend contracts and object-store invariants.
|
||||
- `manifest.md`: session/run manifest schemas, lifecycle transitions, and persistence semantics.
|
||||
- `artifacts.md`: supported artifact IDs, transcript tiers, and source-resolution behavior.
|
||||
- `artifacts.md`: built-in artifact registry, runtime artifact catalog, and source-resolution behavior.
|
||||
- `workspace.md`: local state model, manifests, run-local layout, promotion, and cleanup invariants.
|
||||
- `stage-prepare.md`: input materialization and provenance capture.
|
||||
- `stage-transcribe.md`: WhisperX transcript generation.
|
||||
@@ -18,7 +18,7 @@ Implementation-accurate contracts for workspace/state, stages, and external adap
|
||||
- `stage-polish.md`: Audita transcript polishing.
|
||||
- `stage-normalize.md`: post-polish normalization.
|
||||
- `stage-trim.md`: bounds-driven transcript trimming.
|
||||
- `stage-analyze.md`: Scriptorium session recap generation.
|
||||
- `stage-analyze.md`: dependency-ordered Scriptorium artifact generation for selected configured artifacts.
|
||||
- `stage-archive.md`: archive upload and current-pointer publish contract.
|
||||
|
||||
## External Integration Notes
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
# Internal: Artifacts
|
||||
|
||||
## Purpose
|
||||
Describe supported session artifact IDs, transcript tiers, and artifact resolution/provenance behavior used by stage logic and Scriptorium input configuration.
|
||||
Define Narratio's artifact identity and resolution model for built-in transcript/bounds artifacts and runtime-configured analyze artifacts.
|
||||
|
||||
## Inputs and outputs
|
||||
Inputs:
|
||||
- Artifact source identifiers from stage config/runtime (for example `pipeline.scriptorium.artifacts.*.inputs.*.source`).
|
||||
- Session paths and optional session manifest stage outputs.
|
||||
- artifact sources from config/runtime (`pipeline.scriptorium.artifacts.*.inputs.*.source`)
|
||||
- session paths and optional session manifest stage outputs
|
||||
- runtime artifact catalog state for configured artifact sources
|
||||
|
||||
Outputs:
|
||||
- Resolved local artifact path + provenance (`ResolvedSessionArtifact`).
|
||||
- Validation errors for unsupported or unreadable artifact sources.
|
||||
- resolved local artifact path and provenance (`ResolvedSessionArtifact`)
|
||||
- runtime catalog entries for planned/executable/available artifacts
|
||||
- validation errors for unsupported, missing, or invalid artifact sources
|
||||
|
||||
## Boundaries
|
||||
Owns:
|
||||
- Canonical artifact ID registry and metadata (`internal/artifacts/artifact_resolver.go`).
|
||||
- Alias normalization for legacy source names.
|
||||
- Resolution order and artifact content validation.
|
||||
- built-in artifact registry and content validation rules
|
||||
- runtime artifact catalog for configured artifact source IDs
|
||||
- source resolution behavior for built-in and configured artifact sources
|
||||
|
||||
Does not own:
|
||||
- Artifact generation (stages produce files).
|
||||
- Manifest transition policy.
|
||||
- Remote archive publishing behavior.
|
||||
- artifact generation (stages produce files)
|
||||
- manifest transition policy
|
||||
- archive promotion behavior
|
||||
|
||||
## Config fields used
|
||||
Artifact source usage is driven by:
|
||||
- `pipeline.scriptorium.artifacts.<name>.enabled`
|
||||
- `pipeline.scriptorium.artifacts.<name>.output_path`
|
||||
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source`
|
||||
- Optional source-specific fields for previous artifact input (`artifact`, `path`, `required`).
|
||||
|
||||
## External adapters used
|
||||
- No external service adapters.
|
||||
- Resolver relies on local filesystem checks + session manifest state.
|
||||
- none
|
||||
|
||||
## State and manifest behavior
|
||||
Supported canonical IDs and current mappings:
|
||||
Built-in registry entries:
|
||||
|
||||
| Artifact ID | Canonical file | Producer stage | Output kind |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -42,39 +43,45 @@ Supported canonical IDs and current mappings:
|
||||
| `narratio.transcript.full` | `transcripts/normalized.json` | `normalize` | `transcript_normalized` |
|
||||
| `narratio.transcript.trimmed` | `transcripts/trimmed.json` | `trim` | `transcript_trimmed` |
|
||||
| `narratio.bounds.session` | `artifacts/session_bounds.json` | `trim` | `session_bounds` |
|
||||
| `narratio.artifact.session_recap` | `artifacts/session_recap.md` | `analyze` | `session_recap` |
|
||||
|
||||
Resolution order:
|
||||
1. Session manifest producer-stage outputs (if readable/valid).
|
||||
2. Canonical session path fallback.
|
||||
Runtime catalog entries include built-ins and configured `narratio.artifact.<name>` sources.
|
||||
|
||||
Provenance fields:
|
||||
- `ProducerStage`
|
||||
- `OutputKind`
|
||||
- `ProducerRunID` (when resolved from manifest output)
|
||||
- `Provenance` (`manifest.<stage>.outputs` or `fallback.canonical_path`)
|
||||
Catalog states:
|
||||
- `planned`: source is registered and known for this run
|
||||
- `executable`: configured artifact is selected for analyze execution
|
||||
- `available`: artifact has a usable file path (generated this run or reused from disk)
|
||||
|
||||
Content validation by artifact type:
|
||||
- Transcript artifacts: JSON with top-level `segments` array.
|
||||
- `narratio.bounds.session`: valid JSON.
|
||||
- `narratio.artifact.session_recap`: non-empty text.
|
||||
Resolution behavior:
|
||||
- built-in sources resolve via manifest producer outputs first, then canonical fallback path
|
||||
- configured `narratio.artifact.<name>` sources resolve through runtime catalog availability
|
||||
- configured source lookup requires catalog context
|
||||
|
||||
Configured artifact provenance values:
|
||||
- `generated.current_analyze_run`
|
||||
- `filesystem.disabled_artifact_output`
|
||||
|
||||
Content validation:
|
||||
- transcript built-ins: JSON with top-level `segments` array
|
||||
- bounds built-in: valid JSON
|
||||
- configured artifacts: non-empty text file
|
||||
|
||||
## Skip and resume behavior
|
||||
- Resolver has no direct skip/resume logic.
|
||||
- Resolver output influences stage behavior (for example analyze input resolution and required-input failures).
|
||||
- resolver and catalog have no direct skip/resume decisions
|
||||
- stage/runner skip-resume behavior consumes catalog/resolver results
|
||||
|
||||
## Failure behavior
|
||||
- Unsupported or empty artifact source -> normalization error.
|
||||
- Known source not found/readable -> `ErrSessionArtifactNotFound` wrapped error.
|
||||
- Found but invalid content -> validation error.
|
||||
- unsupported source -> source validation error
|
||||
- known source unavailable -> `ErrSessionArtifactNotFound`
|
||||
- configured source without catalog -> resolution error
|
||||
- resolved file with invalid content -> validation error
|
||||
|
||||
## Tests to inspect before changing
|
||||
- `internal/artifacts/artifact_resolver_test.go`
|
||||
- `internal/artifacts/resolve_test.go`
|
||||
- `internal/artifacts/catalog_test.go`
|
||||
- `internal/stage/analyze_test.go`
|
||||
- `internal/config/scriptorium_test.go`
|
||||
|
||||
## Architectural invariants
|
||||
- Artifact IDs are canonical interface values for stage/config integration.
|
||||
- Alias support is compatibility behavior layered on top of canonical IDs.
|
||||
- Manifest producer outputs are preferred over canonical fallback when both exist.
|
||||
- built-in IDs are static and registry-backed
|
||||
- configured artifact IDs are runtime-derived (`narratio.artifact.<name>`) and catalog-backed
|
||||
- built-in/source resolution remains deterministic and validation-gated
|
||||
|
||||
@@ -55,6 +55,7 @@ Relationship during execution:
|
||||
- Runner updates both manifests for every stage transition.
|
||||
- Session manifest is the durable pipeline-progress ledger.
|
||||
- Run manifest is invocation history and audit record.
|
||||
- Analyze stage outputs are persisted as `kind=scriptorium_artifact` with `source_id=narratio.artifact.<name>` for configured artifact identity.
|
||||
|
||||
## Skip and resume behavior
|
||||
- Resume and skip decisions are based on session-manifest stage statuses.
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
# Stage: analyze
|
||||
|
||||
## Purpose
|
||||
Generate the session recap artifact using configured Scriptorium artifact settings.
|
||||
Execute selected configured Scriptorium artifacts in deterministic dependency order and promote successful outputs to canonical session artifact paths.
|
||||
|
||||
## Inputs and Outputs
|
||||
Inputs:
|
||||
- transcript inputs as requested by selected artifact config (processed/normalized/trimmed/current recap, depending on `pipeline.scriptorium.artifacts.session_recap.inputs`)
|
||||
- configured artifact definitions from `pipeline.scriptorium.artifacts`
|
||||
- selected artifact filter from runtime (`--artifacts`) when provided
|
||||
- resolved artifact input sources declared per artifact (`inputs.*.source`)
|
||||
- optional previous-session file inputs (`previous_session_artifact`)
|
||||
|
||||
Outputs:
|
||||
- `artifacts/session_recap.md`
|
||||
- one promoted output file per executed configured artifact at that artifact's configured `output_path`
|
||||
- stage metadata containing generated artifact entries and reused disabled-artifact entries
|
||||
|
||||
## Boundaries
|
||||
Owns:
|
||||
- Selecting supported analyze artifact (`session_recap` only)
|
||||
- Resolving transcript/reference inputs and vars
|
||||
- Optional render-debug execution before run
|
||||
- Main Scriptorium run and output promotion
|
||||
- runtime artifact catalog construction for analyze execution
|
||||
- selected-artifact planning and dependency ordering
|
||||
- per-artifact input resolution, var resolution, timeout/render-debug resolution
|
||||
- Scriptorium run/render invocation for each selected artifact
|
||||
- run-local output generation and canonical promotion
|
||||
|
||||
Does not own:
|
||||
- Transcript processing pipeline stages
|
||||
- Archive publish/pointer behavior
|
||||
- transcript generation/processing stages
|
||||
- archive promotion policy
|
||||
- per-artifact resume semantics
|
||||
|
||||
## Config Fields Used
|
||||
- `session.session_id`
|
||||
@@ -29,8 +35,9 @@ Does not own:
|
||||
- `pipeline.scriptorium.config_path`
|
||||
- `pipeline.scriptorium.timeout`
|
||||
- `pipeline.scriptorium.render_debug`
|
||||
- `pipeline.scriptorium.artifacts.session_recap.*`
|
||||
- `pipeline.scriptorium.artifacts.<name>.*`
|
||||
- `enabled`
|
||||
- `depends_on`
|
||||
- `prompt_id`
|
||||
- `profile_id`
|
||||
- `timeout`
|
||||
@@ -41,29 +48,37 @@ Does not own:
|
||||
|
||||
## External Adapters Used
|
||||
- Scriptorium adapter:
|
||||
- optional `RenderArtifact` (debug diagnostics)
|
||||
- `RunArtifact` (actual recap generation)
|
||||
- optional `RenderArtifact` (render debug)
|
||||
- `RunArtifact` (artifact generation)
|
||||
|
||||
## State and Manifest Behavior
|
||||
- If `pipeline.scriptorium` is nil, stage returns success metadata with `skipped=true`.
|
||||
- If no enabled artifacts exist, stage returns success metadata with `skipped=true`.
|
||||
- If enabled artifacts exist but any artifact other than `session_recap` is enabled, stage fails.
|
||||
- Uses run-local output/log/config/reports paths when run layout is enabled.
|
||||
- Promotes canonical recap output and records adapter metadata.
|
||||
- If `pipeline.scriptorium` is absent, stage returns success metadata with `skipped=true`.
|
||||
- If no artifacts are configured, stage returns success metadata with `skipped=true`.
|
||||
- If zero artifacts are executable after `enabled` + `--artifacts` filtering, stage returns success metadata with `skipped=true`.
|
||||
- Builds runtime catalog with built-ins and configured artifacts.
|
||||
- Non-executable configured artifacts are marked available only when their configured output file exists and is valid on disk.
|
||||
- Executes selected configured artifacts in topological order with deterministic tie-breaking.
|
||||
- For each generated artifact, records metadata fields including `name`, `source_id`, `output_kind`, `path`, `prompt_id`, `profile_id`, and `provenance`.
|
||||
- Reused disabled artifacts are recorded separately in `reused_artifacts` with provenance `filesystem.disabled_artifact_output`.
|
||||
|
||||
## Skip and Resume Behavior
|
||||
- Runner-level skip applies when already succeeded and not forced.
|
||||
- Forced reruns can stale downstream succeeded stages.
|
||||
- Stage-local "skipped" metadata is distinct from runner-level stage status skip.
|
||||
- Runner-level skip applies when analyze is already `succeeded` and `--force` is not set.
|
||||
- Analyze remains stage-scoped for resume/skip; there is no per-artifact resume state.
|
||||
- `--artifacts` filters which configured artifacts are executable when analyze runs; it does not imply `--force`.
|
||||
|
||||
## Failure Behavior
|
||||
- Fails on missing required resolved inputs, invalid transcript inputs, render/run adapter failures, or validation-failed run results.
|
||||
- Fails on invalid dependency ordering, unavailable required configured inputs, invalid built-in input prerequisites, render/run adapter failures, validation-failed adapter results, or missing/empty outputs.
|
||||
- Required configured dependency missing from catalog availability fails clearly before invocation.
|
||||
- Optional missing inputs are omitted.
|
||||
|
||||
## Tests to Inspect Before Changing
|
||||
- `internal/stage/analyze_test.go`
|
||||
- `internal/artifacts/catalog_test.go`
|
||||
- `internal/artifacts/artifact_resolver_test.go`
|
||||
- `internal/adapters/scriptorium/subprocess_test.go`
|
||||
|
||||
## Architectural Invariants
|
||||
- Analyze implementation supports only `artifacts.session_recap` as executable artifact.
|
||||
- Optional inputs may be omitted; required inputs must resolve.
|
||||
- Successful output must exist and be non-empty before promotion.
|
||||
- Configured artifacts are identified by `narratio.artifact.<name>` source IDs.
|
||||
- Artifact-to-artifact references rely on explicit `depends_on` declarations validated in config.
|
||||
- Generated analyze outputs are treated uniformly as Scriptorium artifacts.
|
||||
- Successful outputs must exist and be non-empty before promotion.
|
||||
|
||||
@@ -6,8 +6,7 @@ For field-level configuration, see [docs/config.md](./config.md). For full comma
|
||||
|
||||
## Normal workflow (S3-first path)
|
||||
|
||||
1. Upload session `.flac` files to the session audio prefix in object storage:
|
||||
- `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/{audio_s3.prefix}`
|
||||
1. Upload session `.flac` files to object storage under the session audio prefix.
|
||||
2. Run Narratio:
|
||||
|
||||
```bash
|
||||
@@ -15,28 +14,24 @@ narratio run --session-id 2026-04-04
|
||||
```
|
||||
|
||||
3. Read success output:
|
||||
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
|
||||
- `manifest=<path>` is the local session manifest path to use with `status`.
|
||||
- `narratio run: session <session_id>; executed=<n> skipped=<n>; manifest=<path>`
|
||||
- use `manifest=<path>` with `status` for inspection.
|
||||
|
||||
Notes:
|
||||
|
||||
- This command relies on discoverable `pipeline.yml` and `session.yml` unless `--config` and `--session` are passed explicitly.
|
||||
- For S3 audio input, `session.inputs.audio_s3.prefix` must be configured and audio files must already exist remotely.
|
||||
- default config/session discovery applies unless `--config` and `--session` are passed.
|
||||
- S3 audio mode requires `session.inputs.audio_s3.prefix` and valid object-store access.
|
||||
|
||||
## Local filesystem layout and state artifacts
|
||||
|
||||
Session root:
|
||||
|
||||
- `{workspace.root}/work/{campaign}/{session_id}/`
|
||||
|
||||
Primary state:
|
||||
|
||||
- `manifest.json`: session-level manifest (authoritative local stage state).
|
||||
- `runs/{run_id}/manifest.json`: run-level manifest for one invocation.
|
||||
- `.lock`: session lock file while a run is active.
|
||||
- `manifest.json`: session-level stage state.
|
||||
- `runs/{run_id}/manifest.json`: invocation-level state.
|
||||
- `.lock`: session lock while a run is active.
|
||||
|
||||
Canonical session directories:
|
||||
|
||||
- `inputs/`
|
||||
- `audio/`
|
||||
- `transcripts/`
|
||||
@@ -48,123 +43,107 @@ Canonical session directories:
|
||||
- `runs/`
|
||||
|
||||
Run-local stage directories:
|
||||
- `runs/{run_id}/{stage}/` with stage-local `outputs/`, `logs/`, `reports/`, `config/`, `scratch/`.
|
||||
|
||||
- `runs/{run_id}/{stage}/`
|
||||
- Stage runtime files are written under deterministic run-local subdirectories such as:
|
||||
- `outputs/`, `logs/`, `reports/`, `config/`, `scratch/`
|
||||
Behavior:
|
||||
- directory creation is idempotent.
|
||||
- stage outputs are generally generated run-local first, then promoted to canonical paths on success.
|
||||
|
||||
Behavior notes:
|
||||
## Analyze artifact execution lifecycle
|
||||
|
||||
- Layout creation is idempotent.
|
||||
- Durable outputs are promoted to canonical session paths after stage success.
|
||||
- Run-local artifacts remain in `runs/{run_id}/...` unless configured post-archive cleanup removes that run scope.
|
||||
Analyze executes configured artifacts from `pipeline.scriptorium.artifacts`.
|
||||
|
||||
Execution model:
|
||||
- executable set = enabled artifacts, filtered by `--artifacts` when provided.
|
||||
- artifact-to-artifact dependencies are declared via `depends_on`.
|
||||
- selected artifacts run in deterministic dependency order.
|
||||
- after each successful artifact run, output is promoted to configured canonical `output_path`.
|
||||
|
||||
Configured artifact source reuse:
|
||||
- a non-executable configured artifact can satisfy inputs if its configured output file already exists and is valid.
|
||||
- reused configured artifact provenance is `filesystem.disabled_artifact_output`.
|
||||
|
||||
`--artifacts` behavior:
|
||||
- accepted on `run`, `resume`, and `run-stage analyze`.
|
||||
- filters analyze execution only; does not force stage rerun.
|
||||
|
||||
## Remote archive layout and publish contract
|
||||
|
||||
When archive is enabled and run upload is enabled, archive publishes to object storage under:
|
||||
When archive is enabled and run upload is enabled, archive publishes under:
|
||||
|
||||
- Session prefix: `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/`
|
||||
- Run prefix: `{session_prefix}/runs/{run_id}/`
|
||||
- session prefix: `{root_prefix}/campaigns/{campaign}/sessions/{session_id}/`
|
||||
- run prefix: `{session_prefix}/runs/{run_id}/`
|
||||
|
||||
Archive uploads:
|
||||
- run record files from run root (excluding `audio/`).
|
||||
- promoted files from explicit `archive.promote_artifacts` rules.
|
||||
|
||||
- Run record files from run root (including stage subtrees and run manifest), excluding local `audio/`.
|
||||
- Promoted artifacts from `archive.promote_artifacts` to session-level keys.
|
||||
Publish order:
|
||||
1. upload `current/manifest.json`
|
||||
2. upload `current/run_id.txt` last
|
||||
|
||||
Publish order (commit contract):
|
||||
`current/run_id.txt` is the remote commit marker.
|
||||
|
||||
1. Upload `current/manifest.json`
|
||||
2. Upload `current/run_id.txt` last
|
||||
|
||||
Meaning of `current/run_id.txt`:
|
||||
|
||||
- It is the effective remote commit marker for published session state.
|
||||
- It is written only after required run uploads and required promotions succeed.
|
||||
Archive promotion is explicit and path-based:
|
||||
- Narratio does not auto-promote all generated analyze artifacts.
|
||||
- missing required promotion sources fail archive stage.
|
||||
- missing optional promotion sources are skipped.
|
||||
|
||||
## Resume, retry, and safe rerun behavior
|
||||
|
||||
Default skip behavior:
|
||||
Default skip:
|
||||
- `run` and `run-stage` skip already-succeeded stages unless `--force` is set.
|
||||
|
||||
- `run` and `run-stage` skip stages already marked `succeeded` unless `--force` is set.
|
||||
Resume:
|
||||
- `resume` starts at first non-succeeded stage.
|
||||
- `resume --force` runs full stage order.
|
||||
|
||||
Resume behavior:
|
||||
Forced reruns:
|
||||
- force-rerunning an upstream succeeded stage marks downstream succeeded stages as `stale`.
|
||||
|
||||
- `resume` starts at the first non-`succeeded` stage in canonical stage order.
|
||||
- If all stages are `succeeded`, `resume` prints that no stages remain.
|
||||
- `resume --force` runs full stage order rather than starting at first non-succeeded.
|
||||
|
||||
Forced rerun behavior:
|
||||
|
||||
- Successful forced rerun of an upstream stage marks downstream previously `succeeded` stages as `stale`.
|
||||
- `stale` stages are not treated as complete and are eligible to run in subsequent commands.
|
||||
|
||||
Targeted rerun with one stage:
|
||||
|
||||
```bash
|
||||
narratio run-stage --force <stage>
|
||||
```
|
||||
|
||||
Valid stage names:
|
||||
|
||||
- `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, `analyze`, `archive`, `notify`
|
||||
|
||||
Safe operator pattern:
|
||||
|
||||
1. Force-rerun the stage that changed.
|
||||
2. Run `resume` to rebuild downstream stages in order.
|
||||
Safe rerun pattern:
|
||||
1. rerun the changed stage with `--force`.
|
||||
2. run `resume` to rebuild downstream stages.
|
||||
|
||||
## Cleanup behavior
|
||||
|
||||
Cleanup is considered only after run execution completes and only when archive stage both executed and succeeded.
|
||||
Cleanup is considered only when archive stage executed and succeeded.
|
||||
|
||||
Configured cleanup toggles:
|
||||
Cleanup toggles:
|
||||
- `pipeline.spool.delete_audio_after_archive=true` deletes run-scoped spool audio.
|
||||
- `pipeline.workspace.cleanup_after_archive=true` deletes run-scoped local run directory.
|
||||
|
||||
- `pipeline.spool.delete_audio_after_archive=true`
|
||||
- deletes only run-scoped spool audio directory: `{spool.root}/{campaign}/{session_id}/{run_id}/audio/`
|
||||
- `pipeline.workspace.cleanup_after_archive=true`
|
||||
- deletes only run-scoped local run directory: `{workspace.root}/work/{campaign}/{session_id}/runs/{run_id}/`
|
||||
Cleanup eligibility gates:
|
||||
- archive enabled
|
||||
- archive run upload enabled
|
||||
- run record upload completed
|
||||
- current pointer write completed (`current/run_id.txt` written)
|
||||
|
||||
Eligibility gates for cleanup:
|
||||
|
||||
- archive is enabled
|
||||
- archive run upload is enabled
|
||||
- archive metadata indicates run record upload happened
|
||||
- archive metadata indicates `current` pointer write completed (`current/run_id.txt` written)
|
||||
|
||||
Cleanup does not run for:
|
||||
|
||||
- failed runs
|
||||
- incomplete runs
|
||||
- unarchived runs
|
||||
- archive-skipped runs (`archive.enabled=false` or `archive.upload_run=false`)
|
||||
No cleanup for failed/incomplete/unarchived/archive-skipped runs.
|
||||
|
||||
## Failure and recovery playbooks
|
||||
|
||||
What remains after failure:
|
||||
After failure, Narratio keeps:
|
||||
- session manifest
|
||||
- run manifest
|
||||
- run-local artifacts/logs/config/reports
|
||||
|
||||
- Session manifest remains on disk.
|
||||
- Run manifest remains under `runs/{run_id}/manifest.json`.
|
||||
- Run-local stage artifacts/logs/config/reports remain under `runs/{run_id}/...`.
|
||||
- Failed/incomplete runs remain local-only.
|
||||
- Remote current pointer is not committed if archive prerequisite or pointer-write steps fail.
|
||||
Failed or incomplete runs remain local-only.
|
||||
|
||||
Recommended recovery flow:
|
||||
Recommended recovery:
|
||||
|
||||
1. Inspect current state:
|
||||
1. inspect state:
|
||||
|
||||
```bash
|
||||
narratio status --manifest <manifest-path-from-run-output>
|
||||
narratio status --manifest <manifest-path>
|
||||
```
|
||||
|
||||
2. Fix the root cause (config, input, credentials, adapter availability, etc.).
|
||||
3. Continue with:
|
||||
- `narratio resume --session-id <id>` for ordered continuation, or
|
||||
- `narratio run-stage --force <stage>` for targeted correction, then `resume`.
|
||||
2. fix root cause (config/input/credentials/service availability).
|
||||
3. continue with `resume`, or targeted `run-stage --force` followed by `resume`.
|
||||
|
||||
## Operational caveats
|
||||
|
||||
- `status` requires an explicit manifest path; there is no direct session-id lookup command.
|
||||
- S3 audio mode and local audio mode are mutually exclusive in session config.
|
||||
- Archive verifies stage prerequisites (`prepare` through `analyze`) before publishing.
|
||||
- By default, archive does not upload local `audio/` into run history.
|
||||
- Unknown CLI commands fail and print usage.
|
||||
- `status` requires explicit `--manifest`; there is no session-id lookup command.
|
||||
- local and S3 audio input modes are mutually exclusive.
|
||||
- archive publish requires upstream stages through `analyze` to be `succeeded`.
|
||||
- required promotion rules can fail when selected analyze artifacts did not generate a required file path.
|
||||
|
||||
@@ -663,6 +663,8 @@ Tests:
|
||||
|
||||
### Phase 8: Documentation and Examples
|
||||
|
||||
Status: complete.
|
||||
|
||||
Update documentation after the implementation is complete.
|
||||
|
||||
Recommended documentation changes:
|
||||
|
||||
@@ -6,11 +6,11 @@ Canonical operator troubleshooting guide for recurring implemented Narratio fail
|
||||
## Config file discovery failure
|
||||
|
||||
Symptom:
|
||||
- `run`, `plan`, `resume`, or `run-stage` fails saying config/session file was not found.
|
||||
- `run`, `plan`, `resume`, or `run-stage` fails with config/session not found.
|
||||
|
||||
Likely Cause:
|
||||
- `pipeline.yml` or `session.yml` is missing from default search paths.
|
||||
- Wrong working directory when relying on `./session.yml`.
|
||||
- `pipeline.yml` or `session.yml` is missing from discovery paths.
|
||||
- wrong working directory when relying on `./session.yml`.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
@@ -21,8 +21,8 @@ ls -l /usr/local/etc/narratio/pipeline.yml /etc/narratio/pipeline.yml
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- Pass explicit paths with `--config` and `--session`.
|
||||
- Or place files in documented discovery paths.
|
||||
- pass explicit `--config` and `--session`.
|
||||
- or place files in documented discovery paths.
|
||||
|
||||
Links:
|
||||
- [docs/config.md](./config.md)
|
||||
@@ -31,11 +31,11 @@ Links:
|
||||
## Session template rendering failure
|
||||
|
||||
Symptom:
|
||||
- Load fails with unresolved template placeholder or `session_id` mismatch.
|
||||
- load fails with unresolved placeholder or `session_id` mismatch.
|
||||
|
||||
Likely Cause:
|
||||
- `session.yml` contains `{{session_id}}`/`{{ session_id }}` but `--session-id` was omitted.
|
||||
- Provided `--session-id` does not match rendered `session_id`.
|
||||
- templated `session.yml` used without `--session-id`.
|
||||
- rendered `session_id` differs from passed `--session-id`.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
@@ -44,8 +44,8 @@ narratio plan --session ./session.yml --session-id 2026-04-04
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- Always pass `--session-id` when using template placeholders.
|
||||
- Ensure rendered `session_id` equals intended run session id.
|
||||
- pass `--session-id` when template placeholders are present.
|
||||
- ensure rendered `session_id` matches intended run session id.
|
||||
|
||||
Links:
|
||||
- [docs/config.md](./config.md)
|
||||
@@ -53,12 +53,11 @@ Links:
|
||||
## Strict YAML decode or validation failure
|
||||
|
||||
Symptom:
|
||||
- Config load fails with unknown field, missing required field, invalid duration, or invalid cross-field constraint.
|
||||
- config load fails with unknown field or validation error.
|
||||
|
||||
Likely Cause:
|
||||
- YAML key typo or stale field name.
|
||||
- Required fields missing.
|
||||
- Invalid value format (for example duration/URL/env var name).
|
||||
- typo/stale field name.
|
||||
- missing required fields or invalid constraints.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
@@ -67,12 +66,104 @@ narratio plan --config /path/to/pipeline.yml --session /path/to/session.yml --se
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- Correct fields/values to match canonical reference and examples.
|
||||
- Validate against `docs/examples/` shapes.
|
||||
- align fields/values to canonical config reference and examples.
|
||||
|
||||
Links:
|
||||
- [docs/config.md](./config.md)
|
||||
- [docs/examples/](./examples/)
|
||||
- [examples/](../examples/)
|
||||
|
||||
## `--artifacts` selection failure
|
||||
|
||||
Symptom:
|
||||
- `run`/`resume`/`run-stage` fails with invalid or unknown artifact selection.
|
||||
|
||||
Likely Cause:
|
||||
- `--artifacts` contains blank names or unknown artifact keys.
|
||||
- `pipeline.scriptorium.artifacts` missing while using `--artifacts`.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio run --config /path/to/pipeline.yml --session /path/to/session.yml --session-id 2026-04-04 --artifacts player_handout
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- use configured artifact keys only.
|
||||
- ensure `pipeline.scriptorium.artifacts` is defined.
|
||||
|
||||
Links:
|
||||
- [docs/cli.md](./cli.md)
|
||||
- [docs/config.md](./config.md)
|
||||
|
||||
## `run-stage --artifacts` on non-analyze stage
|
||||
|
||||
Symptom:
|
||||
- `run-stage` fails with `--artifacts is only supported for stage "analyze"`.
|
||||
|
||||
Likely Cause:
|
||||
- `--artifacts` was used with a non-`analyze` stage.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio run-stage --config /path/to/pipeline.yml --session /path/to/session.yml --session-id 2026-04-04 --artifacts session_recap polish
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- use `--artifacts` only with `run-stage ... analyze`.
|
||||
|
||||
Links:
|
||||
- [docs/cli.md](./cli.md)
|
||||
|
||||
## Configured artifact dependency/input validation failure
|
||||
|
||||
Symptom:
|
||||
- config validation fails for `depends_on`, `narratio.artifact.<name>` source, or artifact output path.
|
||||
|
||||
Likely Cause:
|
||||
- `narratio.artifact.<name>` source missing matching `depends_on` key.
|
||||
- dependency references unknown artifact key.
|
||||
- dependency self-reference or enabled dependency cycle.
|
||||
- artifact output path missing/invalid/outside `artifacts/` root.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio plan --config /path/to/pipeline.yml --session /path/to/session.yml --session-id 2026-04-04
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- ensure artifact-to-artifact inputs have explicit `depends_on` entries using artifact keys.
|
||||
- ensure referenced artifacts exist and define valid `output_path` values.
|
||||
- keep output paths relative and under `artifacts/`.
|
||||
|
||||
Links:
|
||||
- [docs/config.md](./config.md)
|
||||
- [docs/internal/stage-analyze.md](./internal/stage-analyze.md)
|
||||
|
||||
## Required configured artifact input unavailable at analyze time
|
||||
|
||||
Symptom:
|
||||
- analyze fails because configured input source is unavailable.
|
||||
|
||||
Likely Cause:
|
||||
- required upstream configured artifact was not selected/executed this run.
|
||||
- non-executable dependency output file is missing or invalid on disk.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio status --manifest /path/to/manifest.json
|
||||
narratio run-stage --config /path/to/pipeline.yml --session /path/to/session.yml --session-id 2026-04-04 --artifacts player_handout analyze
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- run analyze with needed artifacts selected.
|
||||
- or ensure dependency output file exists at configured path and is valid.
|
||||
|
||||
Links:
|
||||
- [docs/operations.md](./operations.md)
|
||||
- [docs/config.md](./config.md)
|
||||
|
||||
## Manifest/status path failure
|
||||
|
||||
@@ -80,9 +171,9 @@ Symptom:
|
||||
- `status` fails because manifest path is missing, unreadable, or invalid.
|
||||
|
||||
Likely Cause:
|
||||
- Wrong manifest path.
|
||||
- Manifest removed after cleanup.
|
||||
- Trying to run `status` without `--manifest`.
|
||||
- wrong manifest path.
|
||||
- manifest removed after cleanup.
|
||||
- `--manifest` omitted.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
@@ -92,8 +183,7 @@ ls -l /path/to/manifest.json
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- Use manifest path printed by `run`, `resume`, or `run-stage` output.
|
||||
- Re-run with correct session/config if inspecting a different session.
|
||||
- use manifest path printed by `run`, `resume`, or `run-stage`.
|
||||
|
||||
Links:
|
||||
- [docs/cli.md](./cli.md)
|
||||
@@ -102,11 +192,11 @@ Links:
|
||||
## Session lock conflict (`.lock`)
|
||||
|
||||
Symptom:
|
||||
- Run fails with lock conflict indicating session workdir is already locked.
|
||||
- run fails with lock conflict for session workdir.
|
||||
|
||||
Likely Cause:
|
||||
- Another Narratio process is actively running the same session.
|
||||
- Prior run exited unexpectedly and left a stale lock file.
|
||||
- another Narratio process is running same session.
|
||||
- stale lock from interrupted prior run.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
@@ -117,21 +207,21 @@ ps aux | grep narratio
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- If another run is active, wait for it to finish.
|
||||
- If no process is active and lock is stale, remove only that session `.lock` file and retry.
|
||||
- wait for active run to finish.
|
||||
- if no process is active, remove only stale session `.lock` file.
|
||||
|
||||
Links:
|
||||
- [docs/operations.md](./operations.md)
|
||||
- [docs/internal/workspace.md](./internal/workspace.md)
|
||||
|
||||
## Secrets env-dir or credential env failure
|
||||
## Secrets env-dir or credential-env failure
|
||||
|
||||
Symptom:
|
||||
- Startup fails loading secrets directory, or a stage fails because required credential env var is missing.
|
||||
- startup fails loading secrets directory, or stage fails due to missing credential env vars.
|
||||
|
||||
Likely Cause:
|
||||
- `pipeline.secrets.env_dir` path is wrong/unreadable.
|
||||
- Credential env var referenced in config is unset or empty.
|
||||
- invalid `pipeline.secrets.env_dir` path/permissions.
|
||||
- required credential env var unset/empty.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
@@ -141,24 +231,22 @@ env | grep -E 'AUDITA|OBJECT_STORAGE|AWS|SCRIPTORIUM'
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- Fix `pipeline.secrets.env_dir` path/permissions.
|
||||
- Ensure required env vars are set to non-empty values.
|
||||
- Keep secrets out of YAML; use env references only.
|
||||
- fix secrets directory and credential env vars.
|
||||
- keep secret values out of YAML.
|
||||
|
||||
Links:
|
||||
- [docs/config.md](./config.md)
|
||||
- [docs/operations.md](./operations.md)
|
||||
|
||||
## S3-audio prepare failure
|
||||
|
||||
Symptom:
|
||||
- `prepare` fails in S3 mode (no audio found, list/download failure, backend missing, path conflict).
|
||||
- `prepare` fails in S3 mode (listing/downloading/no audio/backend error).
|
||||
|
||||
Likely Cause:
|
||||
- Wrong `session.inputs.audio_s3.prefix`.
|
||||
- No `.flac` files at expected prefix.
|
||||
- Missing or invalid S3 backend credentials/config.
|
||||
- Conflicting audio-source settings (`audio_s3` plus local audio fields).
|
||||
- wrong `session.inputs.audio_s3.prefix`.
|
||||
- no `.flac` files at resolved prefix.
|
||||
- invalid/missing object-store credentials or backend config.
|
||||
- mixed local+S3 audio input config.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
@@ -167,24 +255,21 @@ narratio run-stage --config /path/to/pipeline.yml --session /path/to/session.yml
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- Ensure `audio_s3` is the only audio source configured for that session.
|
||||
- Confirm `.flac` objects exist under the resolved session audio prefix.
|
||||
- Fix S3 storage configuration and credentials.
|
||||
- configure exactly one audio source mode.
|
||||
- verify `.flac` files and storage access.
|
||||
|
||||
Links:
|
||||
- [docs/config.md](./config.md)
|
||||
- [docs/operations.md](./operations.md)
|
||||
- [docs/internal/stage-prepare.md](./internal/stage-prepare.md)
|
||||
|
||||
## Archive prerequisite or promotion/current-pointer failure
|
||||
## Archive promotion/current-pointer failure
|
||||
|
||||
Symptom:
|
||||
- `archive` fails due to prerequisite stage status, missing required promotion source, or pointer write failure.
|
||||
- archive fails on required promotion source missing or pointer write failure.
|
||||
|
||||
Likely Cause:
|
||||
- One or more prerequisite stages are not `succeeded`.
|
||||
- Required promoted artifact does not exist.
|
||||
- Remote upload failure before `current/run_id.txt` write.
|
||||
- required promoted file absent (including analyze outputs not generated for this run).
|
||||
- storage upload failed before `current/run_id.txt` commit marker write.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
@@ -194,36 +279,11 @@ narratio run-stage --config /path/to/pipeline.yml --session /path/to/session.yml
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- Resume or rerun failed upstream stage(s).
|
||||
- Ensure required promoted artifact paths exist locally before archive.
|
||||
- Retry archive after storage/connectivity issue is resolved.
|
||||
- rerun or resume upstream stages to generate required files.
|
||||
- adjust promotion rules to match files that must exist.
|
||||
- retry after storage issue is resolved.
|
||||
|
||||
Links:
|
||||
- [docs/operations.md](./operations.md)
|
||||
- [docs/config.md](./config.md)
|
||||
- [docs/internal/stage-archive.md](./internal/stage-archive.md)
|
||||
|
||||
## `run-stage` invalid stage name or invalid flags
|
||||
|
||||
Symptom:
|
||||
- `run-stage` fails with unknown stage or invalid flag/argument usage.
|
||||
|
||||
Likely Cause:
|
||||
- Stage name typo.
|
||||
- Missing positional stage argument.
|
||||
- Unsupported/incorrect flag syntax.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio run-stage --config /path/to/pipeline.yml --session /path/to/session.yml --session-id 2026-04-04 normalize
|
||||
```
|
||||
|
||||
Safe Fix:
|
||||
- Use only supported stage names.
|
||||
- Provide exactly one positional stage argument.
|
||||
- Align flags to documented command reference.
|
||||
|
||||
Links:
|
||||
- [docs/cli.md](./cli.md)
|
||||
- [docs/operations.md](./operations.md)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Values are safe placeholders and must be adapted per environment.
|
||||
|
||||
workspace:
|
||||
# Required: local workspace root.
|
||||
root: ./tmp/narratio-workspace
|
||||
# Optional: defaults to /var/lib/narratio.
|
||||
root: /var/lib/narratio/workspace
|
||||
# Optional: remove run-scoped workdir after successful archive commit.
|
||||
cleanup_after_archive: false
|
||||
|
||||
@@ -14,7 +14,7 @@ workspace:
|
||||
storage:
|
||||
# Optional storage backend selector; use "s3" for archive + S3 audio workflows.
|
||||
backend: s3
|
||||
# Legacy fields retained in schema for compatibility.
|
||||
# Compatibility fields retained in schema.
|
||||
bucket: ""
|
||||
prefix: ""
|
||||
s3:
|
||||
@@ -40,7 +40,7 @@ archive:
|
||||
# Optional booleans; defaults are true.
|
||||
enabled: true
|
||||
upload_run: true
|
||||
# Optional promotions; defaults shown explicitly.
|
||||
# Optional promotion rules; required files fail archive if missing.
|
||||
promote_artifacts:
|
||||
- from: transcripts/trimmed.json
|
||||
to: transcripts/trimmed.json
|
||||
@@ -48,6 +48,9 @@ archive:
|
||||
- from: artifacts/session_recap.md
|
||||
to: artifacts/session_recap.md
|
||||
required: true
|
||||
- from: artifacts/player_handout.md
|
||||
to: artifacts/player_handout.md
|
||||
required: false
|
||||
|
||||
whisperx:
|
||||
# Required.
|
||||
@@ -118,6 +121,7 @@ scriptorium:
|
||||
timeout: 10m
|
||||
render_debug: false
|
||||
artifacts:
|
||||
# Configured artifact keys map to source IDs narratio.artifact.<key>.
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
@@ -140,6 +144,29 @@ scriptorium:
|
||||
previous_session_id: true
|
||||
output_kind: session_recap
|
||||
|
||||
# Example dependent artifact:
|
||||
# - depends_on entries use artifact keys.
|
||||
# - narratio.artifact.<key> sources require matching depends_on membership.
|
||||
player_handout:
|
||||
enabled: true
|
||||
depends_on:
|
||||
- session_recap
|
||||
prompt_id: dnd.player_handout
|
||||
profile_id: local-fast
|
||||
output_path: artifacts/player_handout.md
|
||||
timeout: 10m
|
||||
inputs:
|
||||
recap:
|
||||
source: narratio.artifact.session_recap
|
||||
required: true
|
||||
transcript:
|
||||
source: narratio.transcript.trimmed
|
||||
required: true
|
||||
vars:
|
||||
session_id: true
|
||||
campaign_name: true
|
||||
output_kind: player_handout
|
||||
|
||||
analyzer:
|
||||
# Optional adapter settings.
|
||||
binary_path: ""
|
||||
@@ -25,6 +25,9 @@ archive:
|
||||
- from: artifacts/session_recap.md
|
||||
to: artifacts/session_recap.md
|
||||
required: true
|
||||
- from: artifacts/player_handout.md
|
||||
to: artifacts/player_handout.md
|
||||
required: false
|
||||
|
||||
whisperx:
|
||||
transcribe_url: "https://transcription.example.com/transcribe"
|
||||
@@ -87,6 +90,24 @@ scriptorium:
|
||||
campaign_name: true
|
||||
previous_session_id: true
|
||||
output_kind: session_recap
|
||||
player_handout:
|
||||
enabled: true
|
||||
depends_on:
|
||||
- session_recap
|
||||
prompt_id: dnd.player_handout
|
||||
profile_id: local-fast
|
||||
output_path: artifacts/player_handout.md
|
||||
timeout: 10m
|
||||
inputs:
|
||||
recap:
|
||||
source: narratio.artifact.session_recap
|
||||
required: true
|
||||
transcript:
|
||||
source: narratio.transcript.trimmed
|
||||
required: true
|
||||
vars:
|
||||
session_id: true
|
||||
output_kind: player_handout
|
||||
|
||||
analyzer:
|
||||
timeout: 2m
|
||||
@@ -204,7 +204,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
return nil, fmt.Errorf("stage %q failed: %w", s.Name(), err)
|
||||
}
|
||||
|
||||
outputs := mapResultOutputs(result, runID)
|
||||
outputs := mapResultOutputs(s.Name(), result, runID)
|
||||
succeededAt := nowUTC()
|
||||
m.MarkStageSucceeded(s.Name(), succeededAt, outputs)
|
||||
applyStageResultToManifest(m, s.Name(), result)
|
||||
@@ -388,7 +388,7 @@ func fileExists(path string) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
func mapResultOutputs(result *stage.StageResult, runID string) []manifest.ArtifactRecord {
|
||||
func mapResultOutputs(stageName string, result *stage.StageResult, runID string) []manifest.ArtifactRecord {
|
||||
if result == nil || len(result.Outputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -400,8 +400,15 @@ func mapResultOutputs(result *stage.StageResult, runID string) []manifest.Artifa
|
||||
if localPath == "" {
|
||||
localPath = ref.RelativePath
|
||||
}
|
||||
kind := ref.Kind
|
||||
sourceID := ""
|
||||
if stageName == "analyze" {
|
||||
sourceID = artifacts.ConfiguredArtifactSourceID(ref.Kind)
|
||||
kind = "scriptorium_artifact"
|
||||
}
|
||||
out = append(out, manifest.ArtifactRecord{
|
||||
Kind: ref.Kind,
|
||||
Kind: kind,
|
||||
SourceID: sourceID,
|
||||
LocalPath: localPath,
|
||||
ProducerRunID: runID,
|
||||
RemoteKey: ref.RemoteKey,
|
||||
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -58,6 +59,61 @@ func (s captureSelectedArtifactsStage) Run(_ context.Context, env *stage.Env, _
|
||||
return &stage.StageResult{Metadata: map[string]any{"captured": true}}, nil
|
||||
}
|
||||
|
||||
type analyzeOutputStage struct {
|
||||
output artifacts.Ref
|
||||
}
|
||||
|
||||
func (s analyzeOutputStage) Name() string { return "analyze" }
|
||||
func (s analyzeOutputStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||
func (s analyzeOutputStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||
return &stage.StageResult{
|
||||
Outputs: []artifacts.Ref{s.output},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type selectedAnalyzeArtifactStage struct {
|
||||
expected []string
|
||||
}
|
||||
|
||||
func (s selectedAnalyzeArtifactStage) Name() string { return "analyze" }
|
||||
func (s selectedAnalyzeArtifactStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||
func (s selectedAnalyzeArtifactStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||
if len(env.SelectedAnalyzeArtifacts) != len(s.expected) {
|
||||
return nil, fmt.Errorf("selected artifacts len = %d, want %d", len(env.SelectedAnalyzeArtifacts), len(s.expected))
|
||||
}
|
||||
for i := range s.expected {
|
||||
if env.SelectedAnalyzeArtifacts[i] != s.expected[i] {
|
||||
return nil, fmt.Errorf("selected artifacts[%d] = %q, want %q", i, env.SelectedAnalyzeArtifacts[i], s.expected[i])
|
||||
}
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(
|
||||
artifacts.SessionWorkDirForCampaign(env.Config.Pipeline.Workspace.Root, env.Config.Session.Campaign, m.SessionID),
|
||||
"artifacts",
|
||||
"player_handout.md",
|
||||
)
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir artifact dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(outputPath, []byte("player handout\n"), 0o644); err != nil {
|
||||
return nil, fmt.Errorf("write player handout: %w", err)
|
||||
}
|
||||
|
||||
return &stage.StageResult{
|
||||
Outputs: []artifacts.Ref{
|
||||
{
|
||||
Kind: "player_handout",
|
||||
Category: "artifacts",
|
||||
RelativePath: "artifacts/player_handout.md",
|
||||
AbsolutePath: outputPath,
|
||||
},
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"stage": "analyze",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestExecuteStagesPropagatesSelectedArtifactsToEnv(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
|
||||
@@ -78,6 +134,124 @@ func TestExecuteStagesPropagatesSelectedArtifactsToEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesAnalyzeOutputsPersistAsScriptoriumArtifacts(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
storeForPaths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
||||
sessionPaths := storeForPaths.SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
outputPath := filepath.Join(sessionPaths.ArtifactsDir, "session_recap.md")
|
||||
|
||||
stageToRun := analyzeOutputStage{
|
||||
output: artifacts.Ref{
|
||||
Kind: "session_recap",
|
||||
Category: "artifacts",
|
||||
RelativePath: "artifacts/session_recap.md",
|
||||
AbsolutePath: outputPath,
|
||||
},
|
||||
}
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
sessionManifest, err := store.Load(context.Background(), summary.ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load session manifest: %v", err)
|
||||
}
|
||||
sessionStage := sessionManifest.Stages["analyze"]
|
||||
if sessionStage == nil {
|
||||
t.Fatal("session manifest analyze stage missing")
|
||||
}
|
||||
if len(sessionStage.Outputs) != 1 {
|
||||
t.Fatalf("session analyze outputs len = %d, want 1", len(sessionStage.Outputs))
|
||||
}
|
||||
sessionOutput := sessionStage.Outputs[0]
|
||||
if sessionOutput.Kind != "scriptorium_artifact" {
|
||||
t.Fatalf("session output kind = %q, want scriptorium_artifact", sessionOutput.Kind)
|
||||
}
|
||||
if sessionOutput.SourceID != "narratio.artifact.session_recap" {
|
||||
t.Fatalf("session output source_id = %q, want narratio.artifact.session_recap", sessionOutput.SourceID)
|
||||
}
|
||||
if sessionOutput.LocalPath != outputPath {
|
||||
t.Fatalf("session output local_path = %q, want %q", sessionOutput.LocalPath, outputPath)
|
||||
}
|
||||
|
||||
runManifest, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load run manifest: %v", err)
|
||||
}
|
||||
runStage := runManifest.Stages["analyze"]
|
||||
if runStage == nil {
|
||||
t.Fatal("run manifest analyze stage missing")
|
||||
}
|
||||
if len(runStage.Outputs) != 1 {
|
||||
t.Fatalf("run analyze outputs len = %d, want 1", len(runStage.Outputs))
|
||||
}
|
||||
runOutput := runStage.Outputs[0]
|
||||
if runOutput.Kind != "scriptorium_artifact" {
|
||||
t.Fatalf("run output kind = %q, want scriptorium_artifact", runOutput.Kind)
|
||||
}
|
||||
if runOutput.SourceID != "narratio.artifact.session_recap" {
|
||||
t.Fatalf("run output source_id = %q, want narratio.artifact.session_recap", runOutput.SourceID)
|
||||
}
|
||||
if runOutput.LocalPath != outputPath {
|
||||
t.Fatalf("run output local_path = %q, want %q", runOutput.LocalPath, outputPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesArchiveFailsWhenRequiredRecapPromotionMissingForSelectedArtifacts(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{
|
||||
Bucket: "my-dnd-archive",
|
||||
RootPrefix: "dnd",
|
||||
}
|
||||
cfg.Pipeline.Archive = &config.ArchiveConfig{
|
||||
Enabled: boolPtr(true),
|
||||
UploadRun: boolPtr(true),
|
||||
PromoteArtifacts: []config.ArchivePromotionRule{
|
||||
{From: "artifacts/session_recap.md", To: "artifacts/session_recap.md", Required: boolPtr(true)},
|
||||
},
|
||||
}
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
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"} {
|
||||
seed.MarkStageSucceeded(stageName, time.Now().UTC(), nil)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||
t.Fatalf("Save manifest error = %v", err)
|
||||
}
|
||||
|
||||
archiveStageImpl, err := stage.Select("archive")
|
||||
if err != nil {
|
||||
t.Fatalf("Select(archive) error = %v", err)
|
||||
}
|
||||
|
||||
_, err = executeStages(
|
||||
context.Background(),
|
||||
cfg,
|
||||
[]stage.Stage{
|
||||
selectedAnalyzeArtifactStage{expected: []string{"player_handout"}},
|
||||
archiveStageImpl,
|
||||
},
|
||||
RunOptions{
|
||||
SelectedArtifacts: []string{"player_handout"},
|
||||
Env: &Env{ObjectStore: &storage.FakeBackend{}},
|
||||
},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected archive promotion failure, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "required promotion source missing") {
|
||||
t.Fatalf("error = %q, want required promotion source missing", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const (
|
||||
ArtifactTranscriptFull = "narratio.transcript.full"
|
||||
ArtifactTranscriptTrimmed = "narratio.transcript.trimmed"
|
||||
ArtifactBoundsSession = "narratio.bounds.session"
|
||||
ArtifactSessionRecap = "narratio.artifact.session_recap"
|
||||
)
|
||||
|
||||
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
||||
@@ -77,13 +76,6 @@ var artifactRegistry = map[string]artifactSpec{
|
||||
OutputKind: "session_bounds",
|
||||
ContentKind: contentJSON,
|
||||
},
|
||||
ArtifactSessionRecap: {
|
||||
ID: ArtifactSessionRecap,
|
||||
CanonicalRelPath: "artifacts/session_recap.md",
|
||||
ProducerStage: "analyze",
|
||||
OutputKind: "session_recap",
|
||||
ContentKind: contentText,
|
||||
},
|
||||
}
|
||||
|
||||
// ResolvedSessionArtifact describes one session-level artifact lookup result.
|
||||
|
||||
@@ -21,6 +21,7 @@ func TestNormalizeSessionArtifactSource(t *testing.T) {
|
||||
{name: "legacy alias processed unsupported", source: "processed_transcript", wantErr: "unsupported artifact source"},
|
||||
{name: "legacy alias normalized unsupported", source: "normalized_transcript", wantErr: "unsupported artifact source"},
|
||||
{name: "legacy alias trimmed unsupported", source: "trimmed_transcript", wantErr: "unsupported artifact source"},
|
||||
{name: "configured source unsupported in built-in normalization", source: "narratio.artifact.session_recap", wantErr: "unsupported artifact source"},
|
||||
{name: "canonical", source: ArtifactTranscriptTrimmed, wantID: ArtifactTranscriptTrimmed},
|
||||
{name: "unsupported", source: "narratio.unknown", wantErr: "unsupported artifact source"},
|
||||
}
|
||||
|
||||
@@ -891,7 +891,7 @@ func TestValidateMissingAudioSource(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExamplesLoadAndValidate(t *testing.T) {
|
||||
examplesDir := filepath.Join("..", "..", "docs", "examples")
|
||||
examplesDir := filepath.Join("..", "..", "examples")
|
||||
tests := []struct {
|
||||
name string
|
||||
pipelineFile string
|
||||
|
||||
@@ -28,6 +28,7 @@ type InputRecord struct {
|
||||
// ArtifactRecord captures one produced artifact and optional remote metadata.
|
||||
type ArtifactRecord struct {
|
||||
Kind string `json:"kind"`
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
LocalPath string `json:"local_path"`
|
||||
// ProducerRunID identifies the run that produced this durable artifact.
|
||||
ProducerRunID string `json:"producer_run_id,omitempty"`
|
||||
|
||||
@@ -28,12 +28,23 @@ func (analyzeStage) Declares() IODecl {
|
||||
{Kind: "transcript_normalized", Category: "transcripts", RelativePath: "transcripts/normalized.json"},
|
||||
{Kind: "transcript_trimmed", Category: "transcripts", RelativePath: "transcripts/trimmed.json"},
|
||||
},
|
||||
Outputs: []artifacts.Ref{
|
||||
{Kind: "session_recap", Category: "artifacts", RelativePath: "artifacts/session_recap.md"},
|
||||
},
|
||||
Outputs: nil,
|
||||
}
|
||||
}
|
||||
|
||||
type analyzeArtifactExecutionPlan struct {
|
||||
Name string
|
||||
Cfg config.ScriptoriumArtifactConfig
|
||||
}
|
||||
|
||||
type analyzeArtifactExecutionResult struct {
|
||||
Output artifacts.Ref
|
||||
Logs []string
|
||||
GeneratedConfigs []string
|
||||
Metadata map[string]any
|
||||
ReusedArtifacts []map[string]any
|
||||
}
|
||||
|
||||
func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
|
||||
if env == nil || env.Config == nil {
|
||||
return nil, fmt.Errorf("analyze: stage environment config is required")
|
||||
@@ -65,27 +76,11 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
return nil, fmt.Errorf("analyze: resolve run-stage layout: %w", err)
|
||||
}
|
||||
if env.Config.Pipeline.Scriptorium == nil {
|
||||
return &StageResult{
|
||||
Metadata: map[string]any{
|
||||
"stage": "analyze",
|
||||
"skipped": true,
|
||||
"reason": "pipeline.scriptorium is not configured",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
artifactName, artifactCfg, skipReason, err := selectAnalyzeArtifact(env.Config.Pipeline.Scriptorium)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: %w", err)
|
||||
}
|
||||
if skipReason != "" {
|
||||
return &StageResult{
|
||||
Metadata: map[string]any{
|
||||
"stage": "analyze",
|
||||
"skipped": true,
|
||||
"reason": skipReason,
|
||||
},
|
||||
}, nil
|
||||
return &StageResult{Metadata: map[string]any{
|
||||
"stage": "analyze",
|
||||
"skipped": true,
|
||||
"reason": "pipeline.scriptorium is not configured",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog(paths, env.Config.Pipeline.Scriptorium, env.SelectedAnalyzeArtifacts)
|
||||
@@ -93,41 +88,274 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err)
|
||||
}
|
||||
|
||||
plans, skipReason, err := buildAnalyzeExecutionPlans(env.Config.Pipeline.Scriptorium, runtimeCatalog)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: %w", err)
|
||||
}
|
||||
if skipReason != "" {
|
||||
return &StageResult{Metadata: map[string]any{
|
||||
"stage": "analyze",
|
||||
"skipped": true,
|
||||
"reason": skipReason,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
transcriptRefs := discoverAnalyzeTranscriptRefs(m, paths)
|
||||
sessionDir := filepath.Dir(strings.TrimSpace(env.Config.SessionPath))
|
||||
|
||||
outputs := make([]artifacts.Ref, 0, len(plans))
|
||||
logs := []string{}
|
||||
generatedConfigs := []string{}
|
||||
artifactMetadata := make([]map[string]any, 0, len(plans))
|
||||
reusedArtifacts := []map[string]any{}
|
||||
reusedSeen := map[string]struct{}{}
|
||||
|
||||
for _, plan := range plans {
|
||||
artifactResult, err := executeAnalyzeArtifact(
|
||||
ctx,
|
||||
env,
|
||||
m,
|
||||
paths,
|
||||
runLayout,
|
||||
sessionID,
|
||||
sessionDir,
|
||||
plan,
|
||||
transcriptRefs,
|
||||
runtimeCatalog,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outputs = append(outputs, artifactResult.Output)
|
||||
logs = append(logs, artifactResult.Logs...)
|
||||
generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...)
|
||||
artifactMetadata = append(artifactMetadata, artifactResult.Metadata)
|
||||
for _, reused := range artifactResult.ReusedArtifacts {
|
||||
sourceID, _ := reused["source_id"].(string)
|
||||
path, _ := reused["path"].(string)
|
||||
key := sourceID + "|" + path
|
||||
if _, exists := reusedSeen[key]; exists {
|
||||
continue
|
||||
}
|
||||
reusedSeen[key] = struct{}{}
|
||||
reusedArtifacts = append(reusedArtifacts, reused)
|
||||
}
|
||||
|
||||
sourceID, ok := runtimeCatalog.SourceIDForConfiguredKey(plan.Name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("analyze: source id not found for artifact %q", plan.Name)
|
||||
}
|
||||
if err := runtimeCatalog.MarkAvailableGenerated(sourceID, artifactResult.Output.AbsolutePath); err != nil {
|
||||
return nil, fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
metadata := map[string]any{
|
||||
"stage": "analyze",
|
||||
"selected_artifacts": extractPlanNames(plans),
|
||||
"generated_artifacts": artifactMetadata,
|
||||
"reused_artifacts": reusedArtifacts,
|
||||
"artifact_count": len(artifactMetadata),
|
||||
"reused_artifact_count": len(reusedArtifacts),
|
||||
}
|
||||
if len(artifactMetadata) == 1 {
|
||||
for k, v := range artifactMetadata[0] {
|
||||
metadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return &StageResult{
|
||||
Outputs: outputs,
|
||||
Logs: dedupeAndSortPaths(logs),
|
||||
GeneratedConfigs: dedupeAndSortPaths(generatedConfigs),
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildAnalyzeExecutionPlans(
|
||||
scriptoriumCfg *config.ScriptoriumConfig,
|
||||
catalog *artifacts.ArtifactCatalog,
|
||||
) ([]analyzeArtifactExecutionPlan, string, error) {
|
||||
if scriptoriumCfg == nil {
|
||||
return nil, "pipeline.scriptorium is not configured", nil
|
||||
}
|
||||
if len(scriptoriumCfg.Artifacts) == 0 {
|
||||
return nil, "no scriptorium artifacts configured", nil
|
||||
}
|
||||
|
||||
entries := catalog.ListConfigured()
|
||||
selected := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Executable {
|
||||
selected = append(selected, entry.ConfiguredKey)
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return nil, "no selected scriptorium artifacts to execute", nil
|
||||
}
|
||||
|
||||
ordered, err := orderSelectedScriptoriumArtifacts(scriptoriumCfg.Artifacts, selected, catalog)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
plans := make([]analyzeArtifactExecutionPlan, 0, len(ordered))
|
||||
for _, name := range ordered {
|
||||
artifactCfg, ok := scriptoriumCfg.Artifacts[name]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("selected artifact %q is not configured", name)
|
||||
}
|
||||
plans = append(plans, analyzeArtifactExecutionPlan{Name: name, Cfg: artifactCfg})
|
||||
}
|
||||
return plans, "", nil
|
||||
}
|
||||
|
||||
func orderSelectedScriptoriumArtifacts(
|
||||
artifactsCfg map[string]config.ScriptoriumArtifactConfig,
|
||||
selected []string,
|
||||
catalog *artifacts.ArtifactCatalog,
|
||||
) ([]string, error) {
|
||||
selectedSet := map[string]struct{}{}
|
||||
for _, key := range selected {
|
||||
trimmed := strings.TrimSpace(key)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("selected artifact key must be non-empty")
|
||||
}
|
||||
selectedSet[trimmed] = struct{}{}
|
||||
}
|
||||
|
||||
for selectedKey := range selectedSet {
|
||||
cfg, ok := artifactsCfg[selectedKey]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("selected artifact %q is not configured", selectedKey)
|
||||
}
|
||||
for _, dep := range cfg.DependsOn {
|
||||
trimmedDep := strings.TrimSpace(dep)
|
||||
if trimmedDep == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := selectedSet[trimmedDep]; ok {
|
||||
continue
|
||||
}
|
||||
sourceID, ok := catalog.SourceIDForConfiguredKey(trimmedDep)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep)
|
||||
}
|
||||
entry, ok := catalog.Lookup(sourceID)
|
||||
if !ok || !entry.Available {
|
||||
return nil, fmt.Errorf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indegree := map[string]int{}
|
||||
edges := map[string][]string{}
|
||||
for key := range selectedSet {
|
||||
indegree[key] = 0
|
||||
}
|
||||
for key := range selectedSet {
|
||||
cfg := artifactsCfg[key]
|
||||
for _, dep := range cfg.DependsOn {
|
||||
trimmedDep := strings.TrimSpace(dep)
|
||||
if _, ok := selectedSet[trimmedDep]; !ok {
|
||||
continue
|
||||
}
|
||||
edges[trimmedDep] = append(edges[trimmedDep], key)
|
||||
indegree[key]++
|
||||
}
|
||||
}
|
||||
|
||||
for key := range edges {
|
||||
sort.Strings(edges[key])
|
||||
}
|
||||
|
||||
ready := make([]string, 0, len(indegree))
|
||||
for key, degree := range indegree {
|
||||
if degree == 0 {
|
||||
ready = append(ready, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(ready)
|
||||
|
||||
order := make([]string, 0, len(selectedSet))
|
||||
for len(ready) > 0 {
|
||||
node := ready[0]
|
||||
ready = ready[1:]
|
||||
order = append(order, node)
|
||||
for _, dep := range edges[node] {
|
||||
indegree[dep]--
|
||||
if indegree[dep] == 0 {
|
||||
ready = append(ready, dep)
|
||||
sort.Strings(ready)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(order) != len(selectedSet) {
|
||||
return nil, fmt.Errorf("selected scriptorium artifacts contain a dependency cycle")
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func executeAnalyzeArtifact(
|
||||
ctx context.Context,
|
||||
env *Env,
|
||||
m *manifest.Manifest,
|
||||
paths artifacts.SessionPaths,
|
||||
runLayout runStageLayout,
|
||||
sessionID string,
|
||||
sessionDir string,
|
||||
plan analyzeArtifactExecutionPlan,
|
||||
transcriptRefs analyzeTranscriptInputs,
|
||||
runtimeCatalog *artifacts.ArtifactCatalog,
|
||||
) (*analyzeArtifactExecutionResult, error) {
|
||||
artifactName := plan.Name
|
||||
artifactCfg := plan.Cfg
|
||||
|
||||
inputPaths := map[string]string{}
|
||||
omittedOptionalInputs := []string{}
|
||||
sessionDir := filepath.Dir(strings.TrimSpace(env.Config.SessionPath))
|
||||
reusedArtifacts := []map[string]any{}
|
||||
|
||||
inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs)
|
||||
for _, inputName := range inputNames {
|
||||
inputCfg := artifactCfg.Inputs[inputName]
|
||||
resolvedPath, resolved, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir, runtimeCatalog)
|
||||
resolvedPath, resolved, resolvedArtifact, resolveErr := resolveScriptoriumInput(inputName, inputCfg, m, paths, sessionDir, runtimeCatalog)
|
||||
if resolveErr != nil {
|
||||
return nil, fmt.Errorf("analyze: resolve input %q: %w", inputName, resolveErr)
|
||||
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolveErr)
|
||||
}
|
||||
if !resolved {
|
||||
if inputCfg.Required {
|
||||
return nil, fmt.Errorf("analyze: required input %q could not be resolved", inputName)
|
||||
return nil, fmt.Errorf("analyze: required input %q for artifact %q could not be resolved", inputName, artifactName)
|
||||
}
|
||||
omittedOptionalInputs = append(omittedOptionalInputs, inputName)
|
||||
continue
|
||||
}
|
||||
inputPaths[inputName] = resolvedPath
|
||||
if resolvedArtifact != nil && resolvedArtifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk {
|
||||
reusedArtifacts = append(reusedArtifacts, map[string]any{
|
||||
"name": configuredArtifactNameFromSourceID(resolvedArtifact.ID),
|
||||
"source_id": resolvedArtifact.ID,
|
||||
"path": resolvedArtifact.Path,
|
||||
"provenance": resolvedArtifact.Provenance,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
vars, err := buildScriptoriumVars(artifactCfg.Vars, env.Config.Session)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: resolve vars: %w", err)
|
||||
return nil, fmt.Errorf("analyze: resolve vars for artifact %q: %w", artifactName, err)
|
||||
}
|
||||
|
||||
canonicalOutputPath, err := resolveScriptoriumOutputPath(paths, artifactCfg.OutputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: resolve output path: %w", err)
|
||||
return nil, fmt.Errorf("analyze: resolve output path for artifact %q: %w", artifactName, err)
|
||||
}
|
||||
outputPath, err := runLocalPathForCanonical(runLayout, paths, canonicalOutputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: resolve run-local output path: %w", err)
|
||||
return nil, fmt.Errorf("analyze: resolve run-local output path for artifact %q: %w", artifactName, err)
|
||||
}
|
||||
|
||||
stdoutLogPath := filepath.Join(paths.LogsDir, "scriptorium."+artifactName+".stdout.log")
|
||||
stderrLogPath := filepath.Join(paths.LogsDir, "scriptorium."+artifactName+".stderr.log")
|
||||
generatedConfigPath := filepath.Join(paths.ConfigDir, "scriptorium."+artifactName+".generated.yml")
|
||||
@@ -139,14 +367,17 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
|
||||
timeout, err := resolveScriptoriumTimeout(env.Config.Pipeline.Scriptorium.Timeout, artifactCfg.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: resolve timeout: %w", err)
|
||||
return nil, fmt.Errorf("analyze: resolve timeout for artifact %q: %w", artifactName, err)
|
||||
}
|
||||
|
||||
logPaths := []string{}
|
||||
generatedConfigs := []string{}
|
||||
meta := map[string]any{
|
||||
"stage": "analyze",
|
||||
"name": artifactName,
|
||||
"artifact_name": artifactName,
|
||||
"source_id": artifacts.ConfiguredArtifactSourceID(artifactName),
|
||||
"output_kind": "scriptorium_artifact",
|
||||
"prompt_id": artifactCfg.PromptID,
|
||||
"profile_id": artifactCfg.ProfileID,
|
||||
"binary": env.Config.Pipeline.Scriptorium.Binary,
|
||||
@@ -194,10 +425,10 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
}
|
||||
renderRes, renderErr := env.Scriptorium.RenderArtifact(ctx, renderReq)
|
||||
if renderErr != nil {
|
||||
return nil, fmt.Errorf("analyze: scriptorium render failed: %w", renderErr)
|
||||
return nil, fmt.Errorf("analyze: scriptorium render failed for artifact %q: %w", artifactName, renderErr)
|
||||
}
|
||||
if renderRes.ValidationFailed {
|
||||
return nil, fmt.Errorf("analyze: scriptorium render returned validation_failed=true")
|
||||
return nil, fmt.Errorf("analyze: scriptorium render returned validation_failed=true for artifact %q", artifactName)
|
||||
}
|
||||
finalRenderOutputPath := coalesceString(renderRes.OutputPath, renderReq.OutputPath)
|
||||
if err := requireNonEmptyFile(finalRenderOutputPath, artifactName+" render output"); err != nil {
|
||||
@@ -245,7 +476,8 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
if runErr != nil {
|
||||
if res.ValidationFailed {
|
||||
return nil, fmt.Errorf(
|
||||
"analyze: scriptorium validation failed (prompt_id=%q, output_path=%q, exit_code=%d, stdout_log=%q, stderr_log=%q): %w",
|
||||
"analyze: scriptorium validation failed (artifact=%q, prompt_id=%q, output_path=%q, exit_code=%d, stdout_log=%q, stderr_log=%q): %w",
|
||||
artifactName,
|
||||
req.PromptID,
|
||||
coalesceString(res.OutputPath, req.OutputPath),
|
||||
res.ExitCode,
|
||||
@@ -254,10 +486,10 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
runErr,
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("analyze: scriptorium run failed: %w", runErr)
|
||||
return nil, fmt.Errorf("analyze: scriptorium run failed for artifact %q: %w", artifactName, runErr)
|
||||
}
|
||||
if res.ValidationFailed {
|
||||
return nil, fmt.Errorf("analyze: scriptorium run returned validation_failed=true")
|
||||
return nil, fmt.Errorf("analyze: scriptorium run returned validation_failed=true for artifact %q", artifactName)
|
||||
}
|
||||
|
||||
finalOutputPath := coalesceString(res.OutputPath, req.OutputPath)
|
||||
@@ -270,12 +502,13 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: promote artifact output: %w", err)
|
||||
return nil, fmt.Errorf("analyze: promote artifact output for %q: %w", artifactName, err)
|
||||
}
|
||||
|
||||
logPaths = append(logPaths, stdoutLogPath, stderrLogPath)
|
||||
generatedConfigs = append(generatedConfigs, generatedConfigPath)
|
||||
meta["run_output_path"] = finalOutputPath
|
||||
meta["path"] = canonicalOutputPath
|
||||
meta["output_path"] = canonicalOutputPath
|
||||
meta["generated_config_path"] = generatedConfigPath
|
||||
meta["stdout_log_path"] = stdoutLogPath
|
||||
@@ -290,39 +523,38 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
meta["adapter_generated_config"] = res.GeneratedConfigPath
|
||||
meta["adapter_stdout_log_path"] = res.StdoutLogPath
|
||||
meta["adapter_stderr_log_path"] = res.StderrLogPath
|
||||
meta["provenance"] = artifacts.ArtifactProvenanceGeneratedCurrentAnalyzeRun
|
||||
if res.Metadata != nil {
|
||||
meta["adapter_metadata"] = res.Metadata
|
||||
}
|
||||
|
||||
return &StageResult{
|
||||
Outputs: []artifacts.Ref{promotedArtifact},
|
||||
return &analyzeArtifactExecutionResult{
|
||||
Output: promotedArtifact,
|
||||
Logs: logPaths,
|
||||
GeneratedConfigs: generatedConfigs,
|
||||
Metadata: meta,
|
||||
ReusedArtifacts: reusedArtifacts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func selectAnalyzeArtifact(cfg *config.ScriptoriumConfig) (string, config.ScriptoriumArtifactConfig, string, error) {
|
||||
if cfg == nil {
|
||||
return "", config.ScriptoriumArtifactConfig{}, "pipeline.scriptorium is not configured", nil
|
||||
func extractPlanNames(plans []analyzeArtifactExecutionPlan) []string {
|
||||
if len(plans) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
out = append(out, plan.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
enabled := []string{}
|
||||
for name, artifact := range cfg.Artifacts {
|
||||
if artifact.Enabled {
|
||||
enabled = append(enabled, name)
|
||||
}
|
||||
func configuredArtifactNameFromSourceID(sourceID string) string {
|
||||
trimmed := strings.TrimSpace(sourceID)
|
||||
const prefix = "narratio.artifact."
|
||||
if !strings.HasPrefix(trimmed, prefix) {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(enabled)
|
||||
if len(enabled) == 0 {
|
||||
return "", config.ScriptoriumArtifactConfig{}, "no enabled scriptorium artifacts configured", nil
|
||||
}
|
||||
|
||||
sessionRecapCfg, ok := cfg.Artifacts["session_recap"]
|
||||
if !ok || !sessionRecapCfg.Enabled {
|
||||
return "", config.ScriptoriumArtifactConfig{}, "", fmt.Errorf("only artifacts.session_recap is supported in this analyze implementation; enabled=%s", strings.Join(enabled, ","))
|
||||
}
|
||||
return "session_recap", sessionRecapCfg, "", nil
|
||||
return strings.TrimPrefix(trimmed, prefix)
|
||||
}
|
||||
|
||||
func discoverProcessedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) {
|
||||
@@ -397,45 +629,47 @@ func resolveScriptoriumInput(
|
||||
paths artifacts.SessionPaths,
|
||||
sessionDir string,
|
||||
runtimeCatalog *artifacts.ArtifactCatalog,
|
||||
) (string, bool, error) {
|
||||
switch strings.TrimSpace(inputCfg.Source) {
|
||||
) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
|
||||
source := strings.TrimSpace(inputCfg.Source)
|
||||
switch source {
|
||||
case "previous_session_artifact":
|
||||
if strings.TrimSpace(inputCfg.Path) == "" {
|
||||
return "", false, nil
|
||||
return "", false, nil, nil
|
||||
}
|
||||
resolved := resolveInputPathForRead(paths, sessionDir, inputCfg.Path)
|
||||
if err := requireFile(resolved, "scriptorium input "+inputName); err != nil {
|
||||
return "", false, nil
|
||||
return "", false, nil, nil
|
||||
}
|
||||
return resolved, true, nil
|
||||
return resolved, true, nil, nil
|
||||
default:
|
||||
resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, inputCfg.Source, runtimeCatalog)
|
||||
resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
|
||||
if err == nil {
|
||||
return resolved.Path, true, nil
|
||||
copy := resolved
|
||||
return resolved.Path, true, ©, nil
|
||||
}
|
||||
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
|
||||
if artifacts.IsConfiguredArtifactSource(inputCfg.Source) {
|
||||
if artifacts.IsConfiguredArtifactSource(source) {
|
||||
if inputCfg.Required {
|
||||
return "", false, fmt.Errorf("configured artifact source %q is unavailable", inputCfg.Source)
|
||||
return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source)
|
||||
}
|
||||
return "", false, nil
|
||||
return "", false, nil, nil
|
||||
}
|
||||
normalized, normalizeErr := artifacts.NormalizeSessionArtifactSource(inputCfg.Source)
|
||||
normalized, normalizeErr := artifacts.NormalizeSessionArtifactSource(source)
|
||||
if normalizeErr != nil {
|
||||
return "", false, normalizeErr
|
||||
return "", false, nil, normalizeErr
|
||||
}
|
||||
switch normalized {
|
||||
case artifacts.ArtifactTranscriptPolished:
|
||||
return "", false, nil
|
||||
return "", false, nil, nil
|
||||
case artifacts.ArtifactTranscriptFull:
|
||||
return "", false, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
|
||||
return "", false, nil, fmt.Errorf("normalized transcript input is unavailable; run normalize stage first")
|
||||
case artifacts.ArtifactTranscriptTrimmed:
|
||||
return "", false, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first")
|
||||
return "", false, nil, fmt.Errorf("trimmed transcript input is unavailable; run trim stage first")
|
||||
default:
|
||||
return "", false, nil
|
||||
return "", false, nil, nil
|
||||
}
|
||||
}
|
||||
return "", false, err
|
||||
return "", false, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -444,6 +444,226 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
|
||||
env, m, _ := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`)
|
||||
playerHandoutPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
||||
writeAnalyzeFile(t, playerHandoutPath, "handout\n")
|
||||
|
||||
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||
sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{
|
||||
Source: "narratio.artifact.player_handout",
|
||||
Required: true,
|
||||
}
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: false,
|
||||
OutputPath: "artifacts/player_handout.md",
|
||||
}
|
||||
|
||||
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
generated := mustArtifactEntryList(t, result.Metadata, "generated_artifacts")
|
||||
if len(generated) != 1 {
|
||||
t.Fatalf("generated_artifacts len = %d, want 1 (%#v)", len(generated), generated)
|
||||
}
|
||||
g0 := generated[0]
|
||||
if g0["name"] != "session_recap" {
|
||||
t.Fatalf("generated[0].name = %#v, want session_recap", g0["name"])
|
||||
}
|
||||
if g0["source_id"] != "narratio.artifact.session_recap" {
|
||||
t.Fatalf("generated[0].source_id = %#v, want narratio.artifact.session_recap", g0["source_id"])
|
||||
}
|
||||
if g0["output_kind"] != "scriptorium_artifact" {
|
||||
t.Fatalf("generated[0].output_kind = %#v, want scriptorium_artifact", g0["output_kind"])
|
||||
}
|
||||
if g0["path"] != filepath.Join(paths.ArtifactsDir, "session_recap.md") {
|
||||
t.Fatalf("generated[0].path = %#v, want session recap path", g0["path"])
|
||||
}
|
||||
if g0["prompt_id"] != "dnd.session_recap" {
|
||||
t.Fatalf("generated[0].prompt_id = %#v, want dnd.session_recap", g0["prompt_id"])
|
||||
}
|
||||
if g0["profile_id"] != "local-quality" {
|
||||
t.Fatalf("generated[0].profile_id = %#v, want local-quality", g0["profile_id"])
|
||||
}
|
||||
if g0["provenance"] != artifacts.ArtifactProvenanceGeneratedCurrentAnalyzeRun {
|
||||
t.Fatalf("generated[0].provenance = %#v, want %q", g0["provenance"], artifacts.ArtifactProvenanceGeneratedCurrentAnalyzeRun)
|
||||
}
|
||||
|
||||
reused := mustArtifactEntryList(t, result.Metadata, "reused_artifacts")
|
||||
if len(reused) != 1 {
|
||||
t.Fatalf("reused_artifacts len = %d, want 1 (%#v)", len(reused), reused)
|
||||
}
|
||||
r0 := reused[0]
|
||||
if r0["name"] != "player_handout" {
|
||||
t.Fatalf("reused[0].name = %#v, want player_handout", r0["name"])
|
||||
}
|
||||
if r0["source_id"] != "narratio.artifact.player_handout" {
|
||||
t.Fatalf("reused[0].source_id = %#v, want narratio.artifact.player_handout", r0["source_id"])
|
||||
}
|
||||
if r0["path"] != playerHandoutPath {
|
||||
t.Fatalf("reused[0].path = %#v, want %q", r0["path"], playerHandoutPath)
|
||||
}
|
||||
if r0["provenance"] != artifacts.ArtifactProvenanceDisabledFromDisk {
|
||||
t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceDisabledFromDisk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRunsMultipleIndependentArtifactsInDeterministicOrder(t *testing.T) {
|
||||
env, m, fake := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`)
|
||||
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: true,
|
||||
PromptID: "dnd.player_handout",
|
||||
ProfileID: "local-quality",
|
||||
OutputPath: "artifacts/player_handout.md",
|
||||
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||
"transcript": {
|
||||
Source: "narratio.transcript.trimmed",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if len(fake.RunRequests) != 2 {
|
||||
t.Fatalf("run requests = %d, want 2", len(fake.RunRequests))
|
||||
}
|
||||
if fake.RunRequests[0].PromptID != "dnd.player_handout" {
|
||||
t.Fatalf("first prompt id = %q, want dnd.player_handout", fake.RunRequests[0].PromptID)
|
||||
}
|
||||
if fake.RunRequests[1].PromptID != "dnd.session_recap" {
|
||||
t.Fatalf("second prompt id = %q, want dnd.session_recap", fake.RunRequests[1].PromptID)
|
||||
}
|
||||
|
||||
selected, ok := result.Metadata["selected_artifacts"].([]string)
|
||||
if !ok {
|
||||
t.Fatalf("selected_artifacts = %#v, want []string", result.Metadata["selected_artifacts"])
|
||||
}
|
||||
if len(selected) != 2 || selected[0] != "player_handout" || selected[1] != "session_recap" {
|
||||
t.Fatalf("selected_artifacts = %#v, want [player_handout session_recap]", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRunsDependenciesBeforeDependents(t *testing.T) {
|
||||
env, m, fake := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`)
|
||||
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: true,
|
||||
DependsOn: []string{"session_recap"},
|
||||
PromptID: "dnd.player_handout",
|
||||
ProfileID: "local-quality",
|
||||
OutputPath: "artifacts/player_handout.md",
|
||||
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||
"recap": {
|
||||
Source: "narratio.artifact.session_recap",
|
||||
Required: true,
|
||||
},
|
||||
"transcript": {
|
||||
Source: "narratio.transcript.trimmed",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if len(fake.RunRequests) != 2 {
|
||||
t.Fatalf("run requests = %d, want 2", len(fake.RunRequests))
|
||||
}
|
||||
if fake.RunRequests[0].PromptID != "dnd.session_recap" {
|
||||
t.Fatalf("first prompt id = %q, want dnd.session_recap", fake.RunRequests[0].PromptID)
|
||||
}
|
||||
if fake.RunRequests[1].PromptID != "dnd.player_handout" {
|
||||
t.Fatalf("second prompt id = %q, want dnd.player_handout", fake.RunRequests[1].PromptID)
|
||||
}
|
||||
if got := fake.RunRequests[1].InputPaths["recap"]; got != filepath.Join(paths.ArtifactsDir, "session_recap.md") {
|
||||
t.Fatalf("dependent recap path = %q, want %q", got, filepath.Join(paths.ArtifactsDir, "session_recap.md"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) {
|
||||
env, m, fake := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`)
|
||||
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: true,
|
||||
PromptID: "dnd.player_handout",
|
||||
ProfileID: "local-quality",
|
||||
OutputPath: "artifacts/player_handout.md",
|
||||
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||
"transcript": {
|
||||
Source: "narratio.transcript.trimmed",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
env.SelectedAnalyzeArtifacts = []string{"player_handout"}
|
||||
|
||||
result, 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].PromptID != "dnd.player_handout" {
|
||||
t.Fatalf("prompt id = %q, want dnd.player_handout", fake.RunRequests[0].PromptID)
|
||||
}
|
||||
if len(result.Outputs) != 1 || result.Outputs[0].Kind != "player_handout" {
|
||||
t.Fatalf("outputs = %#v, want only player_handout", result.Outputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeMetadataIncludesMultipleGeneratedArtifacts(t *testing.T) {
|
||||
env, m, _ := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`)
|
||||
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: true,
|
||||
PromptID: "dnd.player_handout",
|
||||
ProfileID: "local-quality",
|
||||
OutputPath: "artifacts/player_handout.md",
|
||||
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||
"transcript": {
|
||||
Source: "narratio.transcript.trimmed",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
generated := mustArtifactEntryList(t, result.Metadata, "generated_artifacts")
|
||||
if len(generated) != 2 {
|
||||
t.Fatalf("generated_artifacts len = %d, want 2 (%#v)", len(generated), generated)
|
||||
}
|
||||
for i, entry := range generated {
|
||||
for _, field := range []string{"name", "source_id", "output_kind", "path", "prompt_id", "profile_id", "provenance"} {
|
||||
if _, ok := entry[field]; !ok {
|
||||
t.Fatalf("generated[%d] missing field %q: %#v", i, field, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeFailsWhenRequiredConfiguredArtifactMissing(t *testing.T) {
|
||||
env, m, _ := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
@@ -798,6 +1018,22 @@ func TestAnalyzeSkipsWhenNoEnabledScriptoriumArtifactsConfigured(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeSkipsWhenArtifactMapEmpty(t *testing.T) {
|
||||
env, m, _ := setupAnalyzeEnv(t)
|
||||
env.Config.Pipeline.Scriptorium.Artifacts = map[string]config.ScriptoriumArtifactConfig{}
|
||||
|
||||
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if result.Metadata["skipped"] != true {
|
||||
t.Fatalf("metadata = %#v, want skipped=true", result.Metadata)
|
||||
}
|
||||
if result.Metadata["reason"] != "no scriptorium artifacts configured" {
|
||||
t.Fatalf("reason = %#v, want no scriptorium artifacts configured", result.Metadata["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func setupAnalyzeEnv(t *testing.T) (*Env, *manifest.Manifest, *scriptorium.FakeRunner) {
|
||||
t.Helper()
|
||||
workspace := t.TempDir()
|
||||
@@ -885,3 +1121,27 @@ func writeAnalyzeFileNoTest(path, contents string) {
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
_ = os.WriteFile(path, []byte(contents), 0o644)
|
||||
}
|
||||
|
||||
func mustArtifactEntryList(t *testing.T, metadata map[string]any, key string) []map[string]any {
|
||||
t.Helper()
|
||||
raw, ok := metadata[key]
|
||||
if !ok {
|
||||
t.Fatalf("metadata missing key %q: %#v", key, metadata)
|
||||
}
|
||||
if typed, ok := raw.([]map[string]any); ok {
|
||||
return typed
|
||||
}
|
||||
asList, ok := raw.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("metadata[%q] = %#v, want []map[string]any", key, raw)
|
||||
}
|
||||
out := make([]map[string]any, 0, len(asList))
|
||||
for _, item := range asList {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("metadata[%q] entry = %#v, want map[string]any", key, item)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user