23 Commits

Author SHA1 Message Date
bc2ade38d9 Finalize previous-session artifact documentation and restore-analyze continuity coverage 2026-05-20 15:17:04 +00:00
5be831eb13 Restore archived previous-session cache files with session state 2026-05-20 15:05:48 +00:00
cae4d99a89 Archive durable previous-session cache files with session state 2026-05-20 15:03:30 +00:00
e09dc0512d Add analyze integration coverage for previous-session inputs 2026-05-20 15:01:27 +00:00
ae82bc1ce0 Resolve canonical previous-session artifact sources from prepared previous cache 2026-05-20 14:59:28 +00:00
01eb7aa1aa Add prepare rerun guidance for unresolved previous-session analyze inputs 2026-05-20 14:55:44 +00:00
2ca700195c Integrate previous-session artifact hydration into prepare stage 2026-05-20 14:53:22 +00:00
2b08c34539 Add prepare helper to hydrate previous-session artifacts from archive 2026-05-20 14:49:22 +00:00
79f1fc1e09 Add helper to collect previous-session artifact input requirements 2026-05-20 14:37:02 +00:00
9c753270bd Add canonical previous-session artifact source parsing and validation 2026-05-20 14:34:23 +00:00
b907cb01aa Add previous-session workspace path helpers and layout support 2026-05-20 14:31:03 +00:00
7824afd4a5 Add previous session ID templating and CLI support 2026-05-20 14:26:32 +00:00
2a4e1e912c Update documentation to include a roadmap for previous session artifact support 2026-05-20 09:12:17 -05:00
dd03c09d75 Fixed a bug in the S3 credential loading for the restore command
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-05-19 22:51:26 -05:00
5bc8e8683f Documentation update for the restore subcommand 2026-05-19 22:32:55 -05:00
648001a8fe Add workflow integration tests for the restore command 2026-05-19 22:23:25 -05:00
6684774f52 Add restore report and operator summary 2026-05-19 22:15:40 -05:00
f3b63bd5e5 Implement restore execution for the restore subcommand 2026-05-19 22:06:48 -05:00
23d6470b0f Implement restore planning for the restore subcommand 2026-05-19 21:58:38 -05:00
128449040f Implement remote current-state discovery for the restore subcommand 2026-05-19 21:49:19 -05:00
02ab106ade Implement initial CLI command for narratio restore, and extract shared helper functions from the run stages 2026-05-19 21:39:13 -05:00
c128970f58 Updated documentation to remove the completed runtime artifacts roadmap and add a new restore subcommand roadmap 2026-05-19 21:21:06 -05:00
d001baa660 Use artifact source IDs for archive promotion 2026-05-19 20:05:24 -05:00
62 changed files with 7101 additions and 1147 deletions

View File

@@ -6,7 +6,7 @@
narratio run --session-id 2026-04-04
```
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.
This command uses default discovery for `pipeline.yml` and `session.yml`; both files must be discoverable unless you pass explicit `--config` and `--session` paths.
## Command Overview
@@ -17,6 +17,7 @@ Implemented commands:
- `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.
- `restore`: restore durable local session state from the committed remote archive state.
Unknown commands print usage and exit non-zero.
@@ -29,6 +30,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
- `--config <path>`: optional explicit `pipeline.yml` path.
- `--session <path>`: optional explicit `session.yml` path.
- `--session-id <value>`: session template variable value.
- `--previous-session-id <value>`: previous-session template variable value.
- `--force`: force stage execution.
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
@@ -37,6 +39,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
- `--config <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--force`
### `resume`
@@ -44,6 +47,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
- `--config <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--force`
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
@@ -52,6 +56,7 @@ For config semantics, see [docs/config.md](./config.md). For operator lifecycle
- `--config <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--force`
- `--artifacts <names>`: analyze artifact keys to execute (repeatable or comma-separated).
- positional `<stage>`: required stage name.
@@ -68,6 +73,16 @@ Valid stage names:
- `archive`
- `notify`
### `restore`
- `--config <path>`
- `--session <path>`
- `--session-id <value>`
- `--previous-session-id <value>`
- `--dry-run`: plan restore actions without writing local files.
- `--force`: overwrite local conflicting files with remote archive files.
- `--include-audio`: include durable archived `audio/**` files in restore scope.
### `status`
- `--manifest <path>`: required manifest path.
@@ -82,7 +97,7 @@ Purpose:
Syntax:
```bash
narratio run [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>]
narratio run [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
```
Success output:
@@ -102,7 +117,7 @@ Purpose:
Syntax:
```bash
narratio plan [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force]
narratio plan [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force]
```
Success output includes:
@@ -122,7 +137,7 @@ Purpose:
Syntax:
```bash
narratio resume [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>]
narratio resume [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>]
```
Success output:
@@ -162,7 +177,7 @@ Purpose:
Syntax:
```bash
narratio run-stage [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--force] [--artifacts <name[,name...]>] <stage>
narratio run-stage [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--force] [--artifacts <name[,name...]>] <stage>
```
Success output:
@@ -178,6 +193,39 @@ Common failure cases:
- unknown stage name.
- using `--artifacts` with any non-`analyze` stage.
### `restore`
Purpose:
- Restore durable session state (`manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`, and optional `audio/**`) from the committed remote archive current state.
Syntax:
```bash
narratio restore [--config <pipeline.yml>] [--session <session.yml>] [--session-id <id>] [--previous-session-id <id>] [--dry-run] [--force] [--include-audio]
```
Success output (dry-run):
- `Restore plan for <campaign>/<session_id>`
- `Remote run: <run_id>`
- `Would download: <n>`
- `Would skip unchanged: <n>`
- `Conflicts: <n>`
Success output (non-dry-run):
- `Restored session archive for <campaign>/<session_id>`
- `Remote run: <run_id>`
- `Downloaded: <n>`
- `Skipped unchanged: <n>`
- `Conflicts: <n>`
Common failure cases:
- storage backend is not configured.
- remote `current/run_id.txt` missing/empty.
- remote `current/manifest.json` missing or invalid.
- remote manifest session/campaign mismatch.
- local conflicts without `--force`.
- session lock conflict.
## Common Workflows
Default-discovery run:
@@ -204,6 +252,25 @@ Run only analyze stage with selected artifacts:
narratio run-stage --session-id 2026-04-04 --artifacts player_handout analyze
```
Preview restore actions without writes:
```bash
narratio restore --session-id 2026-04-04 --dry-run
```
Restore and then force analyze:
```bash
narratio restore --session-id 2026-04-04
narratio run-stage --session-id 2026-04-04 --force analyze
```
Rehydrate canonical previous-session inputs after artifact-input changes:
```bash
narratio run-stage --session-id 2026-04-04 --force prepare
```
## Diagnostic / Recovery Commands
Inspect stage status:
@@ -219,4 +286,4 @@ Get manifest path from previous output:
- `--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.
- if analyze is already `succeeded` and `--force` is not set, runner-level skip still applies.

View File

@@ -13,6 +13,7 @@ These commands load and validate both files before running:
- `narratio plan`
- `narratio resume`
- `narratio run-stage`
- `narratio restore`
Behavior:
@@ -23,33 +24,37 @@ Behavior:
## 2. Config file discovery
Pipeline config lookup for `run`, `plan`, `resume`, and `run-stage`:
Pipeline config lookup for `run`, `plan`, `resume`, `run-stage`, and `restore`:
- If `--config <path>` is provided, that path is used.
- If 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`
- First existing file wins.
- first existing file wins.
## 3. Session file discovery and templating
Session config lookup for `run`, `plan`, `resume`, and `run-stage`:
Session config lookup for `run`, `plan`, `resume`, `run-stage`, and `restore`:
- If `--session <path>` is provided, that path is used.
- If 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`
- First existing file wins.
- first existing file wins.
Template behavior:
- Supported placeholders:
- supported placeholders:
- `{{session_id}}`
- `{{ session_id }}`
- `{{previous_session_id}}`
- `{{ previous_session_id }}`
- `--session-id <value>` supplies the placeholder value.
- `--previous-session-id <value>` supplies the previous-session placeholder value.
- unresolved placeholders fail load.
- if rendered `session_id` mismatches `--session-id`, load fails.
- if rendered `previous_session_id` mismatches `--previous-session-id`, load fails.
## 4. Minimal pipeline config
@@ -82,6 +87,23 @@ Usage:
narratio run --config /path/to/pipeline.yml --session ./session.yml --session-id 2026-05-03
```
Previous-session-enabled variant:
```yaml
session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./examples/speakers.yml
autocorrect_file: ./examples/autocorrect.yml
glossary_file: ./examples/glossary.yml
```
```bash
narratio run --config /path/to/pipeline.yml --session ./session.yml --session-id 2026-05-03 --previous-session-id 2026-04-26
```
## 6. Production-oriented config
```yaml
@@ -106,11 +128,11 @@ archive:
enabled: true
upload_run: true
promote_artifacts:
- from: transcripts/trimmed.json
to: transcripts/trimmed.json
- source: narratio.transcript.trimmed
dest: transcripts/trimmed.json
required: true
- from: artifacts/session_recap.md
to: artifacts/session_recap.md
- source: narratio.artifact.session_recap
dest: artifacts/session_recap.md
required: true
whisperx:
@@ -126,12 +148,17 @@ scriptorium:
transcript:
source: narratio.transcript.trimmed
required: true
previous_recap:
source: narratio.previous_session.artifact.session_recap
required: false
```
Operational notes:
- archive promotion is explicit and path-based via `archive.promote_artifacts`.
- archive promotion is explicit and source-based via `archive.promote_artifacts`.
- `source` is required; `dest` is optional and derived when omitted.
- Narratio does not auto-promote all generated analyze artifacts.
- `restore` reads the same config/session inputs and restore scope is bounded by committed archive current state.
## 7. Full pipeline reference
@@ -154,9 +181,9 @@ Operational notes:
| `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 | 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[]` | list | No | trimmed transcript rule |
| `pipeline.archive.promote_artifacts[].source` | string | Yes (per rule) | none |
| `pipeline.archive.promote_artifacts[].dest` | string | No | derived from source |
| `pipeline.archive.promote_artifacts[].required` | bool | No | `true` |
| `pipeline.whisperx.transcribe_url` | string | Yes | none |
| `pipeline.whisperx.language` | string | No | `en` |
@@ -240,7 +267,17 @@ Scriptorium artifact-key and dependency rules:
Allowed `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` values:
- `previous_session_artifact`
- `narratio.previous_session.artifact.<configured_artifact_key>`
- `narratio.transcript.merged`
- `narratio.transcript.polished`
- `narratio.transcript.full`
- `narratio.transcript.trimmed`
- `narratio.bounds.session`
- `narratio.artifact.<configured_artifact_key>`
- `previous_session_artifact` (legacy path-based source; uses `inputs.<key>.path`)
`pipeline.archive.promote_artifacts[].source` values:
- `narratio.transcript.merged`
- `narratio.transcript.polished`
- `narratio.transcript.full`
@@ -248,11 +285,26 @@ Allowed `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source` values:
- `narratio.bounds.session`
- `narratio.artifact.<configured_artifact_key>`
Archive promotion destination rules:
- `dest` must be a clean relative path (not absolute, no traversal).
- duplicate `dest` values are rejected.
- if `dest` is omitted:
- built-in sources derive their canonical destination path;
- configured sources derive from `pipeline.scriptorium.artifacts.<name>.output_path`;
- derivation failure is a config validation error.
Restore-related implications:
- restore remote identity requires archive S3 identity to resolve (`pipeline.storage.s3.bucket` and session prefix derivation inputs).
- restore scope considers committed current state and durable paths (`manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`, optional `audio/**`).
## 8. Full session reference
| Path | Type | Required | Default |
| --- | --- | --- | --- |
| `session.session_id` | string | Yes | none |
| `session.previous_session_id` | string | No | empty |
| `session.campaign` | string | Yes | none |
| `session.date` | string | No | empty |
| `session.title` | string | No | empty |
@@ -271,6 +323,11 @@ Audio-source rule:
- `audio_s3.prefix`
- `audio_s3` cannot be combined with local audio fields.
Previous-session rule:
- if `session.previous_session_id` is set, it must not equal `session.session_id`.
- canonical previous-session sources (`narratio.previous_session.artifact.<name>`) are hydrated during `prepare` from archive current state when required by enabled configured artifacts.
## 9. Secrets
Narratio supports filesystem-based secret injection via `pipeline.secrets.env_dir`.

View File

@@ -4,7 +4,7 @@
Developers and LLM coding agents changing Narratio internals.
## Scope
Implementation-accurate contracts for workspace/state, manifests, stages, artifact resolution, and adapter boundaries.
Implementation-accurate contracts for workspace/state, manifests, stages, artifact resolution, adapter boundaries, and restore command behavior.
## Component Docs
- `adapters.md`: external adapter map, runtime wiring, and boundary ownership.
@@ -12,6 +12,7 @@ Implementation-accurate contracts for workspace/state, manifests, stages, artifa
- `manifest.md`: session/run manifest schemas, lifecycle transitions, and persistence semantics.
- `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.
- `command-restore.md`: restore command discovery/planning/execution/reporting contract.
- `stage-prepare.md`: input materialization and provenance capture.
- `stage-transcribe.md`: WhisperX transcript generation.
- `stage-merge.md`: Seriatim normalization + merge.

View File

@@ -1,41 +1,35 @@
# Internal: Artifacts
## Purpose
Define Narratio's artifact identity and resolution model for built-in transcript/bounds artifacts and runtime-configured analyze artifacts.
Define Narratio artifact identity, catalog, and source-resolution behavior for:
- built-in session artifacts;
- configured analyze artifacts;
- canonical previous-session artifact sources.
## Inputs and outputs
Inputs:
- 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
- configured input sources (`pipeline.scriptorium.artifacts.*.inputs.*.source`);
- session paths and manifest inputs/outputs;
- runtime catalog state.
Outputs:
- resolved local artifact path and provenance (`ResolvedSessionArtifact`)
- runtime catalog entries for planned/executable/available artifacts
- validation errors for unsupported, missing, or invalid artifact sources
- resolved artifact path + provenance (`ResolvedSessionArtifact`);
- runtime catalog entries for built-ins and configured artifacts;
- requirement sets for canonical previous-session inputs.
## Boundaries
Owns:
- 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
- built-in source registry and validation;
- configured artifact catalog identity (`narratio.artifact.<name>`);
- canonical previous-session source parsing and resolution;
- previous-session requirement collection (`CollectPreviousArtifactRequirements`).
Does not own:
- artifact generation (stages produce files)
- manifest transition policy
- archive promotion behavior
## Config fields used
- `pipeline.scriptorium.artifacts.<name>.enabled`
- `pipeline.scriptorium.artifacts.<name>.output_path`
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source`
## External adapters used
- none
## State and manifest behavior
Built-in registry entries:
- prepare-stage remote hydration;
- stage success/skip transitions;
- archive upload orchestration.
## Built-in IDs
| Artifact ID | Canonical file | Producer stage | Output kind |
| --- | --- | --- | --- |
| `narratio.transcript.merged` | `transcripts/merged.json` | `merge` | `transcript_merged` |
@@ -44,44 +38,58 @@ Built-in registry entries:
| `narratio.transcript.trimmed` | `transcripts/trimmed.json` | `trim` | `transcript_trimmed` |
| `narratio.bounds.session` | `artifacts/session_bounds.json` | `trim` | `session_bounds` |
Runtime catalog entries include built-ins and configured `narratio.artifact.<name>` sources.
## Source families
- built-in: `narratio.transcript.*`, `narratio.bounds.session`
- configured artifact: `narratio.artifact.<artifact_key>`
- canonical previous-session artifact: `narratio.previous_session.artifact.<artifact_key>`
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)
## Runtime catalog model
Catalog entries track:
- `planned`: source is registered for this run;
- `executable`: configured artifact is selected for analyze execution;
- `available`: usable local file exists (generated this run or reused from disk).
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:
Configured artifact provenance values include:
- `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
Previous-session canonical provenance values include:
- `manifest.inputs.previous_cache`
- `current_session.previous_cache`
## Skip and resume behavior
- resolver and catalog have no direct skip/resume decisions
- stage/runner skip-resume behavior consumes catalog/resolver results
## Resolution behavior
- Built-ins resolve via manifest producer outputs first, then canonical fallback paths.
- Configured `narratio.artifact.<name>` sources resolve through catalog availability.
- Canonical previous-session sources resolve to current-session `previous/` cache candidates derived from configured artifact canonical output paths.
- Previous-session canonical resolution prefers manifest-recorded input paths when present, then filesystem fallback under `previous/artifacts/**`.
## Previous-session requirement scanning
`CollectPreviousArtifactRequirements`:
- scans enabled configured artifacts only;
- includes canonical previous-session sources only;
- deduplicates by artifact key;
- merges required/optional references (`required` wins);
- records deterministic sorted source locations for diagnostics.
## Validation behavior
- transcript built-ins: JSON with top-level `segments` array;
- bounds built-in: valid JSON;
- configured and previous-session artifact files: non-empty text content.
## Failure behavior
- unsupported source -> source validation error
- known source unavailable -> `ErrSessionArtifactNotFound`
- configured source without catalog -> resolution error
- resolved file with invalid content -> validation error
- unsupported source or malformed canonical previous source: validation/resolution error;
- known source unavailable: `ErrSessionArtifactNotFound`;
- configured/previous canonical source without catalog: error;
- resolved invalid file content: validation error.
## Tests to inspect before changing
- `internal/artifacts/artifact_resolver_test.go`
- `internal/artifacts/catalog_test.go`
- `internal/artifacts/previous_requirements_test.go`
- `internal/stage/prepare_previous_test.go`
- `internal/stage/analyze_test.go`
- `internal/config/scriptorium_test.go`
## Architectural invariants
- 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
- Built-in source IDs are static.
- Configured and previous-session source IDs are artifact-key based and validation-gated.
- Resolution behavior remains deterministic and manifest-aware.

View File

@@ -0,0 +1,101 @@
# Internal: Command Restore
## Purpose
Define the implemented `narratio restore` contract: committed remote-state discovery, deterministic plan classification, safe file install semantics, and restore reporting.
## Inputs and outputs
Inputs:
- CLI flags: `--config`, `--session`, `--session-id`, `--previous-session-id`, `--dry-run`, `--force`, `--include-audio`.
- Resolved/validated `pipeline.yml` and `session.yml`.
- Configured remote object store.
- Remote committed current-state markers (`current/run_id.txt`, `current/manifest.json`).
Outputs:
- Dry-run summary to stdout (plan + counts).
- Non-dry-run completion summary to stdout.
- Local durable session files restored under canonical session root.
- Non-dry-run restore report at `reports/restore-latest.json`.
## Boundaries
Owns:
- Restore command flag parsing and command wiring.
- Remote current-state discovery and identity validation.
- Restore plan construction and conflict classification.
- Restore execution for planned downloads.
- Restore report model and persistence.
Does not own:
- Stage execution orchestration (`run`, `resume`, `run-stage`).
- Archive publish behavior (owned by archive stage).
- Storage transport implementation details (owned by storage adapters).
## Config fields used
- Config/session discovery and templating fields consumed by all commands.
- `pipeline.workspace.root` (local restore target root).
- `pipeline.storage.*` (remote backend + archive identity derivation).
- `pipeline.storage.s3.*` identity components used by archive prefix helpers.
- `session.session_id`
- `session.campaign`
## External adapters used
- `storage.ObjectStore` for `Exists`, `List`, `Download`.
- `artifacts.Store` (`LocalStore`) for layout and session lock management.
- `manifest.LocalStore` for manifest decode/validation and identity checks.
## State and manifest behavior
- Restore is not a pipeline run and does not create a run manifest.
- Restore uses committed remote current state only:
- `current/run_id.txt` must exist and be non-empty.
- `current/manifest.json` must decode and match requested session/campaign.
- Non-dry-run writes restore files to canonical session paths.
- Manifest install behavior:
- validated before replacement.
- installed last among download actions.
- existing local manifest is preserved if restored manifest validation/install fails.
- Non-dry-run report persists summary/action status metadata in `reports/restore-latest.json`.
Restore path scope:
- includes:
- `manifest.json`
- `transcripts/**`
- `artifacts/**`
- `previous/**`
- `audio/**` only when `--include-audio` is set
- excludes:
- `runs/**`
- `logs/**`
- `reports/**`
- `config/**`
- `inputs/**`
- remote `current/**` pointer files as local restore targets
## Skip and resume behavior
- Restore does not participate in stage skip/resume decisions.
- Restore provides durable local state so subsequent stage commands can resume or rerun based on restored manifest state.
- Dry-run is read-only and returns plan output only.
## Failure behavior
- Fails when storage backend is unavailable or archive identity cannot be resolved.
- Fails when remote current pointer/manifest is missing or invalid.
- Fails when remote manifest identity mismatches requested campaign/session.
- Fails on local conflicts unless `--force` is set.
- Fails fast on session lock acquisition conflict for non-dry-run execution.
- On execution failure, previously installed files remain; no rollback is performed.
## Tests to inspect before changing
- `internal/app/restore_test.go`
- `internal/app/restore_discovery_test.go`
- `internal/app/restore_plan_test.go`
- `internal/app/restore_execution_test.go`
- `internal/app/restore_workflow_test.go`
- `internal/artifacts/archive_identity_test.go`
## Architectural invariants
- Restore relies on centralized archive identity/key helpers (`internal/artifacts`) rather than ad hoc key building.
- `current/run_id.txt` is the remote commit marker; restore must not infer committed state from incidental files.
- Local path mapping is traversal-safe and constrained to session root.
- Restore scope is deterministic and path-classified:
- include `manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`
- include `audio/**` only with `--include-audio`
- exclude `runs/**`, `logs/**`, `reports/**`, `config/**`, `inputs/**`
- Command remains standalone; no implicit `run --restore` behavior.

View File

@@ -3,31 +3,36 @@
## Purpose
Execute selected configured Scriptorium artifacts in deterministic dependency order and promote successful outputs to canonical session artifact paths.
## Inputs and Outputs
## Inputs and outputs
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`)
- configured artifact definitions from `pipeline.scriptorium.artifacts`;
- selected artifact filter (`--artifacts`) when provided;
- resolved artifact sources from resolver/catalog.
Source types used by analyze:
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`;
- configured artifacts: `narratio.artifact.<artifact_key>`;
- canonical previous-session artifacts: `narratio.previous_session.artifact.<artifact_key>`;
- legacy path-based previous-session source: `previous_session_artifact` (uses `inputs.*.path`).
Outputs:
- 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
- promoted configured artifact files at each configured `output_path`;
- stage metadata (`generated_artifacts`, `reused_artifacts`, selected/order info).
## Boundaries
Owns:
- 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
- runtime artifact catalog construction;
- selected-artifact planning and dependency ordering;
- per-input resolution and required/optional handling;
- Scriptorium render/run invocation;
- run-local output generation and canonical promotion.
Does not own:
- transcript generation/processing stages
- archive promotion policy
- per-artifact resume semantics
- prepare-time previous-session hydration;
- object-store access for previous-session sources;
- archive promotion policy.
## Config Fields Used
## Config fields used
- `session.session_id`
- `session.campaign`
- `pipeline.workspace.root`
@@ -36,49 +41,41 @@ Does not own:
- `pipeline.scriptorium.timeout`
- `pipeline.scriptorium.render_debug`
- `pipeline.scriptorium.artifacts.<name>.*`
- `enabled`
- `depends_on`
- `prompt_id`
- `profile_id`
- `timeout`
- `output_path`
- `render_debug`
- `inputs`
- `vars`
## External Adapters Used
## External adapters used
- Scriptorium adapter:
- optional `RenderArtifact` (render debug)
- `RunArtifact` (artifact generation)
- optional `RenderArtifact` when render-debug is enabled;
- `RunArtifact` for artifact generation.
## State and Manifest Behavior
- 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`.
## State and manifest behavior
- If Scriptorium config is absent, or no artifacts are executable after filtering, analyze returns success metadata with `skipped=true`.
- Builds runtime catalog with built-ins and configured `narratio.artifact.<name>` entries.
- Non-executable configured artifacts may still be marked available from existing canonical output files.
- Resolves canonical previous-session sources from local prepared `previous/` cache:
- prefers manifest-backed previous input paths when present;
- may fall back to current-session `previous/` filesystem paths.
- Analyze does not call object storage for canonical previous-session source resolution.
- Required canonical previous-session input missing:
- fails with guidance to run `narratio run-stage --force prepare`.
- Optional missing sources are omitted from adapter input paths.
## Skip and Resume Behavior
## Skip and resume behavior
- 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`.
- Analyze is stage-scoped for resume; no per-artifact manifest resume state.
- `--artifacts` filters executable artifacts but does not imply force rerun.
## Failure Behavior
- 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.
## Failure behavior
- Fails on dependency-order violations, missing required inputs, resolver validation failures, adapter errors, and missing/empty generated outputs.
- Required unavailable configured artifact source (`narratio.artifact.<name>`) fails before invocation.
- Required canonical previous-session source fails with prepare-rerun guidance.
## Tests to Inspect Before Changing
## 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`
- `internal/app/restore_workflow_test.go`
## Architectural Invariants
- 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.
## Architectural invariants
- Canonical previous-session behavior is local-cache only during analyze.
- Generated outputs are validated and promoted before stage success is recorded.
- Resolver/catalog decisions stay deterministic and validation-gated.

View File

@@ -1,17 +1,19 @@
# Stage: archive
## Purpose
Publish run records and promoted session artifacts to object storage, then atomically advance the remote current pointer.
Publish durable run/session state to object storage, then atomically advance remote current state.
## Inputs and Outputs
Inputs:
- session manifest and prerequisite stage records
- run root contents under `runs/{run_id}/`
- promotion sources from session root (`archive.promote_artifacts`)
- promotion rules with artifact `source` IDs and archive `dest` paths (`archive.promote_artifacts`)
- session-level `previous/**` cache files when present
Outputs:
- uploaded run files under `{session_prefix}/runs/{run_id}/...`
- uploaded promoted artifacts under `{session_prefix}/...`
- uploaded session previous-cache files under `{session_prefix}/previous/...` when present
- `{session_prefix}/current/manifest.json`
- `{session_prefix}/current/run_id.txt` written last
@@ -21,6 +23,7 @@ Owns:
- Prerequisite stage success enforcement
- Run file collection and upload (excluding `audio/`)
- Promotion rule resolution and upload
- Session previous-cache file collection/upload
- Commit pointer publish order
Does not own:
@@ -43,8 +46,10 @@ Does not own:
## State and Manifest Behavior
- Requires `prepare`, `transcribe`, `merge`, `polish`, `normalize`, `trim`, and `analyze` status `succeeded`.
- Resolves bucket/prefix from manifest identity first, then config fallback.
- Uploads session `previous/**` files as durable session state when the local `previous/` directory exists.
- Writes metadata including:
- upload counts/paths
- `previous_files_uploaded` and `previous_uploaded_paths`
- `current_manifest_key`
- `current_run_id_key`
- `current_pointer_written`
@@ -64,5 +69,6 @@ Does not own:
## Architectural Invariants
- Run upload excludes `audio/` subtree.
- Session `previous/**` is archiveable durable input/provenance state, not run-local output.
- `current/manifest.json` uploads before `current/run_id.txt`.
- `current/run_id.txt` is the remote publish commit marker.

View File

@@ -1,40 +1,49 @@
# Stage: prepare
## Purpose
Materialize all required session inputs into canonical local workspace paths and record input provenance in the session manifest.
Materialize canonical current-session input state and provenance before downstream stages run.
## Inputs and Outputs
Prepare owns:
- local input file materialization (`inputs/**`);
- audio input materialization (`audio/**`);
- previous-session cache hydration (`previous/**`) for canonical previous-session artifact sources.
## Inputs and outputs
Inputs:
- `session.yml` (resolved session config)
- `pipeline.resolved.yml` (materialized from resolved pipeline config)
- `speakers.yml`
- `autocorrect.yml`
- `glossary.yml`
- resolved config/session (`pipeline.yml`, `session.yml`);
- session-local input files (`speakers`, `autocorrect`, `glossary`);
- audio source:
- local (`session.inputs.audio_dir` or `session.inputs.audio_files`), or
- S3 (`session.inputs.audio_s3.prefix`)
- local: `session.inputs.audio_dir` or `session.inputs.audio_files`;
- S3: `session.inputs.audio_s3.prefix`;
- configured enabled Scriptorium artifact inputs (for previous-session requirement scanning);
- remote previous-session current archive state when previous hydration is required.
Outputs:
- `inputs/session.yml`
- `inputs/pipeline.resolved.yml`
- `inputs/speakers.yml`
- `inputs/autocorrect.yml`
- `inputs/glossary.yml`
- `audio/*.flac` in session workdir
- `manifest.Inputs` records with checksums and source metadata
- `inputs/session.yml`;
- `inputs/pipeline.resolved.yml`;
- `inputs/speakers.yml`;
- `inputs/autocorrect.yml`;
- `inputs/glossary.yml`;
- `audio/*.flac` in canonical session `audio/`;
- optional `previous/manifest.json`;
- optional `previous/artifacts/**`;
- deterministic `manifest.Inputs` records with checksums and provenance metadata.
## Boundaries
Owns:
- Input path resolution and validation
- Local copy/materialization of configs and audio files
- S3 audio download to run-scoped spool, then copy into work audio dir
- input path resolution and materialization;
- S3 audio list/download/copy flow;
- previous-session artifact requirement collection from enabled configured artifacts;
- previous cache lifecycle when requirements exist (clear and rehydrate managed `previous/` state).
Does not own:
- Transcript generation/processing
- Archive publish behavior
- transcript or artifact generation;
- analyze-stage source resolution;
- archive commit behavior.
## Config Fields Used
## Config fields used
- `session.session_id`
- `session.previous_session_id`
- `session.campaign`
- `session.inputs.speakers_file`
- `session.inputs.autocorrect_file`
@@ -46,29 +55,56 @@ Does not own:
- `pipeline.spool.root`
- `pipeline.storage.s3.bucket`
- `pipeline.storage.s3.root_prefix`
- `pipeline.scriptorium.artifacts.<name>.enabled`
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.source`
- `pipeline.scriptorium.artifacts.<name>.inputs.<key>.required`
## External Adapters Used
- Object storage backend (`env.ObjectStore`) for S3 audio list/download when `audio_s3` is configured.
## External adapters used
- `storage.ObjectStore` for:
- S3 audio listing/downloads;
- previous-session current pointer/manifest/artifact object checks and downloads.
## State and Manifest Behavior
## State and manifest behavior
- Ensures workspace layout exists.
- Writes resolved config and input files to canonical `inputs/` paths.
- Records all prepared inputs into `manifest.Inputs` (sorted deterministically by kind/path).
- For S3 audio, records `S3Bucket`, `S3Key`, `S3Size`, `S3ETag`, and `SpoolPath` in each audio input record.
- Materializes canonical input files and audio files.
- Scans enabled configured artifact inputs for canonical sources:
- `narratio.previous_session.artifact.<artifact_key>`
- If one or more canonical previous-session requirements exist:
- clears managed `previous/` state;
- hydrates required/optional previous artifacts from the configured previous sessions committed archive current state;
- writes `previous/manifest.json` and hydrated `previous/artifacts/**`;
- records hydrated previous inputs in `manifest.Inputs` with source `previous_session_archive.current`.
- If no canonical previous-session requirements exist, prepare does not manage `previous/`.
- `manifest.Inputs` is sorted deterministically by `(kind, path)`.
## Skip and Resume Behavior
- Runner-level skip applies when stage already `succeeded` and `--force` is not set.
- Stage itself is deterministic/idempotent for unchanged inputs (`copyFileIfChanged`, `writeBytesIfChanged`).
## Required and optional previous-session behavior
- `previous_session_id` unset:
- if any referenced previous artifact is required: fail;
- if all referenced previous artifacts are optional: continue and omit them.
- Previous session archive current pointer or manifest missing:
- if any referenced previous artifact is required: fail;
- if all referenced previous artifacts are optional: continue and omit missing ones.
- Missing required previous artifact object: fail.
- Missing optional previous artifact object: omit.
- Downloaded previous artifacts must validate as non-empty files.
## Failure Behavior
- Fails on missing required files, invalid audio source combinations, no discoverable `.flac` files, duplicate audio basenames, missing object store for S3 mode, or S3 list/download failures.
## Skip and resume behavior
- Runner-level skip remains authoritative:
- if `prepare` already succeeded and run is not forced, `prepare` does not run and no hydration/download occurs.
- If `prepare` runs (including with `--force`), it owns managed `previous/` state for canonical previous-session inputs.
## Tests to Inspect Before Changing
## Failure behavior
- Fails on missing required input files, invalid audio-source combinations, empty/duplicate audio inputs, missing object store for S3 modes, and remote access/download/validation errors.
- For required canonical previous-session inputs, analyze-time missing-input guidance is to rerun:
- `narratio run-stage --force prepare`
## Tests to inspect before changing
- `internal/stage/prepare_test.go`
- `internal/app/session_cli_test.go`
- `internal/config/load_validate_test.go`
- `internal/stage/prepare_previous_test.go`
- `internal/artifacts/previous_requirements_test.go`
- `internal/app/runner_test.go`
## Architectural Invariants
## Architectural invariants
- `audio_dir`/`audio_files` and `audio_s3` are mutually exclusive.
- Audio files must be `.flac`.
- Canonical `inputs/*` and `audio/*` paths are the durable source for downstream stages.
- Storage keys are computed by callers using archive/path helpers; storage adapter receives explicit keys.
- `prepare` is the only stage that hydrates canonical previous-session cache state.

View File

@@ -17,7 +17,8 @@ Outputs:
## Boundaries
Owns:
- Session-level path layout (`inputs/`, `audio/`, `transcripts/`, `artifacts/`, `reports/`, `logs/`, `config/`, `current/`, `runs/`)
- Session-level path layout (`inputs/`, `audio/`, `transcripts/`, `artifacts/`, `reports/`, `logs/`, `config/`, `current/`, `runs/`, `previous/`)
- `previous/manifest.json` and `previous/artifacts/**` are reserved for prepared previous-session state
- Run-local stage sandbox layout under `runs/{run_id}/{stage}/`
- Session lock acquisition/release (`.lock`)
@@ -65,4 +66,5 @@ None directly in this subsystem. Stages may use object storage adapters and then
- Session root is campaign-aware: `{workspace.root}/work/{campaign}/{session_id}`.
- Run roots are always nested: `runs/{run_id}` under the session root.
- Run-local output promotion must end in canonical session paths.
- `previous/**` is session-durable state and must not be treated as run-local output scratch state.
- Cleanup only targets run-scoped directories and must never delete configured root directories.

View File

@@ -6,7 +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 object storage under the session audio prefix.
1. Upload session `.flac` files to object storage under the configured session audio prefix.
2. Run Narratio:
```bash
@@ -21,6 +21,37 @@ Notes:
- 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.
## Restore workflow
Use restore when local durable session state is missing or stale and archive current state is authoritative.
Dry-run (no local writes):
```bash
narratio restore --session-id 2026-04-04 --dry-run
```
Execution:
```bash
narratio restore --session-id 2026-04-04
```
Post-restore analyze rerun pattern:
```bash
narratio run-stage --session-id 2026-04-04 --force analyze
```
Restore source-of-truth:
- remote commit marker: `current/run_id.txt`
- remote current manifest: `current/manifest.json`
Restore default scope:
- includes `manifest.json`, `transcripts/**`, `artifacts/**`, `previous/**`
- includes `audio/**` only with `--include-audio`
- excludes `runs/**`, `logs/**`, `reports/**`, `config/**`, `inputs/**`, and `current/**` (except remote `current/manifest.json` as source)
## Local filesystem layout and state artifacts
Session root:
@@ -29,13 +60,14 @@ Session root:
Primary state:
- `manifest.json`: session-level stage state.
- `runs/{run_id}/manifest.json`: invocation-level state.
- `.lock`: session lock while a run is active.
- `.lock`: session lock while a modifying command is active.
Canonical session directories:
- `inputs/`
- `audio/`
- `transcripts/`
- `artifacts/`
- `previous/`
- `reports/`
- `logs/`
- `config/`
@@ -48,6 +80,7 @@ Run-local stage directories:
Behavior:
- directory creation is idempotent.
- stage outputs are generally generated run-local first, then promoted to canonical paths on success.
- restore installs downloaded files to canonical session paths and does not recreate historical run sandboxes.
## Analyze artifact execution lifecycle
@@ -67,6 +100,12 @@ Configured artifact source reuse:
- accepted on `run`, `resume`, and `run-stage analyze`.
- filters analyze execution only; does not force stage rerun.
Canonical previous-session input behavior:
- canonical sources use `narratio.previous_session.artifact.<artifact_key>`.
- these inputs are hydrated by `prepare`, not `analyze`.
- if analyze fails due to missing canonical previous cache, rerun:
- `narratio run-stage --session-id <id> --force prepare`
## Remote archive layout and publish contract
When archive is enabled and run upload is enabled, archive publishes under:
@@ -84,12 +123,14 @@ Publish order:
`current/run_id.txt` is the remote commit marker.
Archive promotion is explicit and path-based:
Archive promotion is explicit and source-based:
- Narratio does not auto-promote all generated analyze artifacts.
- each rule resolves `source` through the artifact resolver/catalog model, then uploads to `dest`.
- missing required promotion sources fail archive stage.
- missing optional promotion sources are skipped.
- invalid resolved artifacts fail archive stage.
## Resume, retry, and safe rerun behavior
## Resume, retry, restore, and safe rerun behavior
Default skip:
- `run` and `run-stage` skip already-succeeded stages unless `--force` is set.
@@ -98,6 +139,11 @@ Resume:
- `resume` starts at first non-succeeded stage.
- `resume --force` runs full stage order.
Restore conflict policy:
- restore classifies local differences as conflicts.
- without `--force`, restore fails when conflicts exist.
- with `--force`, conflicting local files are overwritten by remote archive files.
Forced reruns:
- force-rerunning an upstream succeeded stage marks downstream succeeded stages as `stale`.
@@ -123,13 +169,18 @@ No cleanup for failed/incomplete/unarchived/archive-skipped runs.
## Failure and recovery playbooks
After failure, Narratio keeps:
After run failure, Narratio keeps:
- session manifest
- run manifest
- run-local artifacts/logs/config/reports
Failed or incomplete runs remain local-only.
After restore failure:
- already-installed restore files remain in place.
- restore does not roll back prior successful installs.
- existing local manifest is preserved if restored manifest validation/install fails.
Recommended recovery:
1. inspect state:
@@ -138,8 +189,27 @@ Recommended recovery:
narratio status --manifest <manifest-path>
```
2. fix root cause (config/input/credentials/service availability).
3. continue with `resume`, or targeted `run-stage --force` followed by `resume`.
2. for restore-specific checks, run:
```bash
narratio restore --session-id 2026-04-04 --dry-run
```
3. fix root cause (config/input/credentials/storage/service availability).
4. continue with `resume`, or targeted `run-stage --force` followed by `resume`.
## Restore report
Non-dry-run restore writes a durable report at:
- `reports/restore-latest.json`
Report content includes:
- identity (`campaign`, `session_id`, `run_id`)
- mode flags (`dry_run`, `force`, `include_audio`)
- plan counts and execution counts
- per-action status
Dry-run does not write restore report files.
## Operational caveats
@@ -147,3 +217,4 @@ narratio status --manifest <manifest-path>
- 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.
- restore requires configured remote object storage and committed remote current state.

629
docs/roadmap/previous.md Normal file
View File

@@ -0,0 +1,629 @@
# Roadmap: Previous-Session Artifacts
## Status
Completed.
This roadmap describes the implementation strategy for first-class previous-session artifact support in Narratio. It belongs under `docs/roadmap/previous.md` until the feature is implemented. After implementation, current behavior should be documented in the appropriate user-facing and internal documentation files, and this roadmap should be removed or marked complete according to the documentation policy.
## Summary
Narratio should support using artifacts from a previous session as inputs to artifacts generated for the current session.
The primary use case is session recap continuity: a current session recap should be able to consume the previous session recap. The design should support arbitrary previous-session artifacts from the start, not just `session_recap`.
The canonical source syntax should be:
```yaml
source: narratio.previous_session.artifact.<artifact_name>
```
For example:
```yaml
source: narratio.previous_session.artifact.session_recap
```
Previous-session artifacts are materialized during the `prepare` stage into a current-session top-level `previous/` directory. Downstream stages consume only the local `previous/` copies. The S3 backend is authoritative for previous-session state.
## Design Decisions
### 1. Add `previous_session_id` to `session.yml`
Add an optional top-level session key:
```yaml
session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign
```
Rules:
- `previous_session_id` is optional.
- If present, it identifies the previous session within the same campaign.
- It must not equal `session_id`.
- It should use the same validation rules as `session_id`.
- It may be supplied through session templating.
- Add a CLI/template value such as `--previous-session-id <id>` if required by the existing session templating implementation.
- If a template placeholder for `previous_session_id` is present and no value is supplied, loading should fail with a clear unresolved-template error.
### 2. Use canonical previous-session artifact source IDs
Support this source pattern in Scriptorium artifact input definitions:
```text
narratio.previous_session.artifact.<artifact_name>
```
Examples:
```yaml
inputs:
previous_recap:
source: narratio.previous_session.artifact.session_recap
required: false
previous_quest_log:
source: narratio.previous_session.artifact.quest_log
required: true
```
Rules:
- `<artifact_name>` must be a valid configured artifact key.
- Use the same artifact key validation rules as current-session runtime artifacts.
- Do not special-case `session_recap`.
- Do not limit implementation to a fixed list of previous artifacts.
### 3. Add a top-level `previous/` workspace directory
Extend the session workspace layout with:
```text
previous/
manifest.json
artifacts/
<artifact outputs copied from the previous session>
```
The `previous/` directory is current-session state. It is a prepared input cache, not a full mirror of the previous session workspace.
Conceptually:
```text
work/<campaign>/<current_session_id>/
previous/
manifest.json
artifacts/
session_recap.md
quest_log.json
```
The current session should not read directly from the previous session's local workspace during ordinary operation.
### 4. S3 is authoritative for previous-session state
For this initial implementation, previous-session artifacts should be downloaded from the configured S3 backend.
Do not compare local and remote copies.
Do not prefer local previous-session workspace state.
Do not implement a `--local` override in this roadmap. That can be considered later.
The simplified stage behavior is:
1. If the local manifest indicates `prepare` already succeeded and `--force` is not supplied, the runner skips `prepare`. No previous-session download occurs.
2. If `prepare` has not succeeded, `prepare` runs and downloads referenced previous-session artifacts from S3.
3. If `prepare` previously succeeded but `--force` is supplied, `prepare` runs again and overwrites local `previous/` state from S3.
### 5. Materialize only referenced previous-session artifacts
During `prepare`, scan the current resolved pipeline/session configuration for Scriptorium artifact inputs whose source matches:
```text
narratio.previous_session.artifact.<artifact_name>
```
Only those referenced previous-session artifacts need to be downloaded.
Do not blindly download every previous-session artifact.
If no previous-session artifact sources are referenced, `prepare` should not require `previous_session_id` and should not touch `previous/`.
### 6. Preserve stage boundaries
`prepare` owns previous-session artifact materialization because these files are inputs to later stages.
`analyze` should not talk to S3.
The Scriptorium adapter should not know about previous sessions.
The storage adapter should not infer campaign, session, run, or root-prefix semantics. Callers should continue to provide explicit bucket-relative keys.
### 7. Archive and restore `previous/`
After implementation:
- `archive` should include `previous/` as durable current-session prepared input state.
- `restore` should restore `previous/` along with the rest of the durable session state it already restores.
- `previous/` should not be treated as current-session generated artifacts.
- `previous/` entries should be recorded as inputs/provenance, not as outputs produced by the current session.
## Target User Workflow
A typical session config:
```yaml
session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./examples/speakers.yml
autocorrect_file: ./examples/autocorrect.yml
glossary_file: ./examples/glossary.yml
```
A typical artifact config:
```yaml
scriptorium:
artifacts:
session_recap:
enabled: true
prompt_id: dnd_session.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.trimmed
required: true
previous_recap:
source: narratio.previous_session.artifact.session_recap
required: false
```
Typical command:
```bash
narratio run --session-id 2026-04-11 --previous-session-id 2026-04-04
```
Expected behavior:
1. `prepare` sees a referenced previous-session artifact: `session_recap`.
2. `prepare` downloads the previous session's `narratio.artifact.session_recap` from S3.
3. `prepare` writes it under the current session workspace, for example `previous/artifacts/session_recap.md`.
4. `prepare` records provenance in the current session manifest.
5. `analyze` resolves `narratio.previous_session.artifact.session_recap` from the local `previous/` directory.
6. Scriptorium receives the previous recap as a normal input file.
## Implementation Plan
### Phase 1: Session config and templating
Update session configuration structs to include:
```yaml
previous_session_id: ""
```
Implementation steps:
1. Add `PreviousSessionID` or equivalent to the session config type.
2. Add validation:
- optional;
- same format constraints as `session_id`;
- must not equal `session_id`.
3. Extend session templating support to include:
- `{{previous_session_id}}`
- `{{ previous_session_id }}`
4. Add a CLI flag if required by current templating flow:
- `--previous-session-id <id>`
5. Ensure unresolved `previous_session_id` placeholders fail clearly.
6. Update config tests for:
- no previous session;
- valid previous session;
- previous session equal to current session;
- unresolved placeholder;
- CLI/template rendering.
Do not add future workflow flags in this phase.
### Phase 2: Workspace path helpers
Add centralized path helpers for current-session previous-state paths.
Suggested helpers:
```text
SessionPreviousDir()
SessionPreviousManifestPath()
SessionPreviousArtifactsDir()
SessionPreviousArtifactPath(name or relative output path)
```
The exact names should match existing path-helper style.
Rules:
- Do not construct `previous/` paths through scattered string concatenation.
- Keep paths session-relative where possible.
- Ensure workspace layout creation includes `previous/` only when appropriate, or creates it idempotently with the rest of the layout if simpler.
Tests:
- path helper tests;
- workspace layout tests;
- ensure cleanup logic does not accidentally delete configured roots;
- ensure `previous/` is treated as session-durable state, not run-local state.
### Phase 3: Previous-session source parsing
Add parsing/recognition for:
```text
narratio.previous_session.artifact.<artifact_name>
```
Implementation steps:
1. Add constants/helpers in the artifact/source parsing layer.
2. Validate artifact names using the same rules as current runtime artifact keys.
3. Add helpers such as:
- `IsPreviousSessionArtifactSource(source string) bool`
- `PreviousSessionArtifactName(source string) (string, bool)`
4. Ensure config validation accepts this source pattern.
5. Ensure invalid sources fail clearly.
Tests:
- valid previous-session artifact source;
- invalid/missing artifact name;
- invalid artifact key characters;
- ordinary current-session sources still validate;
- unknown sources still fail.
### Phase 4: Scan configured artifacts for previous-session inputs
Add a helper that inspects resolved Scriptorium artifact definitions and returns the set of referenced previous-session artifact names.
Rules:
- Scan enabled artifacts according to current artifact-enable semantics.
- Include all inputs whose source matches `narratio.previous_session.artifact.<name>`.
- Deduplicate artifact names.
- Sort results deterministically.
- Preserve required/optional information per reference.
- If the same previous artifact is referenced both required and optional, treat it as required.
Suggested output model:
```go
type PreviousArtifactRequirement struct {
Name string
Required bool
Sources []string // optional diagnostics
}
```
Tests:
- no artifacts;
- no previous inputs;
- one optional previous input;
- one required previous input;
- duplicate references;
- required plus optional reference to the same artifact;
- deterministic ordering.
### Phase 5: Resolve previous-session archive keys
Implement a narrow service/helper used by `prepare` to resolve previous-session artifact files from S3.
Responsibilities:
1. Locate the previous session's current remote state.
2. Download the previous session manifest, or the minimum remote metadata needed to resolve artifact source IDs.
3. Resolve `narratio.artifact.<name>` inside the previous session's artifact catalog/manifest.
4. Download the resolved artifact into the current session's `previous/` directory.
5. Download/store the previous session manifest as `previous/manifest.json`.
6. Return provenance records for manifest input recording.
Important archive invariant:
- Remote current state must be based on the committed archive marker.
- Do not treat incomplete archive uploads as current state.
- Use the existing archive/current remote layout and commit-marker rules.
- `current/run_id.txt` is the final remote commit marker and should be respected when locating current remote state.
Do not put prefix semantics into the storage adapter. Compute explicit bucket-relative keys in app/stage/archive helper code, then call the storage adapter.
Required vs optional behavior:
- Required previous artifact missing from S3/current manifest: fail `prepare`.
- Optional previous artifact missing from S3/current manifest: continue without materializing that input.
- Previous session missing entirely:
- fail if any referenced previous artifact is required;
- continue if all referenced previous artifacts are optional.
- If previous_session_id is unset:
- fail if any referenced previous artifact is required;
- continue and omit all previous-session inputs if all are optional.
Validation behavior:
- Downloaded artifacts should pass the same validation rules as current-session artifacts where practical.
- Generic Scriptorium artifacts should at least be non-empty.
- Invalid required artifact: fail.
- Invalid optional artifact: prefer fail if the object exists but is invalid, because invalid archived data is usually an operator problem rather than absence.
Tests:
- downloads previous manifest;
- downloads required previous artifact;
- skips missing optional previous artifact;
- fails missing required previous artifact;
- fails required previous artifact when previous_session_id is unset;
- optional previous artifact with no previous_session_id does not fail;
- respects current remote commit marker;
- does not use local previous-session workspace state;
- uses storage adapter with explicit keys.
### Phase 6: Integrate with `prepare`
Extend the `prepare` stage:
1. Run existing input materialization as before.
2. Detect previous-session artifact requirements.
3. If requirements exist, hydrate `previous/` from S3 according to the rules above.
4. Record hydrated previous artifacts in `manifest.Inputs`.
5. Preserve existing prepare outputs and provenance behavior.
Overwrite behavior:
- If `prepare` runs, it owns `previous/`.
- Before hydrating, clear the managed `previous/` directory, or clear the managed previous artifact paths.
- Prefer clearing the whole `previous/` directory if no other feature writes there.
- Under `--force`, this naturally overwrites local `previous/` state.
- Do not compare local and remote copies.
Skip behavior:
- Do not add stage-local skip logic.
- Runner-level skip remains authoritative.
- If the manifest says `prepare` succeeded and `--force` is not supplied, `prepare` does not run and no S3 downloads occur.
- If users add a new previous-session input after `prepare` already succeeded, they must rerun prepare with `--force`.
Error message requirement:
If an analyze-stage input cannot be resolved because `previous/` is missing or stale, the error should tell the operator to run:
```bash
narratio run-stage --force prepare
```
or the appropriate existing CLI command shape.
Tests:
- ordinary prepare without previous_session_id remains unchanged;
- prepare with optional previous artifact and no previous_session_id succeeds;
- prepare with required previous artifact and no previous_session_id fails;
- prepare with required previous artifact downloads to `previous/`;
- force prepare overwrites `previous/`;
- prepare records manifest inputs for previous artifacts;
- prepare skip behavior remains controlled by runner tests;
- no regression in S3 audio prepare behavior.
### Phase 7: Artifact resolver support
Update artifact resolution so Scriptorium inputs can resolve:
```text
narratio.previous_session.artifact.<artifact_name>
```
from the current session's `previous/` directory.
Rules:
- The resolver should not call S3.
- The resolver should not read the previous session's local workspace.
- The resolver should map the previous-session source ID to the local prepared copy under `previous/`.
- Resolution should use manifest input records when available.
- Fallback to the local `previous/` path may be allowed if consistent with existing resolver behavior, but manifest provenance should be preferred.
- Missing required input should fail with a clear prepare-oriented message.
- Optional missing input should be omitted.
Suggested provenance:
```text
previous_session.manifest.outputs
previous_session.archive.current
current_session.previous_cache
```
Use names that fit the existing manifest/resolver vocabulary.
Tests:
- resolves prepared previous artifact through manifest input record;
- resolves or fails appropriately when only filesystem copy exists, depending on chosen fallback policy;
- missing optional previous artifact is omitted;
- missing required previous artifact fails clearly;
- current-session `narratio.artifact.<name>` behavior is unchanged.
### Phase 8: Analyze-stage integration
The analyze stage should require little or no special previous-session logic if the resolver is designed correctly.
Confirm:
- Scriptorium input resolution accepts previous-session source IDs.
- The Scriptorium adapter receives a normal local input path.
- Render-debug and run modes behave the same as for ordinary inputs.
- Stage metadata includes useful input provenance if current structures support it.
Tests:
- configured artifact receives previous recap input;
- optional previous recap omitted when not prepared;
- required previous recap fails when not prepared;
- render-debug path works with previous-session inputs;
- no S3 calls occur from analyze.
### Phase 9: Archive `previous/`
Update archive behavior so the current session's durable `previous/` directory is uploaded/preserved.
Rules:
- `previous/` is current-session input/provenance state.
- It is not a generated current-session artifact.
- Archive it with the current session's durable state, alongside other durable session files according to the current archive layout.
- Preserve existing archive commit ordering.
- Do not make `previous/` upload the final commit marker.
- Do not treat missing `previous/` as an error when no previous-session inputs were prepared.
Tests:
- archive includes `previous/manifest.json` and prepared previous artifacts when present;
- archive omits or tolerates absent `previous/` when unused;
- archive commit ordering remains valid;
- cleanup behavior does not delete durable `previous/` before archive commit.
### Phase 10: Restore `previous/`
Update `narratio restore` so it restores the current session's archived `previous/` directory.
Rules:
- Restore `previous/` as durable current-session state.
- Do not infer or restore the previous session workspace merely because `previous_session_id` exists.
- Do not redownload previous-session artifacts from the previous session archive during restore; restore the current session's archived `previous/` cache.
- Respect existing restore flags and overwrite behavior.
Tests:
- restore downloads `previous/` when present;
- restore succeeds when `previous/` is absent;
- restore with force overwrites local `previous/` according to existing restore semantics;
- restored current session can run analyze using `previous/` without needing previous session archive access.
### Phase 11: Documentation updates after implementation
After implementation, update current-behavior docs. Do not document implemented behavior only in this roadmap.
Likely files:
- `docs/config.md`
- `docs/cli.md`
- `docs/operations.md`
- `docs/internal/stage-prepare.md`
- `docs/internal/artifacts.md`
- `docs/internal/storage.md` only if storage contracts change
- `docs/internal/workspace.md`
- relevant maintained examples under `examples/`
Docs should explain:
- `session.previous_session_id`;
- `--previous-session-id` if added;
- `narratio.previous_session.artifact.<artifact_name>`;
- `previous/` workspace directory;
- S3-authoritative previous-session behavior;
- required vs optional previous-session artifact handling;
- when to run `narratio run-stage --force prepare`.
Do not document future `--local` support as current behavior.
## Future Work
These items are explicitly out of scope for the initial implementation.
### `--local` previous-session source
A future flag may allow `prepare` to copy previous-session artifacts from a local previous session workspace instead of S3.
Possible future command shape:
```bash
narratio run-stage --force prepare --local
```
or a more specific flag such as:
```bash
narratio run-stage --force prepare --previous-source local
```
Do not implement this now.
### `narratio run --restore`
A future flag may run `narratio restore` before starting the regular pipeline:
```bash
narratio run --restore --session-id 2026-04-11
```
Do not implement this as part of previous-session artifact support unless it already exists and only requires documentation.
### Multi-previous-session support
A future design may support more than one previous/reference session.
Do not implement this now.
### Previous-session artifact version pinning
A future design may pin previous-session artifact inputs to a specific previous run ID or checksum.
Do not implement this now.
## Test Plan Summary
Run at least:
```bash
go test ./internal/config -v
go test ./internal/artifacts -v
go test ./internal/stage -run Prepare -v
go test ./internal/stage -run Analyze -v
go test ./internal/app -run TestExecute -v
go test ./...
```
Add focused tests for:
- session config and templating;
- previous-session source parsing;
- previous artifact requirement scanning;
- S3-backed previous artifact download;
- prepare skip/force semantics;
- manifest input provenance;
- resolver behavior;
- analyze integration;
- archive/restore `previous/` persistence;
- examples load/validate.
## Acceptance Criteria
The feature is complete when:
1. `session.yml` supports optional `previous_session_id`.
2. Session templating supports `previous_session_id`.
3. Scriptorium artifact inputs accept `narratio.previous_session.artifact.<artifact_name>`.
4. `prepare` scans enabled Scriptorium artifacts for previous-session artifact inputs.
5. `prepare` downloads referenced previous-session artifacts from S3 into `previous/`.
6. Required/optional behavior is correct.
7. `prepare` uses stage-level skip/force behavior and does not compare local and remote copies.
8. `analyze` resolves previous-session artifacts only from local prepared `previous/` state.
9. `archive` persists `previous/`.
10. `restore` restores `previous/`.
11. Tests cover config, prepare, resolver, analyze, archive, and restore behavior.
12. Current-behavior docs and maintained examples are updated after implementation.
13. No storage adapter implementation infers campaign/session/root-prefix semantics.
14. No previous-session S3 logic is added to `analyze` or the Scriptorium adapter.

View File

@@ -1,762 +0,0 @@
# Roadmap: Runtime-Defined Scriptorium Artifacts
## Status
Implementation roadmap for a pre-release hard cutover.
## Purpose
Narratio currently treats artifact generation as a narrow `analyze` stage that supports a hard-coded `session_recap` artifact. This roadmap describes how to generalize artifact generation so operators can define Scriptorium-backed output artifacts at runtime through `pipeline.yml`.
The goal is to keep Narratio as a fixed pipeline orchestrator while making the artifact generation step configurable, composable, deterministic, and easy to regenerate selectively.
## Desired Outcome
Operators should be able to define artifacts such as session recaps, player handouts, NPC summaries, quest logs, entity maps, or other campaign-specific outputs without changing Narratio code.
A configured artifact is declared under:
```text
pipeline.scriptorium.artifacts.<name>
```
Each configured artifact becomes a canonical runtime artifact source ID:
```text
narratio.artifact.<name>
```
For example:
```yaml
scriptorium:
artifacts:
session_recap:
enabled: true
prompt_id: dnd_session.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.trimmed
required: true
```
This artifact is addressable by later artifacts as:
```text
narratio.artifact.session_recap
```
A dependent artifact can then consume it explicitly:
```yaml
scriptorium:
artifacts:
player_handout:
enabled: true
depends_on:
- session_recap
prompt_id: dnd_session.player_handout
output_path: artifacts/player_handout.md
inputs:
recap:
source: narratio.artifact.session_recap
required: true
transcript:
source: narratio.transcript.trimmed
required: true
```
## Resolved Design Decisions
The following decisions are settled for the initial implementation:
1. Configured artifact outputs must live under Narratio's internal artifact output directory, initially `artifacts/`.
2. The artifact output directory should be defined as an internal default in `internal/config/defaults.go`, but no public configuration knob should be exposed yet.
3. Artifact `output_path` should remain explicit in the initial implementation to avoid guessing file extensions or output formats.
4. A disabled artifact may still be referenced as an input if its declared output already exists on disk and passes basic validation.
5. A disabled artifact is not executable during the current analyze run.
6. Artifact-to-artifact references require an explicit `depends_on` entry. Narratio should fail fast if the dependency declaration is missing.
7. The manifest remains stage-oriented: `analyze` succeeds or fails as a full stage.
8. Analyze-stage metadata may record per-artifact output details for provenance and later resolution, but not for intra-stage resume semantics.
9. `--artifacts` should be added as a CLI filter for selective artifact generation.
10. `--artifacts` does not imply `--force`; it only changes which configured artifacts are treated as executable when `analyze` actually runs.
11. Because Narratio is still pre-release, the hard-coded `session_recap` behavior should be removed immediately rather than deprecated gradually.
## Scope
This roadmap covers:
- introducing a runtime artifact catalog;
- generalizing configured Scriptorium artifact execution;
- supporting `narratio.artifact.<name>` source IDs;
- adding explicit artifact dependencies;
- supporting disabled-but-resolvable artifact inputs;
- adding selective artifact execution via `--artifacts`;
- recording generated artifacts in analyze-stage metadata and/or manifest outputs;
- removing hard-coded `session_recap` behavior;
- updating tests and documentation.
## Non-Goals
This feature should not turn Narratio into a general workflow engine.
The initial implementation should not add:
- arbitrary shell-command artifacts;
- arbitrary user-defined stages;
- loops or conditional branching;
- automatic archive promotion of generated artifacts;
- semantic knowledge of particular artifact types;
- per-artifact resume semantics within a successful or failed analyze stage;
- automatic dependency inference without `depends_on`.
Narratio should continue to orchestrate a fixed pipeline. The configurable part is the set of Scriptorium artifact invocations performed during the `analyze` stage.
## Current State
Narratio already has several relevant pieces in place:
- `pipeline.scriptorium.artifacts` is modeled as a map of artifact definitions.
- The Scriptorium adapter already accepts generic run/render requests.
- The artifact resolver already understands canonical artifact source IDs.
- The `analyze` stage already resolves inputs, optionally runs render-debug, invokes Scriptorium, verifies output, and records metadata.
The main limitation is that `analyze` currently treats `session_recap` as the only executable artifact and rejects other enabled artifact definitions.
## Target Architecture
### Runtime Artifact Catalog
Introduce a per-run artifact catalog that tracks built-in artifacts and configured artifacts.
Conceptually:
```text
ArtifactCatalog
├── built-in artifacts
│ ├── narratio.transcript.merged
│ ├── narratio.transcript.polished
│ ├── narratio.transcript.full
│ ├── narratio.transcript.trimmed
│ └── narratio.bounds.session
└── configured artifacts
├── narratio.artifact.session_recap
├── narratio.artifact.player_handout
└── narratio.artifact.npc_summary
```
The catalog should distinguish between three states:
```text
planned valid configured or built-in artifact known to Narratio
available artifact has been produced or otherwise resolved
executable configured artifact selected for execution in this analyze run
```
Configured artifacts can be planned without being executable. This distinction is important for disabled artifacts and for `--artifacts` filtering.
### Configured Artifact Source IDs
Configured artifact keys map directly to source IDs:
```text
pipeline.scriptorium.artifacts.<name>
→ narratio.artifact.<name>
```
`session_recap` should no longer be a special built-in analyze artifact. Instead, it is just a conventional configured artifact key:
```yaml
scriptorium:
artifacts:
session_recap:
enabled: true
prompt_id: dnd_session.session_recap
output_path: artifacts/session_recap.md
```
`narratio.artifact.session_recap` remains valid only because `session_recap` is configured.
### Artifact Output Directory
Add an internal default artifact output directory, initially:
```text
artifacts
```
This default should live in `internal/config/defaults.go` or the existing equivalent defaults location.
For the initial implementation:
- expose no public config knob for the artifact output directory;
- require each configured artifact to provide an explicit `output_path`;
- validate that each configured artifact `output_path` is run-relative;
- validate that each configured artifact `output_path` is under the internal artifact output directory;
- reject output paths that escape the run workspace or use path traversal.
This preserves future configurability without forcing Narratio to guess output extensions or formats now.
### Enabled, Disabled, and Selected Artifacts
Configured artifacts should have three distinct execution states:
```text
enabled by config artifact has enabled: true
selected for execution artifact remains executable after --artifacts filtering
disabled for execution artifact is not executable, but may be resolvable from disk
```
Without `--artifacts`, all configured artifacts with `enabled: true` are selected for execution.
With `--artifacts`, only the named artifacts are selected for execution. All other configured artifacts are treated as disabled for the current analyze invocation, regardless of their configured `enabled` value.
Disabled artifacts may still be resolved as inputs if their configured `output_path` exists on disk and passes validation.
### Disabled Artifact Resolution
If artifact `B` references artifact `A`, and `A` is disabled for execution, Narratio should attempt to resolve `A` from disk.
This should succeed only when:
1. `A` is defined in `pipeline.scriptorium.artifacts`;
2. `A` has a valid `output_path`;
3. the output path exists in the current run workspace;
4. the output is non-empty, or otherwise passes any available artifact-specific validation.
The resolved provenance should make the source clear, for example:
```text
filesystem.disabled_artifact_output
```
If the file does not exist or fails validation, the dependent artifact should fail before invoking Scriptorium.
Example error wording:
```text
artifact player_handout requires narratio.artifact.session_recap, but session_recap is disabled for execution and artifacts/session_recap.md does not exist
```
### Explicit Dependencies
Artifact-to-artifact references require explicit `depends_on` entries.
If artifact `B` has an input source of `narratio.artifact.A`, then `B.depends_on` must include `A`.
This should fail:
```yaml
scriptorium:
artifacts:
player_handout:
enabled: true
prompt_id: dnd_session.player_handout
output_path: artifacts/player_handout.md
inputs:
recap:
source: narratio.artifact.session_recap
required: true
```
This should pass:
```yaml
scriptorium:
artifacts:
player_handout:
enabled: true
depends_on:
- session_recap
prompt_id: dnd_session.player_handout
output_path: artifacts/player_handout.md
inputs:
recap:
source: narratio.artifact.session_recap
required: true
```
`depends_on` values refer to configured artifact keys, not full source IDs.
Dependency validation should fail on:
- references to unknown artifact keys;
- missing `depends_on` entries for artifact-to-artifact input references;
- self-dependencies;
- dependency cycles among executable artifacts.
Dependencies on disabled artifacts are permitted, but the disabled dependency must resolve from disk before the dependent artifact runs.
### Execution Order
The analyze stage should execute selected artifacts in dependency order.
Rules:
- selected artifacts are executable;
- disabled artifacts are never executed;
- selected artifacts may depend on other selected artifacts;
- selected artifacts may depend on disabled artifacts if those disabled artifacts resolve from disk;
- independent selected artifacts run in deterministic sorted-name order.
Use topological sorting over selected artifacts, while validating dependency references across the full configured artifact set.
### Input Resolution
Input resolution should use the artifact catalog and existing artifact resolver behavior.
For each configured artifact input:
- built-in sources resolve through existing resolver behavior;
- `previous_session_artifact` preserves existing behavior;
- `narratio.artifact.<name>` resolves through the runtime artifact catalog;
- selected dependencies resolve after being produced earlier in the same analyze execution;
- disabled dependencies resolve from their configured output path on disk;
- optional missing inputs are omitted;
- required missing inputs fail before Scriptorium is invoked.
### Analyze Stage Generalization
The `analyze` stage should become the generic Scriptorium artifact stage.
High-level flow:
1. Load configured Scriptorium artifacts.
2. Apply the `--artifacts` filter, if present.
3. If no artifacts are selected for execution, return success metadata with `skipped=true`.
4. Build the runtime artifact catalog.
5. Validate artifact names, output paths, source IDs, dependencies, selected artifacts, and required fields.
6. Resolve any disabled dependencies that are required by selected artifacts.
7. Sort selected artifacts by dependency order.
8. For each selected artifact:
- resolve configured inputs;
- build the Scriptorium run request;
- optionally run Scriptorium render-debug;
- run Scriptorium;
- fail on validation-failed result;
- verify the output exists and is non-empty;
- record artifact output metadata;
- register `narratio.artifact.<name>` as available in the catalog.
9. Return aggregate analyze-stage metadata containing all generated and reused artifacts relevant to the run.
The Scriptorium adapter should remain generic. It should not decide which artifacts run, how dependencies work, or how artifacts are registered.
### Manifest and Metadata
The manifest should remain stage-oriented.
This means:
- `analyze` succeeds or fails as a full stage;
- if `analyze` has already succeeded and the user does not force it, the runner skips it as a full stage;
- Narratio should not implement per-artifact resume in the first version.
However, analyze-stage metadata should still record artifact outputs for provenance and future resolution.
Recommended metadata shape:
```json
{
"skipped": false,
"artifacts": [
{
"name": "session_recap",
"source_id": "narratio.artifact.session_recap",
"output_kind": "scriptorium_artifact",
"path": "artifacts/session_recap.md",
"prompt_id": "dnd_session.session_recap",
"profile_id": "local-gemma-31b",
"provenance": "generated.current_analyze_run"
},
{
"name": "player_handout",
"source_id": "narratio.artifact.player_handout",
"output_kind": "scriptorium_artifact",
"path": "artifacts/player_handout.md",
"prompt_id": "dnd_session.player_handout",
"profile_id": "local-gemma-31b",
"provenance": "generated.current_analyze_run"
}
],
"reused_artifacts": [
{
"name": "session_recap",
"source_id": "narratio.artifact.session_recap",
"path": "artifacts/session_recap.md",
"provenance": "filesystem.disabled_artifact_output"
}
]
}
```
The exact struct can differ from this example, but it should preserve:
- artifact name;
- canonical source ID;
- output path;
- prompt/profile provenance for generated artifacts;
- reused-vs-generated provenance.
### Resume and Force Behavior
Keep resume behavior stage-level.
Recommended semantics:
```text
No --force, analyze already succeeded:
runner skips analyze, regardless of --artifacts.
--force, no --artifacts:
analyze regenerates all configured artifacts with enabled: true.
--force --artifacts player_handout:
analyze treats only player_handout as executable.
all other configured artifacts are disabled for execution.
disabled dependencies may be reused from disk.
--artifacts player_handout on a not-yet-completed analyze stage:
analyze runs only player_handout.
disabled dependencies may be reused from disk.
```
`--artifacts` should not imply `--force`. It is an execution filter, not a resume override.
### `--artifacts` CLI Flag
Add an `--artifacts` flag to commands that can execute or resume the analyze stage.
The flag should accept one or more configured artifact names. Internally, normalize values to a set of artifact keys.
Recommended behavior:
- validate all requested artifact names against `pipeline.scriptorium.artifacts`;
- reject unknown artifact names before running stages;
- treat requested artifacts as the only executable artifacts for the analyze stage;
- treat all other configured artifacts as disabled for execution;
- allow disabled artifacts to satisfy dependencies from disk as described above;
- if `--artifacts` is used while executing a stage other than `analyze`, either reject it or ignore it with a clear validation error. Prefer rejection.
The exact CLI parsing style can follow Narratio's existing conventions. Both comma-separated and repeatable values are acceptable if the CLI package supports them cleanly, but the internal representation should be a set of artifact keys.
### Archive Behavior
Do not automatically archive every generated artifact.
Artifact generation and archive promotion should remain separate concerns. Operators should continue to use `archive.promote_artifacts` to decide which generated files should be promoted or uploaded.
Example:
```yaml
archive:
promote_artifacts:
- from: artifacts/session_recap.md
to: artifacts/session_recap.md
required: true
- from: artifacts/player_handout.md
to: artifacts/player_handout.md
required: false
```
A later enhancement may add opt-in automatic promotion of configured artifacts, but explicit promotion should remain the default.
## Implementation Plan
### Phase 1: Config Model and Defaults
Add or update the configured artifact model to include:
- `enabled`;
- `depends_on`;
- `prompt_id`;
- `profile_id`;
- `output_path`;
- `timeout`;
- `render_debug`;
- `inputs`;
- `vars`.
Add an internal default artifact output directory in `internal/config/defaults.go`, initially set to `artifacts`.
Validation rules:
- artifact names must match a conservative identifier pattern such as `^[a-z][a-z0-9_]*$`;
- selected/executable artifacts require `prompt_id` and `output_path`;
- configured artifacts that may be referenced while disabled require `output_path`;
- configured artifact output paths must be run-relative;
- configured artifact output paths must live under the internal artifact output directory;
- configured artifact output paths must not escape the run workspace;
- `narratio.artifact.<name>` input sources must refer to configured artifact keys;
- any `narratio.artifact.<name>` input source must have a matching `depends_on` entry;
- `depends_on` entries must refer to configured artifact keys;
- dependencies must not contain self-references or executable cycles;
- input names and var names must remain compatible with the Scriptorium adapter's validation rules;
- unknown YAML fields must continue to fail strict decode.
Tests:
- valid single configured artifact;
- valid multiple independent artifacts;
- valid artifact-to-artifact dependency;
- valid dependency on disabled artifact with output path;
- invalid artifact name;
- missing required fields;
- output path outside `artifacts/`;
- dependency on missing artifact;
- missing `depends_on` for artifact input source;
- self-dependency;
- cycle detection;
- typo in `narratio.artifact.<name>` source;
- unknown YAML fields still fail strict decode.
### Phase 2: CLI Filtering
Add the `--artifacts` flag and carry the selected artifact set into the run execution options.
Implementation notes:
- parse values according to existing CLI conventions;
- normalize to artifact key strings;
- validate against configured artifact definitions after config load;
- make the selected set available to the analyze stage;
- reject use with commands or stages where analyze cannot run.
Tests:
- no `--artifacts` means all enabled artifacts are selected;
- one requested artifact is selected;
- multiple requested artifacts are selected;
- unknown requested artifact fails;
- `--artifacts` does not imply `--force`;
- `--artifacts` with already-succeeded analyze stage is skipped unless forced;
- `--artifacts` on unsupported stage command fails clearly.
### Phase 3: Runtime Artifact Catalog
Introduce an internal artifact catalog abstraction.
Responsibilities:
- register built-in artifact definitions;
- register configured artifact definitions;
- map configured artifact keys to `narratio.artifact.<name>` IDs;
- track planned, available, and executable artifact states;
- expose lookup by canonical source ID;
- record generated provenance;
- record disabled-from-disk provenance.
Keep the catalog narrow. It should not execute Scriptorium and should not understand prompt semantics.
Tests:
- built-in source lookup;
- configured source registration;
- duplicate/conflicting source handling;
- planned but unavailable artifact lookup;
- selected artifact state;
- disabled artifact state;
- registering an artifact as available after generation;
- registering a disabled artifact as available from disk;
- resolving a configured artifact from analyze metadata if that behavior is implemented.
### Phase 4: Resolver Integration
Update artifact resolution so configured artifact IDs are resolved through the runtime catalog.
Resolution behavior:
- built-in sources continue using existing resolver behavior;
- configured artifact sources resolve from catalog availability/provenance;
- selected configured artifacts become available after generation;
- disabled configured artifacts may become available from disk;
- missing optional configured artifact inputs are omitted;
- missing required configured artifact inputs fail clearly.
Tests:
- configured artifact consumes a built-in transcript source;
- configured artifact consumes another configured artifact produced earlier in the same analyze run;
- configured artifact consumes a disabled artifact resolved from disk;
- required disabled artifact missing on disk fails;
- required configured artifact missing fails;
- optional missing configured artifact is omitted;
- reused artifact provenance is recorded distinctly from generated artifact provenance.
### Phase 5: Analyze Stage Generalization
Refactor `analyze` to execute selected configured artifacts.
Implementation notes:
- remove the hard-coded `session_recap` selection path;
- remove the hard-coded rejection of non-`session_recap` artifacts;
- preserve skip behavior when Scriptorium config is absent or no artifacts are selected;
- build the runtime artifact catalog;
- apply `--artifacts` filtering;
- validate selected artifacts and their dependencies;
- pre-resolve disabled dependencies from disk where required;
- compute deterministic dependency order;
- execute selected artifacts one at a time in dependency order;
- keep render-debug behavior at global and artifact levels;
- keep Scriptorium adapter invocation generic;
- after each successful run, register the artifact as available in the catalog;
- aggregate generated and reused artifact metadata.
Tests:
- no Scriptorium config skips;
- empty artifact map skips;
- no selected artifacts skips;
- disabled artifacts do not run;
- one selected artifact runs;
- multiple independent artifacts run in deterministic order;
- dependent selected artifact receives prior selected artifact as input;
- dependent selected artifact receives disabled-from-disk artifact as input;
- render-debug works for configured artifacts;
- Scriptorium validation failure fails the stage;
- missing required input fails the stage;
- successful outputs are non-empty and recorded;
- artifact filter executes only requested artifacts.
### Phase 6: Manifest and Stage Metadata
Update analyze-stage metadata and manifest output recording to support dynamic configured artifacts.
Recommended behavior:
- every generated configured artifact gets `source_id: narratio.artifact.<name>`;
- every generated configured artifact gets a generic output kind such as `scriptorium_artifact`;
- reused disabled artifacts are recorded separately from generated artifacts;
- metadata is sufficient for debugging, provenance, and future resolver support;
- metadata does not create per-artifact resume semantics.
Because this is a pre-release hard cutover, do not preserve a special legacy `session_recap` output kind unless a current internal test or archive path still requires it temporarily. Prefer updating tests and examples to treat `session_recap` as an ordinary configured artifact.
Tests:
- metadata records one generated configured artifact;
- metadata records multiple generated configured artifacts;
- metadata records reused disabled artifact provenance;
- `session_recap` is recorded as a normal configured artifact;
- manifest still treats `analyze` as a single succeeded or failed stage;
- runner skip behavior remains stage-level.
### Phase 7: Archive and Promotion Review
Review archive behavior after dynamic artifacts are recorded.
Implementation notes:
- do not automatically promote every configured artifact;
- keep `archive.promote_artifacts` explicit;
- update default or example promotion rules to use configured `session_recap` output path;
- ensure required promotion rules fail clearly when selected artifact generation did not produce a required file.
Tests:
- generated artifact can be promoted by explicit archive rule;
- required archive promotion fails if selected artifact was not generated and no file exists;
- optional archive promotion skips cleanly if file is absent;
- hard cutover does not rely on hard-coded `session_recap` generation.
### Phase 8: Documentation and Examples
Status: complete.
Update documentation after the implementation is complete.
Recommended documentation changes:
- update `docs/config.md` with the generalized artifact configuration model;
- update `docs/internal/artifacts.md` to describe the runtime artifact catalog;
- update `docs/stages/analyze.md` to describe generic Scriptorium artifact generation;
- update Scriptorium integration docs only if the adapter contract changes;
- update full annotated pipeline examples;
- add at least one example with multiple artifacts and one dependency;
- document `--artifacts` behavior and its relationship to `--force`;
- remove documentation stating that only `session_recap` is supported.
Documentation should make clear that:
- configured artifact source IDs use `narratio.artifact.<name>`;
- `depends_on` uses artifact keys, not full source IDs;
- artifact-to-artifact source references require explicit `depends_on`;
- disabled artifacts can be reused from disk when required by selected artifacts;
- `--artifacts` filters execution but does not imply `--force`;
- archive promotion remains explicit;
- per-artifact resume is not part of the initial implementation.
## Migration Strategy
Because Narratio is pre-release, perform a hard cutover.
Required changes:
1. Remove the hard-coded `session_recap` analyze behavior.
2. Require `session_recap` to be declared under `pipeline.scriptorium.artifacts.session_recap` if the operator wants a session recap.
3. Treat `narratio.artifact.session_recap` as valid only when `session_recap` is a configured artifact key.
4. Update config examples to show `session_recap` as a normal configured artifact.
5. Update tests to stop assuming that `session_recap` is a built-in analyze artifact.
6. Keep archive promotion explicit and path-based.
Example replacement config:
```yaml
scriptorium:
binary: scriptorium
config_path: /etc/scriptorium/config.yml
timeout: 10m
render_debug: false
artifacts:
session_recap:
enabled: true
prompt_id: dnd_session.session_recap
profile_id: local-gemma-31b
output_path: artifacts/session_recap.md
timeout: 20m
inputs:
transcript:
source: narratio.transcript.trimmed
required: true
prior_recap:
source: previous_session_artifact
artifact: artifacts/session_recap.md
required: false
vars:
artifact_title: Session Recap
```
## Acceptance Criteria
The feature is complete when:
- operators can define more than one enabled Scriptorium artifact in `pipeline.yml`;
- Narratio runs selected artifacts in deterministic dependency order;
- configured artifacts are addressable as `narratio.artifact.<name>`;
- one configured artifact can consume another configured artifact as an input;
- artifact-to-artifact input references require explicit `depends_on`;
- disabled artifacts can satisfy dependencies from existing on-disk outputs;
- missing required disabled artifacts fail clearly;
- optional missing inputs are omitted;
- `--artifacts` can selectively execute valid configured artifact names;
- `--artifacts` does not imply `--force`;
- render-debug behavior works for all configured artifacts;
- generated and reused artifacts are recorded in analyze-stage metadata;
- `session_recap` is no longer hard-coded and works as a normal configured artifact;
- archive promotion remains explicit;
- tests cover config validation, dependency sorting, disabled artifact resolution, resolver behavior, CLI filtering, analyze execution, archive interactions, and metadata.
## Suggested Implementation Order
1. Config model, defaults, and validation.
2. CLI parsing and propagation of `--artifacts` selection.
3. Runtime artifact catalog.
4. Resolver integration for configured artifacts.
5. Analyze stage generalization.
6. Stage metadata and manifest output recording.
7. Archive behavior review.
8. Documentation and examples.
This order keeps the most static pieces first, then moves into execution behavior once the configuration contract is explicit and well tested.

View File

@@ -6,7 +6,7 @@ Canonical operator troubleshooting guide for recurring implemented Narratio fail
## Config file discovery failure
Symptom:
- `run`, `plan`, `resume`, or `run-stage` fails with config/session not found.
- `run`, `plan`, `resume`, `run-stage`, or `restore` fails with config/session not found.
Likely Cause:
- `pipeline.yml` or `session.yml` is missing from discovery paths.
@@ -192,7 +192,7 @@ Links:
## Session lock conflict (`.lock`)
Symptom:
- run fails with lock conflict for session workdir.
- `run`, `resume`, `run-stage`, or `restore` fails with lock conflict for session workdir.
Likely Cause:
- another Narratio process is running same session.
@@ -207,13 +207,104 @@ ps aux | grep narratio
```
Safe Fix:
- wait for active run to finish.
- wait for active process 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)
## Restore remote current pointer or manifest missing
Symptom:
- `restore` fails with remote current pointer or current manifest errors.
Likely Cause:
- `current/run_id.txt` was never published.
- `current/manifest.json` is missing for the session prefix.
- archive commit did not complete.
Diagnostics:
```bash
narratio restore --config /path/to/pipeline.yml --session /path/to/session.yml --session-id 2026-04-04 --dry-run
```
Safe Fix:
- verify archive stage succeeded for the target session.
- rerun/archive from a healthy source workspace so current pointers are published.
Links:
- [docs/operations.md](./operations.md)
- [docs/internal/stage-archive.md](./internal/stage-archive.md)
## Restore manifest identity mismatch
Symptom:
- `restore` fails because remote manifest session or campaign does not match requested values.
Likely Cause:
- wrong `--session-id` or wrong session config selected.
- archive prefix points to a different campaign/session.
Diagnostics:
```bash
narratio restore --config /path/to/pipeline.yml --session /path/to/session.yml --session-id 2026-04-04 --dry-run
```
Safe Fix:
- use the correct session config and `--session-id`.
- verify campaign/session identity in local config before restore.
Links:
- [docs/config.md](./config.md)
- [docs/operations.md](./operations.md)
## Restore conflict without `--force`
Symptom:
- `restore` fails with `restore conflict` and conflict counts.
Likely Cause:
- local durable file differs from remote file for one or more planned restore paths.
Diagnostics:
```bash
narratio restore --config /path/to/pipeline.yml --session /path/to/session.yml --session-id 2026-04-04 --dry-run
```
Safe Fix:
- review planned conflicts.
- rerun with `--force` only when remote state should overwrite local state.
Links:
- [docs/cli.md](./cli.md)
- [docs/operations.md](./operations.md)
## Restore report expectations
Symptom:
- operator expects restore report file but does not find one.
Likely Cause:
- restore was executed in `--dry-run` mode.
- restore failed before report persistence path (for example lock acquisition failure).
Diagnostics:
```bash
ls -l {workspace.root}/work/{campaign}/{session_id}/reports/restore-latest.json
```
Safe Fix:
- run non-dry-run restore for durable report output.
- resolve lock or early preflight failures and retry.
Links:
- [docs/operations.md](./operations.md)
## Secrets env-dir or credential-env failure
Symptom:
@@ -280,7 +371,7 @@ narratio run-stage --config /path/to/pipeline.yml --session /path/to/session.yml
Safe Fix:
- rerun or resume upstream stages to generate required files.
- adjust promotion rules to match files that must exist.
- adjust promotion `source`/`dest` rules to match artifacts that must exist.
- retry after storage issue is resolved.
Links:

View File

@@ -40,16 +40,16 @@ archive:
# Optional booleans; defaults are true.
enabled: true
upload_run: true
# Optional promotion rules; required files fail archive if missing.
# Optional promotion rules; sources use Narratio artifact source IDs.
promote_artifacts:
- from: transcripts/trimmed.json
to: transcripts/trimmed.json
- source: narratio.transcript.trimmed
dest: transcripts/trimmed.json
required: true
- from: artifacts/session_recap.md
to: artifacts/session_recap.md
- source: narratio.artifact.session_recap
dest: artifacts/session_recap.md
required: true
- from: artifacts/player_handout.md
to: artifacts/player_handout.md
- source: narratio.artifact.player_handout
dest: artifacts/player_handout.md
required: false
whisperx:
@@ -133,9 +133,7 @@ scriptorium:
source: narratio.transcript.trimmed
required: true
previous_recap:
source: previous_session_artifact
artifact: session_recap
path: ""
source: narratio.previous_session.artifact.session_recap
required: false
vars:
session_id: true

View File

@@ -19,14 +19,14 @@ archive:
enabled: true
upload_run: true
promote_artifacts:
- from: transcripts/trimmed.json
to: transcripts/trimmed.json
- source: narratio.transcript.trimmed
dest: transcripts/trimmed.json
required: true
- from: artifacts/session_recap.md
to: artifacts/session_recap.md
- source: narratio.artifact.session_recap
dest: artifacts/session_recap.md
required: true
- from: artifacts/player_handout.md
to: artifacts/player_handout.md
- source: narratio.artifact.player_handout
dest: artifacts/player_handout.md
required: false
whisperx:
@@ -81,8 +81,7 @@ scriptorium:
source: narratio.transcript.trimmed
required: true
previous_recap:
source: previous_session_artifact
artifact: session_recap
source: narratio.previous_session.artifact.session_recap
required: false
vars:
session_id: true

View File

@@ -7,7 +7,7 @@ import (
"strings"
)
var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage"}
var supportedCommands = []string{"run", "plan", "status", "resume", "run-stage", "restore"}
// Execute dispatches CLI commands and returns a process exit code.
func Execute(args []string, stdout, stderr io.Writer) int {
@@ -32,6 +32,8 @@ func Execute(args []string, stdout, stderr io.Writer) int {
err = Resume(ctx, cmdArgs, stdout)
case "run-stage":
err = RunStage(ctx, cmdArgs, stdout)
case "restore":
err = Restore(ctx, cmdArgs, stdout)
default:
fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd)
printUsage(stderr)

View File

@@ -22,10 +22,12 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string
var sessionPath string
var sessionID string
var previousSessionID string
var force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
if err := fs.Parse(args); err != nil {
@@ -44,7 +46,8 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
}
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID,
SessionID: sessionID,
PreviousSessionID: previousSessionID,
})
if err != nil {
return fmt.Errorf("plan: %w", err)

View File

@@ -82,6 +82,7 @@ func TestPostArchiveCleanupWorkdirOnly(t *testing.T) {
assertExists(t, cfg.Pipeline.Workspace.Root)
assertExists(t, seed.otherRunDir)
assertExists(t, seed.previousCachePath)
assertMissing(t, seed.runWorkDir)
assertExists(t, seed.spoolAudioDir)
}
@@ -98,6 +99,7 @@ func TestPostArchiveCleanupBothPolicies(t *testing.T) {
assertMissing(t, seed.spoolAudioDir)
assertMissing(t, seed.runWorkDir)
assertExists(t, seed.otherRunDir)
assertExists(t, seed.previousCachePath)
}
func TestPostArchiveCleanupNotRunWhenArchiveFails(t *testing.T) {
@@ -195,7 +197,7 @@ func TestPostArchiveCleanupNotRunWhenPromotionIsMissing(t *testing.T) {
cfg.Pipeline.Spool.DeleteAudioAfterArchive = true
cfg.Pipeline.Workspace.CleanupAfterArchive = true
cfg.Pipeline.Archive.PromoteArtifacts = []config.ArchivePromotionRule{
{From: "artifacts/missing.md", To: "artifacts/missing.md", Required: boolPtr(true)},
{Source: "narratio.transcript.merged", Dest: "transcripts/merged.json", Required: boolPtr(true)},
}
archiveStageImpl, err := stage.Select("archive")
@@ -203,7 +205,7 @@ func TestPostArchiveCleanupNotRunWhenPromotionIsMissing(t *testing.T) {
t.Fatalf("Select(archive) error = %v", err)
}
_, err = executeStages(context.Background(), cfg, []stage.Stage{archiveStageImpl}, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
if err == nil || !strings.Contains(err.Error(), "required promotion source missing") {
if err == nil || !strings.Contains(err.Error(), "required promotion source unavailable") {
t.Fatalf("executeStages() error = %v, want promotion-missing failure", err)
}
@@ -256,11 +258,12 @@ func TestPostArchiveCleanupNotRunWhenCurrentPointerUploadFails(t *testing.T) {
}
type cleanupSeed struct {
runWorkDir string
otherRunDir string
spoolAudioDir string
localSourceAudio string
sessionPrefix string
runWorkDir string
otherRunDir string
spoolAudioDir string
localSourceAudio string
previousCachePath string
sessionPrefix string
}
func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) {
@@ -274,11 +277,18 @@ func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) {
runWorkDir := artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)
otherRunDir := artifacts.SessionRunRootForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, "20260516T010204Z-5e6f7a8b")
spoolAudioDir := artifacts.SessionSpoolAudioDir(cfg.Pipeline.Spool.Root, cfg.Session.Campaign, cfg.Session.SessionID, runID)
previousCachePath := artifacts.SessionPreviousArtifactPathForCampaign(
cfg.Pipeline.Workspace.Root,
cfg.Session.Campaign,
cfg.Session.SessionID,
"session_recap.md",
)
mustWriteFile(t, filepath.Join(runWorkDir, "manifest.json"), "{}\n")
mustWriteFile(t, filepath.Join(runWorkDir, "logs", "stage.log"), "log\n")
mustWriteFile(t, filepath.Join(otherRunDir, "logs", "stage.log"), "other\n")
mustWriteFile(t, filepath.Join(spoolAudioDir, "speaker.flac"), "flac\n")
mustWriteFile(t, previousCachePath, "# previous recap\n")
localSourceAudio := filepath.Join(filepath.Dir(cfg.SessionPath), "audio", "alice.flac")
mustWriteFile(t, localSourceAudio, "source\n")
@@ -301,11 +311,12 @@ func cleanupFixtureConfig(t *testing.T) (*config.Config, cleanupSeed) {
}
return cfg, cleanupSeed{
runWorkDir: runWorkDir,
otherRunDir: otherRunDir,
spoolAudioDir: spoolAudioDir,
localSourceAudio: localSourceAudio,
sessionPrefix: seed.S3SessionPrefix,
runWorkDir: runWorkDir,
otherRunDir: otherRunDir,
spoolAudioDir: spoolAudioDir,
localSourceAudio: localSourceAudio,
previousCachePath: previousCachePath,
sessionPrefix: seed.S3SessionPrefix,
}
}
@@ -322,8 +333,15 @@ func archiveStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
Enabled: boolPtr(true),
UploadRun: boolPtr(true),
PromoteArtifacts: []config.ArchivePromotionRule{
{From: "transcripts/trimmed.json", To: "transcripts/trimmed.json", Required: boolPtr(true)},
{From: "artifacts/session_recap.md", To: "artifacts/session_recap.md", Required: boolPtr(true)},
{Source: "narratio.transcript.trimmed", Dest: "transcripts/trimmed.json", Required: boolPtr(true)},
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
},
}
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
OutputPath: "artifacts/session_recap.md",
},
},
}
writeArchiveFixtureRunFiles(
@@ -353,14 +371,14 @@ func writeArchiveFixtureRunFiles(t *testing.T, runWorkDir, sessionRoot string) {
t.Helper()
mustWriteFile(t, filepath.Join(runWorkDir, "prepare", "inputs", "session.yml"), "session_id: 2026-05-03\n")
mustWriteFile(t, filepath.Join(runWorkDir, "transcribe", "outputs", "transcripts", "raw", "speaker.json"), "{}\n")
mustWriteFile(t, filepath.Join(runWorkDir, "trim", "outputs", "transcripts", "trimmed.json"), "{}\n")
mustWriteFile(t, filepath.Join(runWorkDir, "trim", "outputs", "transcripts", "trimmed.json"), "{\"segments\":[]}\n")
mustWriteFile(t, filepath.Join(runWorkDir, "analyze", "outputs", "artifacts", "session_recap.md"), "# recap\n")
mustWriteFile(t, filepath.Join(runWorkDir, "polish", "reports", "audita.report.json"), "{}\n")
mustWriteFile(t, filepath.Join(runWorkDir, "merge", "config", "seriatim.generated.yml"), "key: value\n")
mustWriteFile(t, filepath.Join(runWorkDir, "logs", "audita.stderr.log"), "stderr\n")
mustWriteFile(t, filepath.Join(runWorkDir, "manifest.json"), "{}\n")
mustWriteFile(t, filepath.Join(sessionRoot, "transcripts", "trimmed.json"), "{}\n")
mustWriteFile(t, filepath.Join(sessionRoot, "transcripts", "trimmed.json"), "{\"segments\":[]}\n")
mustWriteFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
}

155
internal/app/restore.go Normal file
View File

@@ -0,0 +1,155 @@
package app
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/logging"
)
var newObjectStoreFromConfigFn = storage.NewObjectStoreFromConfig
var discoverRemoteCurrentStateFn = discoverRemoteCurrentState
var buildRestorePlanFn = buildRestorePlan
var executeRestorePlanFn = executeRestorePlan
// Restore validates restore CLI/config inputs and storage preflight for future restore phases.
func Restore(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("restore", flag.ContinueOnError)
fs.SetOutput(out)
var pipelinePath string
var sessionPath string
var sessionID string
var previousSessionID string
var dryRun bool
var force bool
var includeAudio bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files")
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")
fs.Usage = func() {
_, _ = fmt.Fprintln(out, "Usage: narratio restore [--config <path>] [--session <path>] [--session-id <value>] [--previous-session-id <value>] [--dry-run] [--force] [--include-audio]")
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintln(out, "Flags:")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return fmt.Errorf("restore: invalid flags: %w", err)
}
if fs.NArg() != 0 {
return fmt.Errorf("restore: unexpected positional arguments")
}
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
if err != nil {
return fmt.Errorf("restore: %w", err)
}
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
if err != nil {
return fmt.Errorf("restore: %w", err)
}
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID,
PreviousSessionID: previousSessionID,
})
if err != nil {
return fmt.Errorf("restore: %w", err)
}
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("restore: %w", err)
}
if _, err := loadSecretsFromConfig(cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)); err != nil {
return fmt.Errorf("restore: %w", err)
}
objectStore, err := newObjectStoreFromConfigFn(ctx, cfg)
if err != nil {
return fmt.Errorf("restore: %w", err)
}
current, err := discoverRemoteCurrentStateFn(ctx, cfg, objectStore)
if err != nil {
return fmt.Errorf("restore: %w", err)
}
plan, err := buildRestorePlanFn(ctx, cfg, current, objectStore, RestorePlanOptions{
IncludeAudio: includeAudio,
Force: force,
DryRun: dryRun,
})
if err != nil {
return fmt.Errorf("restore: %w", err)
}
report, err := newRestoreReport(current, plan, RestorePlanOptions{
IncludeAudio: includeAudio,
Force: force,
DryRun: dryRun,
})
if err != nil {
return fmt.Errorf("restore: %w", err)
}
if dryRun {
if err := writeRestoreDryRunSummary(out, report); err != nil {
return fmt.Errorf("restore: write plan output: %w", err)
}
return nil
}
artifactStore := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
if _, err := artifactStore.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID); err != nil {
return fmt.Errorf("restore: prepare workdir: %w", err)
}
lock, err := artifactStore.AcquireSessionLockFor(cfg.Session.Campaign, cfg.Session.SessionID)
if err != nil {
return fmt.Errorf("restore: acquire session lock: %w", err)
}
defer func() {
_ = artifactStore.ReleaseSessionLock(lock)
}()
if plan.ConflictCount > 0 && !force {
report.setFailed(fmt.Errorf("conflict: %d conflicting path(s)", plan.ConflictCount))
if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil {
return fmt.Errorf("restore: report failure: %w", reportErr)
}
return fmt.Errorf(
"restore conflict: %d conflicting path(s); rerun with --force to overwrite (download=%d skip_same=%d conflicts=%d)",
plan.ConflictCount,
plan.DownloadCount,
plan.SkipSameCount,
plan.ConflictCount,
)
}
result, err := executeRestorePlanFn(ctx, cfg, current, plan, report, objectStore)
if err != nil {
report.setFailed(err)
if _, reportErr := persistRestoreReport(artifactStore, cfg, report); reportErr != nil {
return fmt.Errorf("restore: execute plan failed (%v) and report write failed (%v)", err, reportErr)
}
return fmt.Errorf("restore: execute plan: %w", err)
}
report.Execution.Downloaded = result.DownloadedCount
report.setSucceeded()
if _, err := persistRestoreReport(artifactStore, cfg, report); err != nil {
return fmt.Errorf("restore: write report: %w", err)
}
if err := writeRestoreSuccessSummary(out, report); err != nil {
return fmt.Errorf("restore: write summary: %w", err)
}
return nil
}

View File

@@ -0,0 +1,139 @@
package app
import (
"context"
"fmt"
"os"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// RemoteCurrentState captures discovered committed remote archive state for one session.
type RemoteCurrentState struct {
Bucket string
SessionPrefix string
CurrentRunIDKey string
CurrentManifestKey string
RunID string
SessionID string
Campaign string
Manifest *manifest.Manifest
}
func discoverRemoteCurrentState(ctx context.Context, cfg *config.Config, store storage.ObjectStore) (*RemoteCurrentState, error) {
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
return nil, fmt.Errorf("resolved config with pipeline/session is required")
}
if store == nil {
return nil, fmt.Errorf("remote object store is required")
}
bucket := artifacts.ResolveArchiveBucket(cfg, nil)
if strings.TrimSpace(bucket) == "" {
return nil, fmt.Errorf("archive bucket is required")
}
sessionPrefix, err := artifacts.ResolveArchiveSessionPrefix(cfg, nil)
if err != nil {
return nil, fmt.Errorf("resolve archive session prefix: %w", err)
}
currentManifestKey, currentRunIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
exists, err := store.Exists(ctx, currentRunIDKey)
if err != nil {
return nil, fmt.Errorf("check remote current run pointer %q: %w", currentRunIDKey, err)
}
if !exists {
return nil, fmt.Errorf("remote current run pointer missing: %q", currentRunIDKey)
}
runIDPath, err := downloadObjectToTemp(ctx, store, currentRunIDKey, "narratio-restore-current-run-id-*.txt")
if err != nil {
return nil, fmt.Errorf("download remote current run pointer %q: %w", currentRunIDKey, err)
}
defer func() { _ = os.Remove(runIDPath) }()
runIDData, err := os.ReadFile(runIDPath)
if err != nil {
return nil, fmt.Errorf("read downloaded run pointer %q: %w", currentRunIDKey, err)
}
runID := strings.TrimSpace(string(runIDData))
if runID == "" {
return nil, fmt.Errorf("remote current run pointer %q is empty", currentRunIDKey)
}
exists, err = store.Exists(ctx, currentManifestKey)
if err != nil {
return nil, fmt.Errorf("check remote current manifest %q: %w", currentManifestKey, err)
}
if !exists {
return nil, fmt.Errorf("remote current manifest missing: %q", currentManifestKey)
}
manifestPath, err := downloadObjectToTemp(ctx, store, currentManifestKey, "narratio-restore-current-manifest-*.json")
if err != nil {
return nil, fmt.Errorf("download remote current manifest %q: %w", currentManifestKey, err)
}
defer func() { _ = os.Remove(manifestPath) }()
manifestStore := &manifest.LocalStore{}
remoteManifest, err := manifestStore.Load(ctx, manifestPath)
if err != nil {
return nil, fmt.Errorf("remote current manifest decode failed: %w", err)
}
requestedSession := strings.TrimSpace(cfg.Session.SessionID)
requestedCampaign := strings.TrimSpace(cfg.Session.Campaign)
manifestSession := strings.TrimSpace(remoteManifest.SessionID)
manifestCampaign := strings.TrimSpace(remoteManifest.Campaign)
if manifestSession != requestedSession {
return nil, fmt.Errorf(
"remote current manifest session_id %q does not match requested session_id %q",
manifestSession,
requestedSession,
)
}
if manifestCampaign == "" {
return nil, fmt.Errorf("remote current manifest campaign is required")
}
if manifestCampaign != requestedCampaign {
return nil, fmt.Errorf(
"remote current manifest campaign %q does not match requested campaign %q",
manifestCampaign,
requestedCampaign,
)
}
return &RemoteCurrentState{
Bucket: bucket,
SessionPrefix: sessionPrefix,
CurrentRunIDKey: currentRunIDKey,
CurrentManifestKey: currentManifestKey,
RunID: runID,
SessionID: manifestSession,
Campaign: manifestCampaign,
Manifest: remoteManifest,
}, nil
}
func downloadObjectToTemp(ctx context.Context, store storage.ObjectStore, key, pattern string) (string, error) {
tmp, err := os.CreateTemp("", pattern)
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
path := tmp.Name()
if err := tmp.Close(); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("close temp file: %w", err)
}
if err := store.Download(ctx, key, path); err != nil {
_ = os.Remove(path)
return "", err
}
return path, nil
}

View File

@@ -0,0 +1,239 @@
package app
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestDiscoverRemoteCurrentStateSuccess(t *testing.T) {
cfg := restoreDiscoveryConfig()
store := &storage.FakeBackend{}
sessionPrefix, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)})
state, err := discoverRemoteCurrentState(context.Background(), cfg, store)
if err != nil {
t.Fatalf("discoverRemoteCurrentState() error = %v", err)
}
if state.RunID != "20260519T010203Z-a1b2c3d4" {
t.Fatalf("run id = %q, want 20260519T010203Z-a1b2c3d4", state.RunID)
}
if state.SessionPrefix != sessionPrefix {
t.Fatalf("session prefix = %q, want %q", state.SessionPrefix, sessionPrefix)
}
if state.CurrentRunIDKey != runIDKey {
t.Fatalf("current run id key = %q, want %q", state.CurrentRunIDKey, runIDKey)
}
if state.CurrentManifestKey != manifestKey {
t.Fatalf("current manifest key = %q, want %q", state.CurrentManifestKey, manifestKey)
}
if state.Manifest == nil {
t.Fatal("manifest is nil")
}
}
func TestDiscoverRemoteCurrentStateMissingRunPointerFails(t *testing.T) {
cfg := restoreDiscoveryConfig()
store := &storage.FakeBackend{}
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
if err == nil || !strings.Contains(err.Error(), "remote current run pointer missing") {
t.Fatalf("error = %v, want missing run pointer failure", err)
}
}
func TestDiscoverRemoteCurrentStateEmptyRunPointerFails(t *testing.T) {
cfg := restoreDiscoveryConfig()
store := &storage.FakeBackend{}
_, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte(" \n\t")})
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)})
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
if err == nil || !strings.Contains(err.Error(), "is empty") {
t.Fatalf("error = %v, want empty run pointer failure", err)
}
}
func TestDiscoverRemoteCurrentStateMissingManifestFails(t *testing.T) {
cfg := restoreDiscoveryConfig()
store := &storage.FakeBackend{}
_, _, runIDKey := restoreDiscoveryKeys(cfg)
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
if err == nil || !strings.Contains(err.Error(), "remote current manifest missing") {
t.Fatalf("error = %v, want missing manifest failure", err)
}
}
func TestDiscoverRemoteCurrentStateInvalidManifestFails(t *testing.T) {
cfg := restoreDiscoveryConfig()
store := &storage.FakeBackend{}
_, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: []byte("{invalid json")})
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
if err == nil || !strings.Contains(err.Error(), "remote current manifest decode failed") {
t.Fatalf("error = %v, want manifest decode failure", err)
}
}
func TestDiscoverRemoteCurrentStateSessionMismatchFails(t *testing.T) {
cfg := restoreDiscoveryConfig()
store := &storage.FakeBackend{}
_, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, "wrong-session", cfg.Session.Campaign)})
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
if err == nil || !strings.Contains(err.Error(), "does not match requested session_id") {
t.Fatalf("error = %v, want session mismatch failure", err)
}
}
func TestDiscoverRemoteCurrentStateCampaignMismatchFails(t *testing.T) {
cfg := restoreDiscoveryConfig()
store := &storage.FakeBackend{}
_, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, "wrong-campaign")})
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
if err == nil || !strings.Contains(err.Error(), "does not match requested campaign") {
t.Fatalf("error = %v, want campaign mismatch failure", err)
}
}
func TestDiscoverRemoteCurrentStateEmptyCampaignFails(t *testing.T) {
cfg := restoreDiscoveryConfig()
store := &storage.FakeBackend{}
_, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, "")})
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
if err == nil || !strings.Contains(err.Error(), "campaign is required") {
t.Fatalf("error = %v, want empty campaign failure", err)
}
}
func TestDiscoverRemoteCurrentStateUsesCurrentKeysUnderSessionPrefix(t *testing.T) {
cfg := restoreDiscoveryConfig()
sessionPrefix, manifestKey, runIDKey := restoreDiscoveryKeys(cfg)
base := &storage.FakeBackend{}
store := &captureObjectStore{delegate: base}
base.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
base.SeedObject(storage.FakeObject{Key: manifestKey, Data: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign)})
_, err := discoverRemoteCurrentState(context.Background(), cfg, store)
if err != nil {
t.Fatalf("discoverRemoteCurrentState() error = %v", err)
}
expectedRunKey := fmt.Sprintf("%scurrent/run_id.txt", sessionPrefix)
expectedManifestKey := fmt.Sprintf("%scurrent/manifest.json", sessionPrefix)
if !containsString(store.existsKeys, expectedRunKey) {
t.Fatalf("exists keys = %#v, want run pointer key %q", store.existsKeys, expectedRunKey)
}
if !containsString(store.existsKeys, expectedManifestKey) {
t.Fatalf("exists keys = %#v, want manifest key %q", store.existsKeys, expectedManifestKey)
}
if !containsString(store.downloadKeys, expectedRunKey) {
t.Fatalf("download keys = %#v, want run pointer key %q", store.downloadKeys, expectedRunKey)
}
if !containsString(store.downloadKeys, expectedManifestKey) {
t.Fatalf("download keys = %#v, want manifest key %q", store.downloadKeys, expectedManifestKey)
}
}
type captureObjectStore struct {
delegate storage.ObjectStore
existsKeys []string
downloadKeys []string
}
func (s *captureObjectStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
return s.delegate.List(ctx, prefix)
}
func (s *captureObjectStore) Download(ctx context.Context, key, localPath string) error {
s.downloadKeys = append(s.downloadKeys, key)
return s.delegate.Download(ctx, key, localPath)
}
func (s *captureObjectStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
return s.delegate.Upload(ctx, localPath, key, opts)
}
func (s *captureObjectStore) Exists(ctx context.Context, key string) (bool, error) {
s.existsKeys = append(s.existsKeys, key)
return s.delegate.Exists(ctx, key)
}
func restoreDiscoveryConfig() *config.Config {
return &config.Config{
Pipeline: &config.PipelineConfig{
Storage: config.StorageConfig{
S3: &config.StorageS3Config{
Bucket: "my-dnd-archive",
RootPrefix: "dnd",
},
},
},
Session: &config.SessionConfig{
SessionID: "2026-05-03",
Campaign: "sample-campaign",
},
}
}
func restoreDiscoveryKeys(cfg *config.Config) (sessionPrefix, manifestKey, runIDKey string) {
sessionPrefix = artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
manifestKey, runIDKey = artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
return sessionPrefix, manifestKey, runIDKey
}
func restoreManifestJSON(t *testing.T, sessionID, campaign string) []byte {
t.Helper()
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
payload := map[string]any{
"session_id": sessionID,
"campaign": campaign,
"created_at": now,
"updated_at": now,
"stages": map[string]any{},
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal manifest payload: %v", err)
}
return append(data, '\n')
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -0,0 +1,180 @@
package app
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// RestoreExecutionResult captures concrete file-install results for one restore execution.
type RestoreExecutionResult struct {
DownloadedCount int
}
func executeRestorePlan(
ctx context.Context,
cfg *config.Config,
current *RemoteCurrentState,
plan *RestorePlan,
report *RestoreReport,
store storage.ObjectStore,
) (*RestoreExecutionResult, error) {
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
return nil, fmt.Errorf("resolved config with pipeline/session is required")
}
if current == nil {
return nil, fmt.Errorf("remote current state is required")
}
if plan == nil {
return nil, fmt.Errorf("restore plan is required")
}
if store == nil {
return nil, fmt.Errorf("remote object store is required")
}
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
manifestActions := make([]RestoreAction, 0, 1)
actions := make([]RestoreAction, 0, len(plan.Actions))
for _, action := range plan.Actions {
if action.Kind != RestoreActionDownload {
continue
}
if action.LocalRelativePath == config.PathManifestFile {
manifestActions = append(manifestActions, action)
continue
}
actions = append(actions, action)
}
if len(manifestActions) > 1 {
return nil, fmt.Errorf("restore plan includes multiple manifest download actions")
}
if len(manifestActions) == 1 {
actions = append(actions, manifestActions[0])
}
result := &RestoreExecutionResult{}
for _, action := range actions {
if err := executeRestoreDownloadAction(ctx, cfg, sessionRoot, current, action, store); err != nil {
if report != nil {
report.markFailed(action, err)
}
return nil, fmt.Errorf("install %q from %q: %w", action.LocalRelativePath, action.RemoteKey, err)
}
if report != nil {
report.markDownloaded(action)
}
result.DownloadedCount++
}
return result, nil
}
func executeRestoreDownloadAction(
ctx context.Context,
cfg *config.Config,
sessionRoot string,
current *RemoteCurrentState,
action RestoreAction,
store storage.ObjectStore,
) error {
safeLocalPath, err := joinWithinSessionRoot(sessionRoot, action.LocalRelativePath)
if err != nil {
return fmt.Errorf("resolve safe local path: %w", err)
}
if strings.TrimSpace(action.LocalPath) != "" && filepath.Clean(action.LocalPath) != safeLocalPath {
return fmt.Errorf("restore plan local path mismatch for %q", action.LocalRelativePath)
}
tmpPath, err := downloadObjectToSiblingTemp(ctx, store, action.RemoteKey, safeLocalPath)
if err != nil {
return fmt.Errorf("download to temp file: %w", err)
}
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpPath)
}
}()
if action.LocalRelativePath == config.PathManifestFile {
if err := validateRestoredManifest(ctx, cfg, current, tmpPath); err != nil {
return err
}
}
if err := os.Chmod(tmpPath, 0o644); err != nil {
return fmt.Errorf("set file permissions: %w", err)
}
if err := os.Rename(tmpPath, safeLocalPath); err != nil {
return fmt.Errorf("install file atomically: %w", err)
}
removeTmp = false
return nil
}
func downloadObjectToSiblingTemp(ctx context.Context, store storage.ObjectStore, remoteKey, destPath string) (string, error) {
if strings.TrimSpace(destPath) == "" {
return "", fmt.Errorf("destination path is required")
}
dir := filepath.Dir(destPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
base := filepath.Base(destPath)
tmp, err := os.CreateTemp(dir, "."+base+".restore-*.tmp")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("close temp file: %w", err)
}
if err := store.Download(ctx, remoteKey, tmpPath); err != nil {
_ = os.Remove(tmpPath)
return "", err
}
return tmpPath, nil
}
func validateRestoredManifest(ctx context.Context, cfg *config.Config, current *RemoteCurrentState, path string) error {
manifestStore := &manifest.LocalStore{}
m, err := manifestStore.Load(ctx, path)
if err != nil {
return fmt.Errorf("validate manifest decode: %w", err)
}
requestedSession := strings.TrimSpace(cfg.Session.SessionID)
requestedCampaign := strings.TrimSpace(cfg.Session.Campaign)
manifestSession := strings.TrimSpace(m.SessionID)
manifestCampaign := strings.TrimSpace(m.Campaign)
if manifestSession != requestedSession {
return fmt.Errorf("manifest session_id %q does not match requested session_id %q", manifestSession, requestedSession)
}
if manifestCampaign == "" {
return fmt.Errorf("manifest campaign is required")
}
if manifestCampaign != requestedCampaign {
return fmt.Errorf("manifest campaign %q does not match requested campaign %q", manifestCampaign, requestedCampaign)
}
if current != nil {
if expected := strings.TrimSpace(current.SessionID); expected != "" && manifestSession != expected {
return fmt.Errorf("manifest session_id %q does not match discovered session_id %q", manifestSession, expected)
}
if expected := strings.TrimSpace(current.Campaign); expected != "" && manifestCampaign != expected {
return fmt.Errorf("manifest campaign %q does not match discovered campaign %q", manifestCampaign, expected)
}
}
return nil
}

View File

@@ -0,0 +1,413 @@
package app
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestExecuteRestoreNonDryRunRestoresDurableFiles(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`))
seedRestoreObject(fake, sessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio"))
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
seedRestoreObject(fake, manifestKey, restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
restoreWithStoreAndRealPhases(t, fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
if !strings.Contains(stdout.String(), "Restored session archive for sample-campaign/2026-05-03") {
t.Fatalf("stdout = %q, want completion summary", stdout.String())
}
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1,2,3]}`)
mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
reportPath := filepath.Join(sessionRoot, "reports", "restore-latest.json")
report := mustReadRestoreReport(t, reportPath)
if report.Status != "succeeded" {
t.Fatalf("report status = %q, want succeeded", report.Status)
}
if report.Execution.Downloaded != 3 {
t.Fatalf("report execution.downloaded = %d, want 3", report.Execution.Downloaded)
}
if len(report.Actions) == 0 {
t.Fatal("report actions is empty")
}
if _, err := os.Stat(filepath.Join(sessionRoot, "audio", "alice.flac")); !os.IsNotExist(err) {
t.Fatalf("audio should not be restored by default; stat err=%v", err)
}
}
func TestExecuteRestoreIncludeAudioRestoresAudio(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"audio/alice.flac", []byte("remote-audio"))
restoreWithStoreAndRealPhases(t, fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--include-audio"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustReadEquals(t, filepath.Join(sessionRoot, "audio", "alice.flac"), "remote-audio")
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
if !report.IncludeAudio {
t.Fatalf("report include_audio = %v, want true", report.IncludeAudio)
}
}
func TestExecuteRestoreRestoresPreviousCacheWhenPresent(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`))
seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
restoreWithStoreAndRealPhases(t, fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "manifest.json"), `{"session_id":"2026-04-26"}`)
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
if report.Execution.Downloaded != 3 {
t.Fatalf("report execution.downloaded = %d, want 3", report.Execution.Downloaded)
}
}
func TestExecuteRestoreConflictWithoutForceDoesNotOverwrite(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
restoreWithStoreAndRealPhases(t, fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "conflicting path") {
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
}
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
if report.Status != "failed" {
t.Fatalf("report status = %q, want failed", report.Status)
}
if report.Plan.Conflicts != 1 {
t.Fatalf("report plan.conflicts = %d, want 1", report.Plan.Conflicts)
}
}
func TestExecuteRestoreForceOverwritesDifferingFile(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "local-transcript")
restoreWithStoreAndRealPhases(t, fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript")
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
if !report.Force {
t.Fatalf("report force = %v, want true", report.Force)
}
}
func TestExecuteRestoreForceOverwritesDifferingPreviousCacheFile(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# remote previous recap\n"))
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# local previous recap\n")
restoreWithStoreAndRealPhases(t, fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# remote previous recap\n")
}
func TestExecuteRestoreLockConflictFailsAndWritesNothing(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
cfg, sessionPrefix, _, _ := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath)
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
store := artifacts.NewLocalStore(workspaceRoot)
lock, err := store.AcquireSessionLockFor(cfg.Session.Campaign, cfg.Session.SessionID)
if err != nil {
t.Fatalf("AcquireSessionLockFor() error = %v", err)
}
defer func() { _ = store.ReleaseSessionLock(lock) }()
restoreWithStoreAndRealPhases(t, fake)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "acquire session lock") {
t.Fatalf("stderr = %q, want lock failure", stderr.String())
}
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
if _, err := os.Stat(filepath.Join(sessionRoot, "transcripts", "full.json")); !os.IsNotExist(err) {
t.Fatalf("transcript should not be restored when lock acquisition fails; stat err=%v", err)
}
}
func TestExecuteRestoreInvalidManifestDoesNotCorruptExistingManifest(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
base := &storage.FakeBackend{}
cfg, sessionPrefix, manifestKey, _ := seedRestoreCommittedState(t, base, pipelinePath, sessionPath)
seedRestoreObject(base, sessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
toggled := &stagedManifestDownloadStore{
delegate: base,
manifestKey: manifestKey,
firstManifest: restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign),
secondManifest: []byte("{invalid json"),
manifestReads: 0,
}
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
existing := manifest.New(cfg.Session.SessionID, nowUTC())
existing.Campaign = cfg.Session.Campaign
existingPath := filepath.Join(sessionRoot, "manifest.json")
manifestStore := &manifest.LocalStore{}
if err := manifestStore.Save(context.Background(), existingPath, existing); err != nil {
t.Fatalf("save existing local manifest: %v", err)
}
existingData, err := os.ReadFile(existingPath)
if err != nil {
t.Fatalf("read existing local manifest: %v", err)
}
restoreWithStoreAndRealPhases(t, toggled)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "validate manifest decode") {
t.Fatalf("stderr = %q, want manifest validation failure", stderr.String())
}
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), "remote-transcript")
report := mustReadRestoreReport(t, filepath.Join(sessionRoot, "reports", "restore-latest.json"))
if report.Status != "failed" {
t.Fatalf("report status = %q, want failed", report.Status)
}
if strings.TrimSpace(report.Error) == "" {
t.Fatal("report error is empty, want failure context")
}
afterData, err := os.ReadFile(existingPath)
if err != nil {
t.Fatalf("read local manifest after failure: %v", err)
}
if string(afterData) != string(existingData) {
t.Fatalf("local manifest changed after failed restore; before=%q after=%q", string(existingData), string(afterData))
}
}
func TestExecuteRestorePlanPathMismatchFails(t *testing.T) {
cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte("remote-transcript"))
plan := &RestorePlan{Actions: []RestoreAction{{
Kind: RestoreActionDownload,
RemoteKey: current.SessionPrefix + "transcripts/full.json",
LocalRelativePath: "transcripts/full.json",
LocalPath: "/tmp/escape.txt",
}}}
report, err := newRestoreReport(current, plan, RestorePlanOptions{})
if err != nil {
t.Fatalf("newRestoreReport() error = %v", err)
}
_, err = executeRestorePlan(context.Background(), cfg, current, plan, report, store)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "local path mismatch") {
t.Fatalf("error = %v, want local path mismatch", err)
}
}
func mustReadRestoreReport(t *testing.T, path string) *RestoreReport {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%q): %v", path, err)
}
var report RestoreReport
if err := json.Unmarshal(data, &report); err != nil {
t.Fatalf("Unmarshal restore report %q: %v", path, err)
}
return &report
}
func restoreWithStoreAndRealPhases(t *testing.T, objectStore storage.ObjectStore) {
t.Helper()
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
return objectStore, nil
}
discoverRemoteCurrentStateFn = discoverRemoteCurrentState
buildRestorePlanFn = buildRestorePlan
executeRestorePlanFn = executeRestorePlan
}
func seedRestoreCommittedState(t *testing.T, fake *storage.FakeBackend, pipelinePath, sessionPath string) (*config.Config, string, string, string) {
t.Helper()
cfg, err := config.LoadWithSessionOptions(pipelinePath, sessionPath, config.SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
if err := config.Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
seedRestoreObject(fake, manifestKey, restoreManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
return cfg, sessionPrefix, manifestKey, runIDKey
}
func mustReadEquals(t *testing.T, path, want string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%q): %v", path, err)
}
if string(data) != want {
t.Fatalf("file %q = %q, want %q", path, string(data), want)
}
}
type stagedManifestDownloadStore struct {
delegate *storage.FakeBackend
manifestKey string
firstManifest []byte
secondManifest []byte
manifestReads int
}
func (s *stagedManifestDownloadStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
return s.delegate.List(ctx, prefix)
}
func (s *stagedManifestDownloadStore) Download(ctx context.Context, key, localPath string) error {
if strings.TrimSpace(key) == strings.TrimSpace(s.manifestKey) {
s.manifestReads++
payload := s.secondManifest
if s.manifestReads <= 1 {
payload = s.firstManifest
}
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
return fmt.Errorf("download staged manifest: create parent: %w", err)
}
if err := os.WriteFile(localPath, payload, 0o644); err != nil {
return fmt.Errorf("download staged manifest: write local file: %w", err)
}
return nil
}
return s.delegate.Download(ctx, key, localPath)
}
func (s *stagedManifestDownloadStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
return s.delegate.Upload(ctx, localPath, key, opts)
}
func (s *stagedManifestDownloadStore) Exists(ctx context.Context, key string) (bool, error) {
return s.delegate.Exists(ctx, key)
}

View File

@@ -0,0 +1,347 @@
package app
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
// RestoreActionKind identifies one restore planner action.
type RestoreActionKind string
const (
RestoreActionDownload RestoreActionKind = "download"
RestoreActionSkipSame RestoreActionKind = "skip_same"
RestoreActionConflict RestoreActionKind = "conflict"
)
// RestoreAction is one deterministic planner action.
type RestoreAction struct {
Kind RestoreActionKind
RemoteKey string
LocalRelativePath string
LocalPath string
Size int64
ETag string
ExistsLocal bool
SameLocal bool
Conflict bool
Reason string
}
// RestorePlan is the deterministic output of restore planning.
type RestorePlan struct {
Actions []RestoreAction
DownloadCount int
SkipSameCount int
ConflictCount int
}
// RestorePlanOptions control restore planning scope and classification.
type RestorePlanOptions struct {
IncludeAudio bool
Force bool
DryRun bool
}
func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCurrentState, store storage.ObjectStore, opts RestorePlanOptions) (*RestorePlan, error) {
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
return nil, fmt.Errorf("resolved config with pipeline/session is required")
}
if current == nil {
return nil, fmt.Errorf("remote current state is required")
}
if store == nil {
return nil, fmt.Errorf("remote object store is required")
}
prefix := normalizeRemoteKey(current.SessionPrefix)
if strings.TrimSpace(prefix) == "" {
return nil, fmt.Errorf("remote session prefix is required")
}
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
sessionPaths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
objects, err := store.List(ctx, prefix)
if err != nil {
return nil, fmt.Errorf("list remote session objects under %q: %w", prefix, err)
}
candidates := make(map[string]storage.ObjectInfo, len(objects)+1)
for _, obj := range objects {
key := normalizeRemoteKey(obj.Key)
if key == "" {
continue
}
obj.Key = key
candidates[key] = obj
}
if strings.TrimSpace(current.CurrentManifestKey) != "" {
key := normalizeRemoteKey(current.CurrentManifestKey)
if _, ok := candidates[key]; !ok {
candidates[key] = storage.ObjectInfo{Key: key}
}
}
actions := make([]RestoreAction, 0, len(candidates))
for key, obj := range candidates {
rel, include, err := restoreLocalRelativePathForKey(prefix, normalizeRemoteKey(current.CurrentManifestKey), key, opts.IncludeAudio)
if err != nil {
return nil, fmt.Errorf("map remote key %q: %w", key, err)
}
if !include {
continue
}
localPath, err := joinWithinSessionRoot(sessionPaths.Root, rel)
if err != nil {
return nil, fmt.Errorf("map remote key %q: %w", key, err)
}
action, err := classifyRestoreAction(ctx, store, obj, rel, localPath, opts.Force)
if err != nil {
return nil, fmt.Errorf("classify remote key %q: %w", key, err)
}
actions = append(actions, action)
}
sort.Slice(actions, func(i, j int) bool {
if actions[i].LocalRelativePath == actions[j].LocalRelativePath {
return actions[i].RemoteKey < actions[j].RemoteKey
}
return actions[i].LocalRelativePath < actions[j].LocalRelativePath
})
plan := &RestorePlan{Actions: actions}
for _, action := range actions {
switch action.Kind {
case RestoreActionDownload:
plan.DownloadCount++
case RestoreActionSkipSame:
plan.SkipSameCount++
case RestoreActionConflict:
plan.ConflictCount++
}
}
_ = opts.DryRun
return plan, nil
}
func normalizeRemoteKey(v string) string {
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(v), "\\", "/"), "/")
}
func restoreLocalRelativePathForKey(sessionPrefix, currentManifestKey, key string, includeAudio bool) (string, bool, error) {
if key == "" {
return "", false, nil
}
if key == currentManifestKey {
return config.PathManifestFile, true, nil
}
if !strings.HasPrefix(key, sessionPrefix) {
return "", false, fmt.Errorf("key is outside resolved session prefix %q", sessionPrefix)
}
rel := strings.TrimPrefix(key, sessionPrefix)
rel = strings.TrimSpace(rel)
if rel == "" {
return "", false, nil
}
cleanRel := path.Clean(rel)
if cleanRel == "." || cleanRel == "" {
return "", false, nil
}
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") || strings.HasPrefix(cleanRel, "/") {
return "", false, fmt.Errorf("key relative path %q escapes session scope", rel)
}
if cleanRel == config.PathManifestFile {
return config.PathManifestFile, true, nil
}
if strings.HasPrefix(cleanRel, config.S3CurrentSegment+"/") {
return "", false, nil
}
if strings.HasPrefix(cleanRel, config.S3RunsSegment+"/") {
return "", false, nil
}
excludedRoots := []string{
config.PathLogsDirSegment,
config.PathReportsDirSegment,
config.PathConfigDirSegment,
config.PathInputsDirSegment,
}
for _, root := range excludedRoots {
if cleanRel == root || strings.HasPrefix(cleanRel, root+"/") {
return "", false, nil
}
}
if cleanRel == config.PathTranscriptsSegment || strings.HasPrefix(cleanRel, config.PathTranscriptsSegment+"/") {
return cleanRel, true, nil
}
if cleanRel == config.PathArtifactsDirSegment || strings.HasPrefix(cleanRel, config.PathArtifactsDirSegment+"/") {
return cleanRel, true, nil
}
if cleanRel == config.PathPreviousDirSegment || strings.HasPrefix(cleanRel, config.PathPreviousDirSegment+"/") {
return cleanRel, true, nil
}
if includeAudio && (cleanRel == config.PathAudioDirSegment || strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/")) {
return cleanRel, true, nil
}
return "", false, nil
}
func joinWithinSessionRoot(sessionRoot, relative string) (string, error) {
if strings.TrimSpace(sessionRoot) == "" {
return "", fmt.Errorf("session root is required")
}
cleanRel := path.Clean(strings.TrimSpace(relative))
if cleanRel == "." || cleanRel == "" {
return "", fmt.Errorf("relative path is required")
}
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") || strings.HasPrefix(cleanRel, "/") {
return "", fmt.Errorf("relative path escapes session root")
}
abs := filepath.Clean(filepath.Join(sessionRoot, filepath.FromSlash(cleanRel)))
root := filepath.Clean(sessionRoot)
if abs != root && !strings.HasPrefix(abs, root+string(filepath.Separator)) {
return "", fmt.Errorf("resolved local path escapes session root")
}
return abs, nil
}
func classifyRestoreAction(
ctx context.Context,
store storage.ObjectStore,
object storage.ObjectInfo,
localRelPath string,
localPath string,
force bool,
) (RestoreAction, error) {
action := RestoreAction{
RemoteKey: normalizeRemoteKey(object.Key),
LocalRelativePath: localRelPath,
LocalPath: localPath,
Size: object.Size,
ETag: object.ETag,
}
info, err := os.Stat(localPath)
if err != nil {
if os.IsNotExist(err) {
action.Kind = RestoreActionDownload
action.Reason = "local file missing"
return action, nil
}
return RestoreAction{}, fmt.Errorf("stat local file: %w", err)
}
action.ExistsLocal = true
if info.IsDir() {
action.Kind = RestoreActionConflict
action.Conflict = true
action.Reason = "local path is a directory"
return action, nil
}
if object.Size > 0 && info.Size() != object.Size {
if force {
action.Kind = RestoreActionDownload
action.Reason = "local file differs (size mismatch); overwrite with --force"
return action, nil
}
action.Kind = RestoreActionConflict
action.Conflict = true
action.Reason = "local file differs (size mismatch)"
return action, nil
}
localDigest, err := artifacts.SHA256File(localPath)
if err != nil {
return RestoreAction{}, fmt.Errorf("checksum local file: %w", err)
}
remotePath, err := downloadObjectToTemp(ctx, store, action.RemoteKey, "narratio-restore-plan-remote-*.tmp")
if err != nil {
return RestoreAction{}, fmt.Errorf("download remote object: %w", err)
}
defer func() { _ = os.Remove(remotePath) }()
remoteDigest, err := artifacts.SHA256File(remotePath)
if err != nil {
return RestoreAction{}, fmt.Errorf("checksum remote object: %w", err)
}
if remoteDigest == localDigest {
action.Kind = RestoreActionSkipSame
action.SameLocal = true
action.Reason = "local file matches remote content"
return action, nil
}
if force {
action.Kind = RestoreActionDownload
action.Reason = "local file differs; overwrite with --force"
return action, nil
}
action.Kind = RestoreActionConflict
action.Conflict = true
action.Reason = "local file differs"
return action, nil
}
func writeRestorePlan(out io.Writer, current *RemoteCurrentState, plan *RestorePlan, opts RestorePlanOptions) error {
if out == nil {
return fmt.Errorf("output writer is required")
}
if current == nil {
return fmt.Errorf("remote current state is required")
}
if plan == nil {
return fmt.Errorf("restore plan is required")
}
if _, err := fmt.Fprintf(
out,
"restore plan: session %s/%s run=%s actions=%d download=%d skip_same=%d conflict=%d dry_run=%t force=%t include_audio=%t\n",
current.Campaign,
current.SessionID,
current.RunID,
len(plan.Actions),
plan.DownloadCount,
plan.SkipSameCount,
plan.ConflictCount,
opts.DryRun,
opts.Force,
opts.IncludeAudio,
); err != nil {
return err
}
for _, action := range plan.Actions {
if _, err := fmt.Fprintf(out, "%s %s <- %s", action.Kind, action.LocalRelativePath, action.RemoteKey); err != nil {
return err
}
if strings.TrimSpace(action.Reason) != "" {
if _, err := fmt.Fprintf(out, " (%s)", action.Reason); err != nil {
return err
}
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,220 @@
package app
import (
"context"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestRestorePlanDefaultScope(t *testing.T) {
cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("# recap\n"))
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
seedRestoreObject(store, current.SessionPrefix+"runs/20260519T010203Z-a1b2/manifest.json", []byte("{}"))
seedRestoreObject(store, current.SessionPrefix+"logs/archive.log", []byte("log"))
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
if err != nil {
t.Fatalf("buildRestorePlan() error = %v", err)
}
got := actionRelPaths(plan.Actions)
want := []string{"artifacts/session_recap.md", "manifest.json", "transcripts/full.json"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("action local paths = %#v, want %#v", got, want)
}
if plan.DownloadCount != 3 || plan.SkipSameCount != 0 || plan.ConflictCount != 0 {
t.Fatalf("counts = download=%d skip_same=%d conflict=%d, want 3/0/0", plan.DownloadCount, plan.SkipSameCount, plan.ConflictCount)
}
}
func TestRestorePlanIncludeAudio(t *testing.T) {
cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"audio/alice.flac", []byte("audio"))
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{IncludeAudio: true})
if err != nil {
t.Fatalf("buildRestorePlan() error = %v", err)
}
got := actionRelPaths(plan.Actions)
want := []string{"audio/alice.flac", "manifest.json"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("action local paths = %#v, want %#v", got, want)
}
}
func TestRestorePlanIncludesPreviousCacheByDefault(t *testing.T) {
cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`))
seedRestoreObject(store, current.SessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
if err != nil {
t.Fatalf("buildRestorePlan() error = %v", err)
}
got := actionRelPaths(plan.Actions)
want := []string{"manifest.json", "previous/artifacts/session_recap.md", "previous/manifest.json"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("action local paths = %#v, want %#v", got, want)
}
}
func TestRestorePlanClassifiesSameAndConflict(t *testing.T) {
cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"transcripts/full.json", []byte(`{"segments":[1]}`))
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1]}`)
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
if err != nil {
t.Fatalf("buildRestorePlan() error = %v", err)
}
if plan.SkipSameCount != 1 {
t.Fatalf("SkipSameCount = %d, want 1", plan.SkipSameCount)
}
if plan.ConflictCount != 1 {
t.Fatalf("ConflictCount = %d, want 1", plan.ConflictCount)
}
actionByRel := map[string]RestoreAction{}
for _, action := range plan.Actions {
actionByRel[action.LocalRelativePath] = action
}
if actionByRel["transcripts/full.json"].Kind != RestoreActionSkipSame {
t.Fatalf("transcripts/full.json kind = %q, want %q", actionByRel["transcripts/full.json"].Kind, RestoreActionSkipSame)
}
if actionByRel["artifacts/session_recap.md"].Kind != RestoreActionConflict {
t.Fatalf("artifacts/session_recap.md kind = %q, want %q", actionByRel["artifacts/session_recap.md"].Kind, RestoreActionConflict)
}
}
func TestRestorePlanForceTurnsConflictsIntoDownloads(t *testing.T) {
cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"artifacts/session_recap.md", []byte("remote-content\n"))
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
mustWriteTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "different\n")
plan, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{Force: true})
if err != nil {
t.Fatalf("buildRestorePlan() error = %v", err)
}
actionByRel := map[string]RestoreAction{}
for _, action := range plan.Actions {
actionByRel[action.LocalRelativePath] = action
}
recap := actionByRel["artifacts/session_recap.md"]
if recap.Kind != RestoreActionDownload {
t.Fatalf("artifacts/session_recap.md kind = %q, want %q", recap.Kind, RestoreActionDownload)
}
if plan.ConflictCount != 0 {
t.Fatalf("ConflictCount = %d, want 0", plan.ConflictCount)
}
}
func TestRestorePlanTraversalUnsafeKeyFails(t *testing.T) {
cfg := restorePlanConfig(t)
current := restorePlanCurrentState(t, cfg)
store := &storage.FakeBackend{}
seedRestoreObject(store, current.CurrentManifestKey, []byte(`{"session_id":"2026-05-03"}`))
seedRestoreObject(store, current.SessionPrefix+"artifacts/../../escape.txt", []byte("bad"))
_, err := buildRestorePlan(context.Background(), cfg, current, store, RestorePlanOptions{})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "escapes session scope") {
t.Fatalf("error = %v, want traversal safety failure", err)
}
}
func seedRestoreObject(store *storage.FakeBackend, key string, data []byte) {
store.SeedObject(storage.FakeObject{Key: key, Data: data})
}
func actionRelPaths(actions []RestoreAction) []string {
out := make([]string, 0, len(actions))
for _, action := range actions {
out = append(out, action.LocalRelativePath)
}
return out
}
func restorePlanConfig(t *testing.T) *config.Config {
t.Helper()
workspaceRoot := t.TempDir()
return &config.Config{
Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: workspaceRoot},
},
Session: &config.SessionConfig{
SessionID: "2026-05-03",
Campaign: "sample-campaign",
},
}
}
func restorePlanCurrentState(t *testing.T, cfg *config.Config) *RemoteCurrentState {
t.Helper()
sessionPrefix := artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
return &RemoteCurrentState{
Bucket: "test-bucket",
SessionPrefix: sessionPrefix,
CurrentManifestKey: manifestKey,
CurrentRunIDKey: runIDKey,
RunID: "20260519T010203Z-a1b2c3d4",
SessionID: cfg.Session.SessionID,
Campaign: cfg.Session.Campaign,
}
}
func TestWriteRestorePlan(t *testing.T) {
current := &RemoteCurrentState{Campaign: "sample-campaign", SessionID: "2026-05-03", RunID: "r-1"}
plan := &RestorePlan{Actions: []RestoreAction{{Kind: RestoreActionDownload, LocalRelativePath: "manifest.json", RemoteKey: "k", Reason: "local file missing"}}, DownloadCount: 1}
var out strings.Builder
if err := writeRestorePlan(&out, current, plan, RestorePlanOptions{DryRun: true}); err != nil {
t.Fatalf("writeRestorePlan() error = %v", err)
}
text := out.String()
if !strings.Contains(text, "restore plan: session sample-campaign/2026-05-03 run=r-1") {
t.Fatalf("output = %q, want plan summary", text)
}
if !strings.Contains(text, "download manifest.json <- k") {
t.Fatalf("output = %q, want action line", text)
}
}

View File

@@ -0,0 +1,243 @@
package app
import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
// RestoreReport is the durable restore diagnostic model.
type RestoreReport struct {
GeneratedAt string `json:"generated_at"`
SessionID string `json:"session_id"`
Campaign string `json:"campaign"`
RunID string `json:"run_id"`
DryRun bool `json:"dry_run"`
Force bool `json:"force"`
IncludeAudio bool `json:"include_audio"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Plan RestorePlanSummary `json:"plan"`
Execution RestoreExecutionStats `json:"execution"`
Actions []RestoreReportAction `json:"actions"`
reportPathRel string
}
type RestorePlanSummary struct {
Actions int `json:"actions"`
Download int `json:"download"`
SkipSame int `json:"skip_same"`
Conflicts int `json:"conflicts"`
}
type RestoreExecutionStats struct {
Downloaded int `json:"downloaded"`
Failed int `json:"failed"`
}
type RestoreReportAction struct {
Kind string `json:"kind"`
LocalRelativePath string `json:"local_relative_path"`
RemoteKey string `json:"remote_key"`
Reason string `json:"reason,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
func newRestoreReport(current *RemoteCurrentState, plan *RestorePlan, opts RestorePlanOptions) (*RestoreReport, error) {
if current == nil {
return nil, fmt.Errorf("remote current state is required")
}
if plan == nil {
return nil, fmt.Errorf("restore plan is required")
}
r := &RestoreReport{
GeneratedAt: nowUTC().Format("2006-01-02T15:04:05.999999999Z07:00"),
SessionID: current.SessionID,
Campaign: current.Campaign,
RunID: current.RunID,
DryRun: opts.DryRun,
Force: opts.Force,
IncludeAudio: opts.IncludeAudio,
Status: "planned",
Plan: RestorePlanSummary{
Actions: len(plan.Actions),
Download: plan.DownloadCount,
SkipSame: plan.SkipSameCount,
Conflicts: plan.ConflictCount,
},
Actions: make([]RestoreReportAction, 0, len(plan.Actions)),
reportPathRel: filepath.ToSlash(filepath.Join(config.PathReportsDirSegment, "restore-latest.json")),
}
for _, action := range plan.Actions {
r.Actions = append(r.Actions, RestoreReportAction{
Kind: string(action.Kind),
LocalRelativePath: action.LocalRelativePath,
RemoteKey: action.RemoteKey,
Reason: action.Reason,
Status: initialRestoreActionStatus(action.Kind),
})
}
return r, nil
}
func initialRestoreActionStatus(kind RestoreActionKind) string {
switch kind {
case RestoreActionDownload:
return "planned_download"
case RestoreActionSkipSame:
return "skipped_same"
case RestoreActionConflict:
return "conflict"
default:
return "planned"
}
}
func (r *RestoreReport) markDownloaded(action RestoreAction) {
if r == nil {
return
}
if idx := r.findAction(action); idx >= 0 {
r.Actions[idx].Status = "downloaded"
r.Actions[idx].Error = ""
}
r.Execution.Downloaded++
}
func (r *RestoreReport) markFailed(action RestoreAction, err error) {
if r == nil {
return
}
if idx := r.findAction(action); idx >= 0 {
r.Actions[idx].Status = "failed"
if err != nil {
r.Actions[idx].Error = err.Error()
}
}
r.Execution.Failed++
}
func (r *RestoreReport) setFailed(err error) {
if r == nil {
return
}
r.Status = "failed"
if err != nil {
r.Error = err.Error()
}
}
func (r *RestoreReport) setSucceeded() {
if r == nil {
return
}
r.Status = "succeeded"
r.Error = ""
}
func (r *RestoreReport) findAction(action RestoreAction) int {
if r == nil {
return -1
}
for i := range r.Actions {
if r.Actions[i].LocalRelativePath == action.LocalRelativePath && r.Actions[i].RemoteKey == action.RemoteKey {
return i
}
}
return -1
}
func writeRestoreDryRunSummary(out io.Writer, report *RestoreReport) error {
if out == nil {
return fmt.Errorf("output writer is required")
}
if report == nil {
return fmt.Errorf("restore report is required")
}
if _, err := fmt.Fprintf(out, "Restore plan for %s/%s\n", report.Campaign, report.SessionID); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "Remote run: %s\n", report.RunID); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "Would download: %d\n", report.Plan.Download); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "Would skip unchanged: %d\n", report.Plan.SkipSame); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "Conflicts: %d\n", report.Plan.Conflicts); err != nil {
return err
}
for _, action := range report.Actions {
line := ""
switch action.Status {
case "planned_download":
line = "Would download: " + action.LocalRelativePath
case "skipped_same":
line = "Would skip unchanged: " + action.LocalRelativePath
case "conflict":
line = "Conflict: " + action.LocalRelativePath
default:
line = strings.TrimSpace(action.Kind) + ": " + action.LocalRelativePath
}
if _, err := fmt.Fprintln(out, line); err != nil {
return err
}
}
return nil
}
func writeRestoreSuccessSummary(out io.Writer, report *RestoreReport) error {
if out == nil {
return fmt.Errorf("output writer is required")
}
if report == nil {
return fmt.Errorf("restore report is required")
}
if _, err := fmt.Fprintf(out, "Restored session archive for %s/%s\n", report.Campaign, report.SessionID); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "Remote run: %s\n", report.RunID); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "Downloaded: %d\n", report.Execution.Downloaded); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "Skipped unchanged: %d\n", report.Plan.SkipSame); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "Conflicts: %d\n", report.Plan.Conflicts); err != nil {
return err
}
return nil
}
func persistRestoreReport(store artifacts.Store, cfg *config.Config, report *RestoreReport) (string, error) {
if store == nil {
return "", fmt.Errorf("artifact store is required")
}
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
return "", fmt.Errorf("resolved config with pipeline/session is required")
}
if report == nil {
return "", fmt.Errorf("restore report is required")
}
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
reportPath := filepath.Join(sessionRoot, filepath.FromSlash(report.reportPathRel))
payload, err := json.MarshalIndent(report, "", " ")
if err != nil {
return "", fmt.Errorf("marshal restore report: %w", err)
}
payload = append(payload, '\n')
if err := store.WriteFileAtomic(reportPath, payload, 0o644); err != nil {
return "", fmt.Errorf("write restore report %q: %w", reportPath, err)
}
return reportPath, nil
}

View File

@@ -0,0 +1,409 @@
package app
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestExecuteRestoreHelp(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--help"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0", code)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
out := stdout.String()
if !strings.Contains(out, "Usage: narratio restore") {
t.Fatalf("stdout = %q, want restore usage", out)
}
if !strings.Contains(out, "--include-audio") {
t.Fatalf("stdout = %q, want --include-audio flag", out)
}
}
func TestExecuteRestoreRecognizedAndReturnsNYI(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
return &RemoteCurrentState{
SessionID: "2026-05-03",
Campaign: "sample-campaign",
RunID: "20260519T010203Z-a1b2c3d4",
}, nil
}
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
return &RestorePlan{
Actions: []RestoreAction{
{
Kind: RestoreActionDownload,
LocalRelativePath: "manifest.json",
RemoteKey: "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json",
Reason: "local file missing",
},
},
DownloadCount: 1,
}, nil
}
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{
"restore",
"--config", pipelinePath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--dry-run",
"--force",
"--include-audio",
},
&stdout,
&stderr,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0 for --dry-run restore planning; stderr=%q", code, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
outText := stdout.String()
if !strings.Contains(outText, "Restore plan for sample-campaign/2026-05-03") {
t.Fatalf("stdout = %q, want restore plan summary", outText)
}
if !strings.Contains(outText, "Would download: 1") {
t.Fatalf("stdout = %q, want plan count output", outText)
}
if !strings.Contains(outText, "Would download: manifest.json") {
t.Fatalf("stdout = %q, want action output", outText)
}
manifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
if _, err := os.Stat(manifestPath); !os.IsNotExist(err) {
t.Fatalf("manifest should not be created during phase-4 restore planning; stat err=%v", err)
}
reportPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "reports", "restore-latest.json")
if _, err := os.Stat(reportPath); !os.IsNotExist(err) {
t.Fatalf("restore report should not be written during dry-run; stat err=%v", err)
}
}
func TestExecuteRestoreRejectsUnexpectedPositionalArguments(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "extra"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "restore: unexpected positional arguments") {
t.Fatalf("stderr = %q, want positional-args failure", stderr.String())
}
}
func TestExecuteRestoreFailsWhenStorageBackendNotConfigured(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
})
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeRestoreConfigWithoutStorage(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "no remote object store backend is configured") {
t.Fatalf("stderr = %q, want storage backend preflight failure", stderr.String())
}
}
func TestExecuteRestoreDiscoveryErrorSurfaced(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
return nil, fmt.Errorf("remote current run pointer missing: %q", "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/run_id.txt")
}
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "remote current run pointer missing") {
t.Fatalf("stderr = %q, want discovery error context", stderr.String())
}
}
func TestExecuteRestoreLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
const accessKeyEnv = "OBJECT_STORAGE_KEY_ID"
const secretKeyEnv = "OBJECT_STORAGE_KEY"
restoreEnv := func(name string) {
value, exists := os.LookupEnv(name)
_ = os.Unsetenv(name)
t.Cleanup(func() {
if exists {
_ = os.Setenv(name, value)
return
}
_ = os.Unsetenv(name)
})
}
restoreEnv(accessKeyEnv)
restoreEnv(secretKeyEnv)
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
secretsDir := filepath.Join(t.TempDir(), "secrets")
mustWriteTestFile(t, filepath.Join(secretsDir, accessKeyEnv), "test-access-key-id\n")
mustWriteTestFile(t, filepath.Join(secretsDir, secretKeyEnv), "test-secret-key\n")
f, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatalf("open pipeline config for append: %v", err)
}
defer f.Close()
if _, err := f.WriteString("\nsecrets:\n env_dir: " + secretsDir + "\n"); err != nil {
t.Fatalf("append secrets config: %v", err)
}
storeInitCalled := false
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
storeInitCalled = true
gotID, okID := os.LookupEnv(accessKeyEnv)
if !okID || gotID != "test-access-key-id" {
return nil, fmt.Errorf("missing or unexpected %s: %q (set=%t)", accessKeyEnv, gotID, okID)
}
gotSecret, okSecret := os.LookupEnv(secretKeyEnv)
if !okSecret || gotSecret != "test-secret-key" {
return nil, fmt.Errorf("missing or unexpected %s: %q (set=%t)", secretKeyEnv, gotSecret, okSecret)
}
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
return &RemoteCurrentState{
SessionID: "2026-05-03",
Campaign: "sample-campaign",
RunID: "20260519T010203Z-a1b2c3d4",
}, nil
}
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
return &RestorePlan{
Actions: []RestoreAction{
{
Kind: RestoreActionDownload,
LocalRelativePath: "manifest.json",
RemoteKey: "dnd/campaigns/sample-campaign/sessions/2026-05-03/current/manifest.json",
Reason: "local file missing",
},
},
DownloadCount: 1,
}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute(
[]string{
"restore",
"--config", pipelinePath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--dry-run",
},
&stdout,
&stderr,
)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !storeInitCalled {
t.Fatal("expected object store initialization to be called")
}
}
func TestExecuteRestoreNonDryRunConflictFailsBeforeNYI(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
return &RemoteCurrentState{
SessionID: "2026-05-03",
Campaign: "sample-campaign",
RunID: "20260519T010203Z-a1b2c3d4",
}, nil
}
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
return &RestorePlan{
Actions: []RestoreAction{
{Kind: RestoreActionConflict, LocalRelativePath: "transcripts/full.json", RemoteKey: "k", Reason: "local file differs"},
},
ConflictCount: 1,
}, nil
}
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty on conflict failure", stdout.String())
}
if !strings.Contains(stderr.String(), "restore conflict: 1 conflicting path(s); rerun with --force to overwrite") {
t.Fatalf("stderr = %q, want conflict failure", stderr.String())
}
if strings.Contains(stderr.String(), "phase 4: restore execution") {
t.Fatalf("stderr = %q, should fail before phase-4 NYI boundary", stderr.String())
}
}
func TestExecuteRestoreNonDryRunForceExecutesPlan(t *testing.T) {
origStoreFn := newObjectStoreFromConfigFn
origDiscoverFn := discoverRemoteCurrentStateFn
origPlanFn := buildRestorePlanFn
origExecuteFn := executeRestorePlanFn
t.Cleanup(func() {
newObjectStoreFromConfigFn = origStoreFn
discoverRemoteCurrentStateFn = origDiscoverFn
buildRestorePlanFn = origPlanFn
executeRestorePlanFn = origExecuteFn
})
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
return &storage.FakeBackend{}, nil
}
discoverRemoteCurrentStateFn = func(context.Context, *config.Config, storage.ObjectStore) (*RemoteCurrentState, error) {
return &RemoteCurrentState{
SessionID: "2026-05-03",
Campaign: "sample-campaign",
RunID: "20260519T010203Z-a1b2c3d4",
}, nil
}
buildRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, storage.ObjectStore, RestorePlanOptions) (*RestorePlan, error) {
return &RestorePlan{
Actions: []RestoreAction{
{Kind: RestoreActionDownload, LocalRelativePath: "transcripts/full.json", RemoteKey: "k", Reason: "local file differs; overwrite with --force"},
},
DownloadCount: 1,
}, nil
}
executeRestorePlanFn = func(context.Context, *config.Config, *RemoteCurrentState, *RestorePlan, *RestoreReport, storage.ObjectStore) (*RestoreExecutionResult, error) {
return &RestoreExecutionResult{DownloadedCount: 1}, nil
}
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"restore", "--config", pipelinePath, "--session", sessionPath, "--force"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
if !strings.Contains(stdout.String(), "Restored session archive for sample-campaign/2026-05-03") {
t.Fatalf("stdout = %q, want completion summary", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func writeRestoreConfigWithoutStorage(t *testing.T, workspaceRoot string) (string, string) {
t.Helper()
dir := t.TempDir()
pipelinePath := filepath.Join(dir, "pipeline.yml")
sessionPath := filepath.Join(dir, "session.yml")
pipelineYAML := `workspace:
root: ` + workspaceRoot + `
whisperx:
transcribe_url: https://example.com/transcribe
`
sessionYAML := `session_id: 2026-05-03
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
t.Fatalf("write pipeline config: %v", err)
}
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session config: %v", err)
}
mustWriteTestFile(t, filepath.Join(dir, "speakers.yml"), "alice: alice.flac\n")
mustWriteTestFile(t, filepath.Join(dir, "autocorrect.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "glossary.yml"), "[]\n")
mustWriteTestFile(t, filepath.Join(dir, "audio", "alice.flac"), "audio-bytes")
return pipelinePath, sessionPath
}

View File

@@ -0,0 +1,300 @@
package app
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
fake := &storage.FakeBackend{}
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fake, pipelinePath, sessionPath)
seedRestoreObject(fake, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
seedRestoreObject(fake, manifestKey, restoreWorkflowManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
seedRestoreObject(fake, sessionPrefix+"transcripts/full.json", []byte(`{"segments":[1,2,3]}`+"\n"))
seedRestoreObject(fake, sessionPrefix+"artifacts/session_recap.md", []byte("# restored recap\n"))
restoreWithStoreAndRealPhases(t, fake)
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
if opts.Env == nil {
opts.Env = &Env{}
}
opts.Env.Scriptorium = &scriptorium.NoopRunner{}
return executeStages(ctx, cfg, stages, opts)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
restoreCode := Execute(
[]string{
"restore",
"--config", pipelinePath,
"--session", sessionPath,
"--session-id", cfg.Session.SessionID,
},
&stdout,
&stderr,
)
if restoreCode != 0 {
t.Fatalf("restore exit code = %d, want 0; stderr=%q", restoreCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("restore stderr = %q, want empty", stderr.String())
}
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "full.json"), `{"segments":[1,2,3]}`+"\n")
mustReadEquals(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# restored recap\n")
manifestStore := &manifest.LocalStore{}
sessionManifestPath := artifacts.SessionManifestPathForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
beforeAnalyze, err := manifestStore.Load(context.Background(), sessionManifestPath)
if err != nil {
t.Fatalf("load restored session manifest: %v", err)
}
upstreamCompletedAt := map[string]time.Time{}
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
rec := beforeAnalyze.Stages[stageName]
if rec == nil || rec.Status != manifest.StatusSucceeded || rec.CompletedAt == nil {
t.Fatalf("restored manifest stage %q = %#v, want succeeded with completion timestamp", stageName, rec)
}
upstreamCompletedAt[stageName] = *rec.CompletedAt
}
stdout.Reset()
stderr.Reset()
runStageCode := Execute(
[]string{
"run-stage",
"--config", pipelinePath,
"--session", sessionPath,
"--session-id", cfg.Session.SessionID,
"--force",
"--artifacts", "player_handout",
"analyze",
},
&stdout,
&stderr,
)
if runStageCode != 0 {
t.Fatalf("run-stage exit code = %d, want 0; stderr=%q", runStageCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("run-stage stderr = %q, want empty", stderr.String())
}
if !strings.Contains(stdout.String(), "stage=analyze executed=1 skipped=0 force=true") {
t.Fatalf("run-stage stdout = %q, want analyze execution summary", stdout.String())
}
playerHandoutPath := filepath.Join(sessionRoot, "artifacts", "player_handout.md")
if _, err := os.Stat(playerHandoutPath); err != nil {
t.Fatalf("restored analyze output %q missing: %v", playerHandoutPath, err)
}
afterAnalyze, err := manifestStore.Load(context.Background(), sessionManifestPath)
if err != nil {
t.Fatalf("load session manifest after run-stage analyze: %v", err)
}
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
rec := afterAnalyze.Stages[stageName]
if rec == nil || rec.Status != manifest.StatusSucceeded || rec.CompletedAt == nil {
t.Fatalf("post-analyze manifest stage %q = %#v, want succeeded with completion timestamp", stageName, rec)
}
if !rec.CompletedAt.Equal(upstreamCompletedAt[stageName]) {
t.Fatalf(
"stage %q completion changed: before=%s after=%s",
stageName,
upstreamCompletedAt[stageName].Format(time.RFC3339Nano),
rec.CompletedAt.Format(time.RFC3339Nano),
)
}
}
analyzeRec := afterAnalyze.Stages["analyze"]
if analyzeRec == nil || analyzeRec.Status != manifest.StatusSucceeded {
t.Fatalf("post-analyze stage record = %#v, want succeeded", analyzeRec)
}
runManifestPaths, err := filepath.Glob(filepath.Join(sessionRoot, "runs", "*", "manifest.json"))
if err != nil {
t.Fatalf("glob run manifests: %v", err)
}
if len(runManifestPaths) != 1 {
t.Fatalf("run manifest count = %d, want 1; paths=%v", len(runManifestPaths), runManifestPaths)
}
runManifest, err := manifestStore.LoadRun(context.Background(), runManifestPaths[0])
if err != nil {
t.Fatalf("load run manifest %q: %v", runManifestPaths[0], err)
}
if len(runManifest.RequestedStages) != 1 || runManifest.RequestedStages[0] != "analyze" {
t.Fatalf("run manifest requested_stages = %#v, want [analyze]", runManifest.RequestedStages)
}
if runManifest.Stages["analyze"] == nil || runManifest.Stages["analyze"].Status != manifest.StatusSucceeded {
t.Fatalf("run manifest analyze stage = %#v, want succeeded", runManifest.Stages["analyze"])
}
if runManifest.Stages["prepare"] != nil {
t.Fatalf("run manifest should not include upstream prepare stage, got %#v", runManifest.Stages["prepare"])
}
}
func TestRestoreThenAnalyzeUsesRestoredPreviousCacheWithoutObjectStore(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
appendRestoreWorkflowScriptoriumConfig(t, pipelinePath, `
scriptorium:
binary: 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
previous_recap:
source: narratio.previous_session.artifact.session_recap
required: true
`)
fakeStore := &storage.FakeBackend{}
cfg, sessionPrefix, manifestKey, runIDKey := seedRestoreCommittedState(t, fakeStore, pipelinePath, sessionPath)
seedRestoreObject(fakeStore, runIDKey, []byte("20260519T010203Z-a1b2c3d4\n"))
seedRestoreObject(fakeStore, manifestKey, restoreWorkflowManifestJSON(t, cfg.Session.SessionID, cfg.Session.Campaign))
seedRestoreObject(fakeStore, sessionPrefix+"transcripts/trimmed.json", []byte(`{"segments":[]}`+"\n"))
seedRestoreObject(fakeStore, sessionPrefix+"previous/manifest.json", []byte(`{"session_id":"2026-04-26"}`))
seedRestoreObject(fakeStore, sessionPrefix+"previous/artifacts/session_recap.md", []byte("# previous recap\n"))
restoreWithStoreAndRealPhases(t, fakeStore)
var stdout bytes.Buffer
var stderr bytes.Buffer
restoreCode := Execute(
[]string{
"restore",
"--config", pipelinePath,
"--session", sessionPath,
"--session-id", cfg.Session.SessionID,
},
&stdout,
&stderr,
)
if restoreCode != 0 {
t.Fatalf("restore exit code = %d, want 0; stderr=%q", restoreCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("restore stderr = %q, want empty", stderr.String())
}
sessionRoot := artifacts.SessionWorkDirForCampaign(workspaceRoot, cfg.Session.Campaign, cfg.Session.SessionID)
mustReadEquals(t, filepath.Join(sessionRoot, "transcripts", "trimmed.json"), `{"segments":[]}`+"\n")
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "manifest.json"), `{"session_id":"2026-04-26"}`)
mustReadEquals(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
scriptoriumFake := &scriptorium.FakeRunner{}
origExecuteStagesFn := executeStagesFn
origObjectStoreFn := newObjectStoreFromConfigFn
objectStoreConstructed := false
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
newObjectStoreFromConfigFn = origObjectStoreFn
})
executeStagesFn = func(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
if opts.Env == nil {
opts.Env = &Env{}
}
opts.Env.Scriptorium = scriptoriumFake
return executeStages(ctx, cfg, stages, opts)
}
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
objectStoreConstructed = true
return nil, context.Canceled
}
stdout.Reset()
stderr.Reset()
runStageCode := Execute(
[]string{
"run-stage",
"--config", pipelinePath,
"--session", sessionPath,
"--session-id", cfg.Session.SessionID,
"--force",
"--artifacts", "session_recap",
"analyze",
},
&stdout,
&stderr,
)
if runStageCode != 0 {
t.Fatalf("run-stage exit code = %d, want 0; stderr=%q", runStageCode, stderr.String())
}
if stderr.Len() != 0 {
t.Fatalf("run-stage stderr = %q, want empty", stderr.String())
}
if objectStoreConstructed {
t.Fatal("analyze run-stage should not construct object store for previous-session input resolution")
}
if len(scriptoriumFake.RunRequests) != 1 {
t.Fatalf("scriptorium run requests = %d, want 1", len(scriptoriumFake.RunRequests))
}
req := scriptoriumFake.RunRequests[0]
if got := req.InputPaths["transcript"]; got != filepath.Join(sessionRoot, "transcripts", "trimmed.json") {
t.Fatalf("transcript input = %q, want trimmed transcript path", got)
}
if got := req.InputPaths["previous_recap"]; got != filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md") {
t.Fatalf("previous_recap input = %q, want restored previous cache path", got)
}
}
func restoreWorkflowManifestJSON(t *testing.T, sessionID, campaign string) []byte {
t.Helper()
store := &manifest.LocalStore{}
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC)
m := manifest.New(sessionID, now)
m.Campaign = campaign
m.RunID = "20260519T010203Z-a1b2c3d4"
stages := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"}
for i, stageName := range stages {
m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil)
}
path := filepath.Join(t.TempDir(), "manifest.json")
if err := store.Save(context.Background(), path, m); err != nil {
t.Fatalf("save workflow manifest fixture: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read workflow manifest fixture: %v", err)
}
return data
}
func appendRestoreWorkflowScriptoriumConfig(t *testing.T, pipelinePath, extra string) {
t.Helper()
f, err := os.OpenFile(pipelinePath, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatalf("open pipeline config for append: %v", err)
}
defer f.Close()
if _, err := f.WriteString(extra); err != nil {
t.Fatalf("append pipeline config: %v", err)
}
}

View File

@@ -19,11 +19,13 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string
var sessionPath string
var sessionID string
var previousSessionID string
var force bool
var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
@@ -43,7 +45,8 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
}
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID,
SessionID: sessionID,
PreviousSessionID: previousSessionID,
})
if err != nil {
return fmt.Errorf("resume: %w", err)
@@ -76,7 +79,7 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
}
}
summary, err := executeStages(ctx, cfg, selected, RunOptions{
summary, err := executeStagesFn(ctx, cfg, selected, RunOptions{
Force: force,
SelectedArtifacts: normalizedArtifacts,
})

View File

@@ -17,11 +17,13 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string
var sessionPath string
var sessionID string
var previousSessionID string
var force bool
var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
@@ -41,7 +43,8 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
}
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID,
SessionID: sessionID,
PreviousSessionID: previousSessionID,
})
if err != nil {
return fmt.Errorf("run: %w", err)
@@ -58,7 +61,7 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
}
stages := BuildFullPlan()
summary, err := executeStages(ctx, cfg, stages, RunOptions{
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
Force: force,
SelectedArtifacts: normalizedArtifacts,
})

View File

@@ -17,11 +17,13 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
var pipelinePath string
var sessionPath string
var sessionID string
var previousSessionID string
var force bool
var selectedArtifacts artifactSelectionFlag
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
@@ -54,7 +56,8 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
}
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
SessionID: sessionID,
SessionID: sessionID,
PreviousSessionID: previousSessionID,
})
if err != nil {
return fmt.Errorf("run-stage: %w", err)
@@ -66,7 +69,7 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
return fmt.Errorf("run-stage: %w", err)
}
summary, err := executeStages(ctx, cfg, stages, RunOptions{
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
Force: force,
SelectedArtifacts: normalizedArtifacts,
})

View File

@@ -36,6 +36,8 @@ type RunSummary struct {
Skipped []string
}
var executeStagesFn = executeStages
func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
env := opts.Env
if env == nil {
@@ -553,6 +555,12 @@ func needsObjectStoreForRun(cfg *config.Config, stages []stage.Stage) bool {
if cfg.Session.Inputs.AudioS3 != nil && stageRequested("prepare") {
return true
}
if stageRequested("prepare") {
requirements := artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg))
if len(requirements) > 0 && strings.TrimSpace(cfg.Session.PreviousSessionID) != "" {
return true
}
}
if !stageRequested("archive") {
return false
}
@@ -567,3 +575,10 @@ func needsObjectStoreForRun(cfg *config.Config, stages []stage.Stage) bool {
}
return true
}
func configuredScriptoriumArtifacts(cfg *config.Config) map[string]config.ScriptoriumArtifactConfig {
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Scriptorium == nil {
return nil
}
return cfg.Pipeline.Scriptorium.Artifacts
}

View File

@@ -199,6 +199,55 @@ func TestExecuteStagesAnalyzeOutputsPersistAsScriptoriumArtifacts(t *testing.T)
}
}
func TestNeedsObjectStoreForRunPrepareWithPreviousRequirements(t *testing.T) {
tests := []struct {
name string
previousSessionID string
want bool
}{
{
name: "previous session configured",
previousSessionID: "2026-05-10",
want: true,
},
{
name: "previous session missing",
previousSessionID: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
Pipeline: &config.PipelineConfig{
Scriptorium: &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"previous_recap": {
Source: "narratio.previous_session.artifact.session_recap",
Required: true,
},
},
},
},
},
},
Session: &config.SessionConfig{
PreviousSessionID: tt.previousSessionID,
},
}
got := needsObjectStoreForRun(cfg, []stage.Stage{countingStage{name: "prepare", runs: new(int)}})
if got != tt.want {
t.Fatalf("needsObjectStoreForRun() = %v, want %v", got, tt.want)
}
})
}
}
func TestExecuteStagesArchiveFailsWhenRequiredRecapPromotionMissingForSelectedArtifacts(t *testing.T) {
cfg := testConfig(t)
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{
@@ -209,7 +258,14 @@ func TestExecuteStagesArchiveFailsWhenRequiredRecapPromotionMissingForSelectedAr
Enabled: boolPtr(true),
UploadRun: boolPtr(true),
PromoteArtifacts: []config.ArchivePromotionRule{
{From: "artifacts/session_recap.md", To: "artifacts/session_recap.md", Required: boolPtr(true)},
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
},
}
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
OutputPath: "artifacts/session_recap.md",
},
},
}
@@ -247,8 +303,8 @@ func TestExecuteStagesArchiveFailsWhenRequiredRecapPromotionMissingForSelectedAr
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())
if !strings.Contains(err.Error(), "required promotion source unavailable") {
t.Fatalf("error = %q, want required promotion source unavailable", err.Error())
}
}

View File

@@ -9,11 +9,12 @@ import (
"testing"
)
func TestPlanUsesDiscoveredSessionTemplateWithSessionID(t *testing.T) {
func TestPlanUsesDiscoveredSessionTemplateWithSessionIDs(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
sessionTemplate := `session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign
inputs:
audio_dir: ./audio
@@ -36,7 +37,11 @@ inputs:
t.Cleanup(func() { _ = os.Chdir(originalWD) })
var out bytes.Buffer
if err := Plan(context.Background(), []string{"--config", pipelinePath, "--session-id", "2026-04-04"}, &out); err != nil {
if err := Plan(context.Background(), []string{
"--config", pipelinePath,
"--session-id", "2026-04-04",
"--previous-session-id", "2026-03-28",
}, &out); err != nil {
t.Fatalf("Plan() error = %v", err)
}
if !strings.Contains(out.String(), "narratio plan: workdir prepared") {
@@ -58,6 +63,38 @@ func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
}
}
func TestPlanFailsWhenPreviousSessionIDMismatchesConcreteSession(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
sessionYAML := `session_id: 2026-05-03
previous_session_id: 2026-04-26
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
var out bytes.Buffer
err := Plan(context.Background(), []string{
"--config", pipelinePath,
"--session", sessionPath,
"--session-id", "2026-05-03",
"--previous-session-id", "2026-04-25",
}, &out)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "previous_session_id mismatch") {
t.Fatalf("error = %q, want mismatch context", err.Error())
}
}
func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)

View File

@@ -0,0 +1,77 @@
package artifacts
import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// ResolveArchiveBucket resolves archive bucket identity with manifest-first precedence.
func ResolveArchiveBucket(cfg *config.Config, m *manifest.Manifest) string {
if m != nil && strings.TrimSpace(m.S3Bucket) != "" {
return strings.TrimSpace(m.S3Bucket)
}
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Storage.S3 == nil {
return ""
}
return strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket)
}
// ResolveArchiveSessionPrefix resolves archive session prefix with manifest-first precedence.
func ResolveArchiveSessionPrefix(cfg *config.Config, m *manifest.Manifest) (string, error) {
if m != nil && strings.TrimSpace(m.S3SessionPrefix) != "" {
return strings.TrimSpace(m.S3SessionPrefix), nil
}
if cfg == nil || cfg.Session == nil || cfg.Pipeline == nil {
return "", fmt.Errorf("resolved config is required")
}
sessionID := strings.TrimSpace(cfg.Session.SessionID)
if sessionID == "" && m != nil {
sessionID = strings.TrimSpace(m.SessionID)
}
campaign := strings.TrimSpace(cfg.Session.Campaign)
if campaign == "" && m != nil {
campaign = strings.TrimSpace(m.Campaign)
}
if cfg.Pipeline.Storage.S3 == nil {
return "", fmt.Errorf("pipeline.storage.s3 configuration is required")
}
sessionPrefix := S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, campaign, sessionID)
if strings.TrimSpace(sessionPrefix) == "" {
return "", fmt.Errorf("session prefix is required")
}
return sessionPrefix, nil
}
// ResolveArchiveRunPrefix resolves archive run prefix with manifest-first precedence.
func ResolveArchiveRunPrefix(cfg *config.Config, m *manifest.Manifest) (string, error) {
if m != nil {
runPrefix := strings.TrimSpace(m.S3RunPrefix)
if runPrefix != "" {
return runPrefix, nil
}
}
sessionPrefix, err := ResolveArchiveSessionPrefix(cfg, m)
if err != nil {
return "", err
}
runID := ""
if m != nil {
runID = strings.TrimSpace(m.RunID)
}
if runID == "" {
return "", fmt.Errorf("run id is required")
}
return S3RunPrefix(sessionPrefix, runID), nil
}
// ResolveArchiveCurrentStateKeys returns current pointer keys for a session prefix.
func ResolveArchiveCurrentStateKeys(sessionPrefix string) (manifestKey, runIDKey string) {
return S3CurrentManifestKey(sessionPrefix), S3CurrentRunPointerKey(sessionPrefix)
}

View File

@@ -0,0 +1,130 @@
package artifacts
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestResolveArchiveBucketPrefersManifestThenConfig(t *testing.T) {
cfg := &config.Config{
Pipeline: &config.PipelineConfig{
Storage: config.StorageConfig{
S3: &config.StorageS3Config{Bucket: "cfg-bucket"},
},
},
}
if got := ResolveArchiveBucket(cfg, &manifest.Manifest{S3Bucket: "manifest-bucket"}); got != "manifest-bucket" {
t.Fatalf("bucket = %q, want manifest-bucket", got)
}
if got := ResolveArchiveBucket(cfg, &manifest.Manifest{}); got != "cfg-bucket" {
t.Fatalf("bucket = %q, want cfg-bucket", got)
}
}
func TestResolveArchiveSessionPrefixPrefersManifestThenConfig(t *testing.T) {
cfg := &config.Config{
Pipeline: &config.PipelineConfig{
Storage: config.StorageConfig{
S3: &config.StorageS3Config{RootPrefix: "dnd"},
},
},
Session: &config.SessionConfig{
SessionID: "2026-04-19",
Campaign: "forsaken",
},
}
m := &manifest.Manifest{S3SessionPrefix: "manifest/session/prefix/"}
got, err := ResolveArchiveSessionPrefix(cfg, m)
if err != nil {
t.Fatalf("ResolveArchiveSessionPrefix() error = %v", err)
}
if got != "manifest/session/prefix/" {
t.Fatalf("session prefix = %q, want manifest/session/prefix/", got)
}
got, err = ResolveArchiveSessionPrefix(cfg, &manifest.Manifest{})
if err != nil {
t.Fatalf("ResolveArchiveSessionPrefix() error = %v", err)
}
want := "dnd/campaigns/forsaken/sessions/2026-04-19/"
if got != want {
t.Fatalf("session prefix = %q, want %q", got, want)
}
}
func TestResolveArchiveRunPrefixPrefersManifestThenDerived(t *testing.T) {
cfg := &config.Config{
Pipeline: &config.PipelineConfig{
Storage: config.StorageConfig{
S3: &config.StorageS3Config{RootPrefix: "dnd"},
},
},
Session: &config.SessionConfig{
SessionID: "2026-04-19",
Campaign: "forsaken",
},
}
m := &manifest.Manifest{
RunID: "20260516T010203Z-1a2b3c4d",
S3RunPrefix: "manifest/run/prefix/",
}
got, err := ResolveArchiveRunPrefix(cfg, m)
if err != nil {
t.Fatalf("ResolveArchiveRunPrefix() error = %v", err)
}
if got != "manifest/run/prefix/" {
t.Fatalf("run prefix = %q, want manifest/run/prefix/", got)
}
m = &manifest.Manifest{
RunID: "20260516T010203Z-1a2b3c4d",
}
got, err = ResolveArchiveRunPrefix(cfg, m)
if err != nil {
t.Fatalf("ResolveArchiveRunPrefix() error = %v", err)
}
want := "dnd/campaigns/forsaken/sessions/2026-04-19/runs/20260516T010203Z-1a2b3c4d/"
if got != want {
t.Fatalf("run prefix = %q, want %q", got, want)
}
}
func TestResolveArchiveIdentityErrorsAreDeterministic(t *testing.T) {
cfgNoS3 := &config.Config{
Pipeline: &config.PipelineConfig{},
Session: &config.SessionConfig{SessionID: "2026-04-19", Campaign: "forsaken"},
}
_, err := ResolveArchiveSessionPrefix(cfgNoS3, &manifest.Manifest{})
if err == nil || !strings.Contains(err.Error(), "pipeline.storage.s3 configuration is required") {
t.Fatalf("error = %v, want missing storage.s3", err)
}
cfg := &config.Config{
Pipeline: &config.PipelineConfig{
Storage: config.StorageConfig{
S3: &config.StorageS3Config{RootPrefix: "dnd"},
},
},
Session: &config.SessionConfig{SessionID: "2026-04-19", Campaign: "forsaken"},
}
_, err = ResolveArchiveRunPrefix(cfg, &manifest.Manifest{})
if err == nil || !strings.Contains(err.Error(), "run id is required") {
t.Fatalf("error = %v, want missing run id", err)
}
}
func TestResolveArchiveCurrentStateKeys(t *testing.T) {
manifestKey, runIDKey := ResolveArchiveCurrentStateKeys("dnd/campaigns/forsaken/sessions/2026-04-19/")
if manifestKey != "dnd/campaigns/forsaken/sessions/2026-04-19/current/manifest.json" {
t.Fatalf("manifest key = %q", manifestKey)
}
if runIDKey != "dnd/campaigns/forsaken/sessions/2026-04-19/current/run_id.txt" {
t.Fatalf("run id key = %q", runIDKey)
}
}

View File

@@ -18,11 +18,15 @@ const (
ArtifactTranscriptFull = "narratio.transcript.full"
ArtifactTranscriptTrimmed = "narratio.transcript.trimmed"
ArtifactBoundsSession = "narratio.bounds.session"
ArtifactProvenancePreviousCacheManifestInput = "manifest.inputs.previous_cache"
ArtifactProvenancePreviousCacheFilesystem = "current_session.previous_cache"
)
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
var ErrSessionArtifactNotFound = errors.New("session artifact not found")
var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.[a-z][a-z0-9_]*$`)
var previousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
type artifactContentKind string
@@ -118,6 +122,21 @@ func IsConfiguredArtifactSource(source string) bool {
return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source))
}
// IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.<name>.
func IsPreviousSessionArtifactSource(source string) bool {
_, ok := PreviousSessionArtifactName(source)
return ok
}
// PreviousSessionArtifactName extracts <name> from narratio.previous_session.artifact.<name>.
func PreviousSessionArtifactName(source string) (string, bool) {
matches := previousSessionArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source))
if len(matches) != 2 {
return "", false
}
return matches[1], true
}
// ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path.
// Resolution order is manifest producer outputs first, then canonical session path fallback.
func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source string) (ResolvedSessionArtifact, error) {
@@ -170,6 +189,9 @@ func ResolveSessionArtifact(paths SessionPaths, m *manifest.Manifest, source str
// configured narratio.artifact.<name> sources through runtime catalog availability.
func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest, source string, catalog *ArtifactCatalog) (ResolvedSessionArtifact, error) {
normalized := strings.TrimSpace(source)
if IsPreviousSessionArtifactSource(normalized) {
return ResolvePreviousSessionArtifactWithCatalog(paths, m, normalized, catalog)
}
if !IsConfiguredArtifactSource(normalized) {
return ResolveSessionArtifact(paths, m, normalized)
}
@@ -195,6 +217,72 @@ func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest,
}, nil
}
// ResolvePreviousSessionArtifactWithCatalog resolves one canonical previous-session source id
// to the prepared current-session previous-cache path.
func ResolvePreviousSessionArtifactWithCatalog(
paths SessionPaths,
m *manifest.Manifest,
source string,
catalog *ArtifactCatalog,
) (ResolvedSessionArtifact, error) {
artifactName, ok := PreviousSessionArtifactName(source)
if !ok {
return ResolvedSessionArtifact{}, fmt.Errorf("unsupported previous-session artifact source %q", source)
}
if catalog == nil {
return ResolvedSessionArtifact{}, fmt.Errorf("previous-session artifact source %q requires runtime artifact catalog", source)
}
configuredSourceID := ConfiguredArtifactSourceID(artifactName)
entry, ok := catalog.Lookup(configuredSourceID)
if !ok {
return ResolvedSessionArtifact{}, fmt.Errorf("unsupported previous-session artifact source %q", source)
}
candidates := previousSessionCacheCandidatePaths(paths, entry.CanonicalRelPath)
if len(candidates) == 0 {
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source}
}
manifestInputPaths := manifestInputPathSet(paths, m)
fallback := ""
for _, candidate := range candidates {
exists, isDir, statErr := pathExists(candidate)
if statErr != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("stat %q: %w", candidate, statErr)
}
if !exists || isDir {
continue
}
if err := validateResolvedContent(candidate, contentText); err != nil {
return ResolvedSessionArtifact{}, fmt.Errorf("validate %q: %w", source, err)
}
if _, ok := manifestInputPaths[candidate]; ok {
return ResolvedSessionArtifact{
ID: source,
Path: candidate,
ProducerStage: "prepare",
OutputKind: "previous_session_artifact",
Provenance: ArtifactProvenancePreviousCacheManifestInput,
}, nil
}
if fallback == "" {
fallback = candidate
}
}
if fallback != "" {
return ResolvedSessionArtifact{
ID: source,
Path: fallback,
ProducerStage: "prepare",
OutputKind: "previous_session_artifact",
Provenance: ArtifactProvenancePreviousCacheFilesystem,
}, nil
}
return ResolvedSessionArtifact{}, &SessionArtifactNotFoundError{ArtifactID: source}
}
func manifestArtifactCandidates(paths SessionPaths, m *manifest.Manifest, spec artifactSpec) []ResolvedSessionArtifact {
if m == nil || len(m.Stages) == 0 || spec.ProducerStage == "" || spec.OutputKind == "" {
return nil
@@ -243,6 +331,50 @@ func dedupeResolvedArtifacts(values []ResolvedSessionArtifact) []ResolvedSession
return out
}
func previousSessionCacheCandidatePaths(paths SessionPaths, canonicalRelPath string) []string {
trimmed := strings.TrimSpace(canonicalRelPath)
if trimmed == "" {
return nil
}
normalized := filepath.ToSlash(filepath.Clean(filepath.FromSlash(trimmed)))
if normalized == "." || normalized == "" || normalized == ".." || strings.HasPrefix(normalized, "../") || strings.HasPrefix(normalized, "/") {
return nil
}
relCandidates := []string{normalized}
const artifactsPrefix = "artifacts/"
if strings.HasPrefix(normalized, artifactsPrefix) && len(normalized) > len(artifactsPrefix) {
relCandidates = append(relCandidates, strings.TrimPrefix(normalized, artifactsPrefix))
}
out := make([]string, 0, len(relCandidates))
seen := map[string]struct{}{}
for _, rel := range relCandidates {
abs := filepath.Clean(SessionPreviousArtifactPath(paths, rel))
if _, ok := seen[abs]; ok {
continue
}
seen[abs] = struct{}{}
out = append(out, abs)
}
return out
}
func manifestInputPathSet(paths SessionPaths, m *manifest.Manifest) map[string]struct{} {
if m == nil || len(m.Inputs) == 0 {
return nil
}
out := make(map[string]struct{}, len(m.Inputs))
for _, in := range m.Inputs {
resolved := filepath.Clean(ResolveSessionLocalPathForRead(paths, in.Path))
if strings.TrimSpace(resolved) == "" {
continue
}
out[resolved] = struct{}{}
}
return out
}
func pathExists(path string) (exists bool, isDir bool, err error) {
info, err := os.Stat(path)
if err == nil {

View File

@@ -45,6 +45,58 @@ func TestNormalizeSessionArtifactSource(t *testing.T) {
}
}
func TestPreviousSessionArtifactSourceHelpers(t *testing.T) {
tests := []struct {
name string
source string
wantName string
wantMatch bool
}{
{
name: "valid",
source: "narratio.previous_session.artifact.session_recap",
wantName: "session_recap",
wantMatch: true,
},
{
name: "valid with surrounding whitespace",
source: " narratio.previous_session.artifact.quest_log ",
wantName: "quest_log",
wantMatch: true,
},
{
name: "missing name",
source: "narratio.previous_session.artifact.",
wantMatch: false,
},
{
name: "invalid key characters",
source: "narratio.previous_session.artifact.session-recap",
wantMatch: false,
},
{
name: "wrong prefix",
source: "narratio.previous.artifact.session_recap",
wantMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
name, ok := PreviousSessionArtifactName(tt.source)
if ok != tt.wantMatch {
t.Fatalf("PreviousSessionArtifactName(%q) ok = %t, want %t", tt.source, ok, tt.wantMatch)
}
if name != tt.wantName {
t.Fatalf("PreviousSessionArtifactName(%q) name = %q, want %q", tt.source, name, tt.wantName)
}
if got := IsPreviousSessionArtifactSource(tt.source); got != tt.wantMatch {
t.Fatalf("IsPreviousSessionArtifactSource(%q) = %t, want %t", tt.source, got, tt.wantMatch)
}
})
}
}
func TestResolveSessionArtifactPrefersManifestOutput(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
@@ -266,3 +318,120 @@ func TestResolveSessionArtifactWithCatalogUnsupportedConfiguredSourceFails(t *te
t.Fatalf("error = %q, want unsupported artifact source", err.Error())
}
}
func TestResolvePreviousSessionArtifactWithCatalogPrefersManifestInputRecord(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
manifestBackedPath := SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
fallbackPath := SessionPreviousArtifactPath(paths, "session_recap.md")
if err := os.MkdirAll(filepath.Dir(manifestBackedPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.MkdirAll(filepath.Dir(fallbackPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(manifestBackedPath, []byte("recap from manifest input\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := os.WriteFile(fallbackPath, []byte("recap fallback\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(
map[string]ConfiguredArtifactDefinition{
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
},
nil,
); err != nil {
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
}
m := manifest.New("session", time.Now().UTC())
m.Inputs = []manifest.InputRecord{
{Kind: "previous_artifact", Path: manifestBackedPath},
}
resolved, err := ResolvePreviousSessionArtifactWithCatalog(
paths,
m,
"narratio.previous_session.artifact.session_recap",
catalog,
)
if err != nil {
t.Fatalf("ResolvePreviousSessionArtifactWithCatalog() error = %v", err)
}
if resolved.Path != manifestBackedPath {
t.Fatalf("resolved path = %q, want %q", resolved.Path, manifestBackedPath)
}
if resolved.Provenance != ArtifactProvenancePreviousCacheManifestInput {
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenancePreviousCacheManifestInput)
}
}
func TestResolvePreviousSessionArtifactWithCatalogFallsBackToPreparedCachePath(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
fallbackPath := SessionPreviousArtifactPath(paths, "session_recap.md")
if err := os.MkdirAll(filepath.Dir(fallbackPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(fallbackPath, []byte("recap fallback\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(
map[string]ConfiguredArtifactDefinition{
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
},
nil,
); err != nil {
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
}
resolved, err := ResolvePreviousSessionArtifactWithCatalog(
paths,
nil,
"narratio.previous_session.artifact.session_recap",
catalog,
)
if err != nil {
t.Fatalf("ResolvePreviousSessionArtifactWithCatalog() error = %v", err)
}
if resolved.Path != fallbackPath {
t.Fatalf("resolved path = %q, want %q", resolved.Path, fallbackPath)
}
if resolved.Provenance != ArtifactProvenancePreviousCacheFilesystem {
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenancePreviousCacheFilesystem)
}
}
func TestResolvePreviousSessionArtifactWithCatalogMissingReturnsTypedError(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(
map[string]ConfiguredArtifactDefinition{
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
},
nil,
); err != nil {
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
}
_, err := ResolvePreviousSessionArtifactWithCatalog(
paths,
nil,
"narratio.previous_session.artifact.session_recap",
catalog,
)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, ErrSessionArtifactNotFound) {
t.Fatalf("errors.Is(err, ErrSessionArtifactNotFound)=false; err=%v", err)
}
}

View File

@@ -73,6 +73,8 @@ func (s *LocalStore) ensureLayout(paths SessionPaths) (SessionPaths, error) {
paths.LogsDir,
paths.CurrentDir,
paths.RunsDir,
paths.PreviousDir,
paths.PreviousArtifactsDir,
}
for _, dir := range dirs {

View File

@@ -27,10 +27,15 @@ func TestEnsureLayoutCreatesExpectedDirectories(t *testing.T) {
checkDirExists(t, paths.LogsDir)
checkDirExists(t, paths.CurrentDir)
checkDirExists(t, paths.RunsDir)
checkDirExists(t, paths.PreviousDir)
checkDirExists(t, paths.PreviousArtifactsDir)
if filepath.Base(paths.ManifestPath) != "manifest.json" {
t.Fatalf("ManifestPath = %q, want basename manifest.json", paths.ManifestPath)
}
if filepath.Base(paths.PreviousManifestPath) != "manifest.json" {
t.Fatalf("PreviousManifestPath = %q, want basename manifest.json", paths.PreviousManifestPath)
}
if filepath.Base(paths.LockPath) != ".lock" {
t.Fatalf("LockPath = %q, want basename .lock", paths.LockPath)
}

View File

@@ -23,6 +23,9 @@ type SessionPaths struct {
LogsDir string
CurrentDir string
RunsDir string
PreviousDir string
PreviousManifestPath string
PreviousArtifactsDir string
ManifestPath string
LockPath string
}
@@ -42,6 +45,29 @@ func SessionRunsDirForCampaign(rootDir, campaign, sessionID string) string {
return filepath.Join(SessionWorkDirForCampaign(rootDir, campaign, sessionID), config.PathRunsDirSegment)
}
// SessionPreviousDirForCampaign returns the canonical previous-session state directory.
func SessionPreviousDirForCampaign(rootDir, campaign, sessionID string) string {
return filepath.Join(SessionWorkDirForCampaign(rootDir, campaign, sessionID), config.PathPreviousDirSegment)
}
// SessionPreviousManifestPathForCampaign returns the canonical previous-session manifest cache path.
func SessionPreviousManifestPathForCampaign(rootDir, campaign, sessionID string) string {
return filepath.Join(SessionPreviousDirForCampaign(rootDir, campaign, sessionID), config.PathManifestFile)
}
// SessionPreviousArtifactsDirForCampaign returns the canonical previous-session artifact cache directory.
func SessionPreviousArtifactsDirForCampaign(rootDir, campaign, sessionID string) string {
return filepath.Join(SessionPreviousDirForCampaign(rootDir, campaign, sessionID), config.PathArtifactsDirSegment)
}
// SessionPreviousArtifactPathForCampaign returns a path under previous/artifacts for one artifact.
func SessionPreviousArtifactPathForCampaign(rootDir, campaign, sessionID, artifactRelativePath string) string {
return filepath.Join(
SessionPreviousArtifactsDirForCampaign(rootDir, campaign, sessionID),
filepath.FromSlash(artifactRelativePath),
)
}
// SessionRunRootForCampaign returns the canonical run root under runs/{run_id}.
func SessionRunRootForCampaign(rootDir, campaign, sessionID, runID string) string {
return filepath.Join(SessionRunsDirForCampaign(rootDir, campaign, sessionID), runID)
@@ -62,6 +88,26 @@ func SessionSpoolAudioDir(spoolRoot, campaign, sessionID, runID string) string {
return filepath.Join(spoolRoot, campaign, sessionID, runID, config.PathAudioDirSegment)
}
// SessionPreviousDir returns the previous-session state directory for already-resolved session paths.
func SessionPreviousDir(paths SessionPaths) string {
return paths.PreviousDir
}
// SessionPreviousManifestPath returns the previous-session manifest path for already-resolved session paths.
func SessionPreviousManifestPath(paths SessionPaths) string {
return paths.PreviousManifestPath
}
// SessionPreviousArtifactsDir returns the previous-session artifact directory for already-resolved session paths.
func SessionPreviousArtifactsDir(paths SessionPaths) string {
return paths.PreviousArtifactsDir
}
// SessionPreviousArtifactPath returns a path under previous/artifacts for already-resolved session paths.
func SessionPreviousArtifactPath(paths SessionPaths, artifactRelativePath string) string {
return filepath.Join(paths.PreviousArtifactsDir, filepath.FromSlash(artifactRelativePath))
}
func buildSessionPaths(workspaceRoot, campaign, sessionID string) SessionPaths {
root := SessionWorkDirForCampaign(workspaceRoot, campaign, sessionID)
return buildSessionPathsFromRoot(workspaceRoot, campaign, sessionID, root)
@@ -84,6 +130,9 @@ func buildSessionPathsFromRoot(workspaceRoot, campaign, sessionID, root string)
LogsDir: filepath.Join(root, config.PathLogsDirSegment),
CurrentDir: filepath.Join(root, config.PathCurrentDirSegment),
RunsDir: filepath.Join(root, config.PathRunsDirSegment),
PreviousDir: filepath.Join(root, config.PathPreviousDirSegment),
PreviousManifestPath: filepath.Join(root, config.PathPreviousDirSegment, config.PathManifestFile),
PreviousArtifactsDir: filepath.Join(root, config.PathPreviousDirSegment, config.PathArtifactsDirSegment),
ManifestPath: filepath.Join(root, config.PathManifestFile),
LockPath: filepath.Join(root, config.PathLockFile),
}

View File

@@ -49,6 +49,52 @@ func TestSessionRunManifestPathForCampaign(t *testing.T) {
}
}
func TestSessionPreviousPathsForCampaign(t *testing.T) {
root := "/tmp/workspace"
previousDir := SessionPreviousDirForCampaign(root, "forsaken", "2026-04-19")
wantPreviousDir := filepath.Join(root, "work", "forsaken", "2026-04-19", "previous")
if previousDir != wantPreviousDir {
t.Fatalf("SessionPreviousDirForCampaign() = %q, want %q", previousDir, wantPreviousDir)
}
manifestPath := SessionPreviousManifestPathForCampaign(root, "forsaken", "2026-04-19")
wantManifestPath := filepath.Join(root, "work", "forsaken", "2026-04-19", "previous", "manifest.json")
if manifestPath != wantManifestPath {
t.Fatalf("SessionPreviousManifestPathForCampaign() = %q, want %q", manifestPath, wantManifestPath)
}
artifactsDir := SessionPreviousArtifactsDirForCampaign(root, "forsaken", "2026-04-19")
wantArtifactsDir := filepath.Join(root, "work", "forsaken", "2026-04-19", "previous", "artifacts")
if artifactsDir != wantArtifactsDir {
t.Fatalf("SessionPreviousArtifactsDirForCampaign() = %q, want %q", artifactsDir, wantArtifactsDir)
}
artifactPath := SessionPreviousArtifactPathForCampaign(root, "forsaken", "2026-04-19", "session_recap.md")
wantArtifactPath := filepath.Join(root, "work", "forsaken", "2026-04-19", "previous", "artifacts", "session_recap.md")
if artifactPath != wantArtifactPath {
t.Fatalf("SessionPreviousArtifactPathForCampaign() = %q, want %q", artifactPath, wantArtifactPath)
}
}
func TestSessionPreviousPathsFromSessionPaths(t *testing.T) {
paths := buildSessionPaths("/tmp/workspace", "forsaken", "2026-04-19")
if got := SessionPreviousDir(paths); got != paths.PreviousDir {
t.Fatalf("SessionPreviousDir() = %q, want %q", got, paths.PreviousDir)
}
if got := SessionPreviousManifestPath(paths); got != paths.PreviousManifestPath {
t.Fatalf("SessionPreviousManifestPath() = %q, want %q", got, paths.PreviousManifestPath)
}
if got := SessionPreviousArtifactsDir(paths); got != paths.PreviousArtifactsDir {
t.Fatalf("SessionPreviousArtifactsDir() = %q, want %q", got, paths.PreviousArtifactsDir)
}
got := SessionPreviousArtifactPath(paths, "quest_log.json")
want := filepath.Join(paths.PreviousArtifactsDir, "quest_log.json")
if got != want {
t.Fatalf("SessionPreviousArtifactPath() = %q, want %q", got, want)
}
}
func TestSessionSpoolAudioDir(t *testing.T) {
root := "/var/spool/narratio"
got := SessionSpoolAudioDir(root, "forsaken", "2026-04-19", "20260515T031522Z-a1b2c3d4")

View File

@@ -0,0 +1,112 @@
package artifacts
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
// PreviousArtifactRequirement describes one previous-session artifact dependency.
type PreviousArtifactRequirement struct {
Name string
Required bool
Sources []string
}
// CollectPreviousArtifactRequirements scans enabled Scriptorium artifacts and returns
// deduplicated previous-session artifact requirements in deterministic order.
func CollectPreviousArtifactRequirements(
artifactsCfg map[string]config.ScriptoriumArtifactConfig,
) []PreviousArtifactRequirement {
if len(artifactsCfg) == 0 {
return nil
}
artifactNames := sortedScriptoriumArtifactNames(artifactsCfg)
byName := map[string]PreviousArtifactRequirement{}
for _, artifactName := range artifactNames {
artifactCfg := artifactsCfg[artifactName]
if !artifactCfg.Enabled {
continue
}
inputNames := sortedScriptoriumInputKeys(artifactCfg.Inputs)
for _, inputName := range inputNames {
inputCfg := artifactCfg.Inputs[inputName]
previousName, ok := PreviousSessionArtifactName(inputCfg.Source)
if !ok {
continue
}
location := fmt.Sprintf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source",
artifactName,
inputName,
)
requirement := byName[previousName]
requirement.Name = previousName
requirement.Required = requirement.Required || inputCfg.Required
requirement.Sources = append(requirement.Sources, location)
byName[previousName] = requirement
}
}
if len(byName) == 0 {
return nil
}
requirements := make([]PreviousArtifactRequirement, 0, len(byName))
for _, requirement := range byName {
requirement.Sources = dedupeAndSortStrings(requirement.Sources)
requirements = append(requirements, requirement)
}
sort.Slice(requirements, func(i, j int) bool {
return requirements[i].Name < requirements[j].Name
})
return requirements
}
func sortedScriptoriumArtifactNames(artifactsCfg map[string]config.ScriptoriumArtifactConfig) []string {
names := make([]string, 0, len(artifactsCfg))
for name := range artifactsCfg {
names = append(names, name)
}
sort.Strings(names)
return names
}
func sortedScriptoriumInputKeys(inputs map[string]config.ScriptoriumInputConfig) []string {
if len(inputs) == 0 {
return nil
}
names := make([]string, 0, len(inputs))
for name := range inputs {
names = append(names, name)
}
sort.Strings(names)
return names
}
func dedupeAndSortStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
sort.Strings(out)
return out
}

View File

@@ -0,0 +1,190 @@
package artifacts
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestCollectPreviousArtifactRequirements(t *testing.T) {
tests := []struct {
name string
artifactsCfg map[string]config.ScriptoriumArtifactConfig
want []PreviousArtifactRequirement
}{
{
name: "no artifacts",
artifactsCfg: nil,
want: nil,
},
{
name: "no previous inputs",
artifactsCfg: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"transcript": {Source: "narratio.transcript.trimmed", Required: true},
},
},
},
want: nil,
},
{
name: "one optional previous input",
artifactsCfg: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"previous_recap": {Source: "narratio.previous_session.artifact.session_recap", Required: false},
},
},
},
want: []PreviousArtifactRequirement{
{
Name: "session_recap",
Required: false,
Sources: []string{"pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source"},
},
},
},
{
name: "one required previous input",
artifactsCfg: map[string]config.ScriptoriumArtifactConfig{
"quest_log": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"previous_quest_log": {Source: "narratio.previous_session.artifact.quest_log", Required: true},
},
},
},
want: []PreviousArtifactRequirement{
{
Name: "quest_log",
Required: true,
Sources: []string{"pipeline.scriptorium.artifacts.quest_log.inputs.previous_quest_log.source"},
},
},
},
{
name: "duplicate references are deduped",
artifactsCfg: map[string]config.ScriptoriumArtifactConfig{
"a": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"x": {Source: "narratio.previous_session.artifact.session_recap", Required: false},
},
},
"b": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"y": {Source: "narratio.previous_session.artifact.session_recap", Required: false},
},
},
},
want: []PreviousArtifactRequirement{
{
Name: "session_recap",
Required: false,
Sources: []string{
"pipeline.scriptorium.artifacts.a.inputs.x.source",
"pipeline.scriptorium.artifacts.b.inputs.y.source",
},
},
},
},
{
name: "required plus optional reference becomes required",
artifactsCfg: map[string]config.ScriptoriumArtifactConfig{
"a": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"x": {Source: "narratio.previous_session.artifact.session_recap", Required: false},
},
},
"b": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"y": {Source: "narratio.previous_session.artifact.session_recap", Required: true},
},
},
},
want: []PreviousArtifactRequirement{
{
Name: "session_recap",
Required: true,
Sources: []string{
"pipeline.scriptorium.artifacts.a.inputs.x.source",
"pipeline.scriptorium.artifacts.b.inputs.y.source",
},
},
},
},
{
name: "disabled artifact references are ignored",
artifactsCfg: map[string]config.ScriptoriumArtifactConfig{
"disabled_artifact": {
Enabled: false,
Inputs: map[string]config.ScriptoriumInputConfig{
"x": {Source: "narratio.previous_session.artifact.session_recap", Required: true},
},
},
"enabled_artifact": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"y": {Source: "narratio.previous_session.artifact.quest_log", Required: false},
},
},
},
want: []PreviousArtifactRequirement{
{
Name: "quest_log",
Required: false,
Sources: []string{"pipeline.scriptorium.artifacts.enabled_artifact.inputs.y.source"},
},
},
},
{
name: "deterministic ordering",
artifactsCfg: map[string]config.ScriptoriumArtifactConfig{
"zz": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"b_input": {Source: "narratio.previous_session.artifact.quest_log", Required: false},
"a_input": {Source: "narratio.previous_session.artifact.session_recap", Required: false},
},
},
"aa": {
Enabled: true,
Inputs: map[string]config.ScriptoriumInputConfig{
"c_input": {Source: "narratio.previous_session.artifact.session_recap", Required: false},
},
},
},
want: []PreviousArtifactRequirement{
{
Name: "quest_log",
Required: false,
Sources: []string{"pipeline.scriptorium.artifacts.zz.inputs.b_input.source"},
},
{
Name: "session_recap",
Required: false,
Sources: []string{
"pipeline.scriptorium.artifacts.aa.inputs.c_input.source",
"pipeline.scriptorium.artifacts.zz.inputs.a_input.source",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CollectPreviousArtifactRequirements(tt.artifactsCfg)
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("CollectPreviousArtifactRequirements() = %#v, want %#v", got, tt.want)
}
})
}
}

View File

@@ -27,11 +27,12 @@ type PipelineConfig struct {
// SessionConfig contains per-session inputs and metadata.
type SessionConfig struct {
SessionID string `yaml:"session_id"`
Campaign string `yaml:"campaign"`
Date string `yaml:"date"`
Title string `yaml:"title"`
Inputs SessionInputsConfig `yaml:"inputs"`
SessionID string `yaml:"session_id"`
PreviousSessionID string `yaml:"previous_session_id"`
Campaign string `yaml:"campaign"`
Date string `yaml:"date"`
Title string `yaml:"title"`
Inputs SessionInputsConfig `yaml:"inputs"`
}
// WorkspaceConfig configures local workspace behavior.
@@ -79,8 +80,8 @@ type ArchiveConfig struct {
// ArchivePromotionRule configures one artifact promotion mapping.
type ArchivePromotionRule struct {
From string `yaml:"from"`
To string `yaml:"to"`
Source string `yaml:"source"`
Dest string `yaml:"dest"`
Required *bool `yaml:"required"`
}

View File

@@ -56,6 +56,7 @@ const (
PathLogsDirSegment = "logs"
PathCurrentDirSegment = "current"
PathRunsDirSegment = "runs"
PathPreviousDirSegment = "previous"
PathManifestFile = "manifest.json"
PathLockFile = ".lock"
PathTranscriptMerged = "transcripts/merged.json"
@@ -74,8 +75,7 @@ const (
// DefaultArchivePromoteArtifacts defines the default archive promotion rules.
// Callers should copy this slice before mutating.
var DefaultArchivePromoteArtifacts = []ArchivePromotionRule{
{From: PathTranscriptTrimmed, To: PathTranscriptTrimmed},
{From: "artifacts/session_recap.md", To: "artifacts/session_recap.md"},
{Source: "narratio.transcript.trimmed", Dest: PathTranscriptTrimmed},
}
// DefaultPipelineConfigSearchPaths defines the default search order for

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
@@ -28,7 +29,8 @@ func LoadSession(path string) (*SessionConfig, error) {
// SessionLoadOptions configures session template rendering behavior.
type SessionLoadOptions struct {
SessionID string
SessionID string
PreviousSessionID string
}
// LoadSessionWithOptions loads session configuration from a YAML file with
@@ -56,6 +58,16 @@ func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfi
strings.TrimSpace(cfg.SessionID),
)
}
if strings.TrimSpace(opts.PreviousSessionID) != "" &&
strings.TrimSpace(cfg.PreviousSessionID) != "" &&
strings.TrimSpace(cfg.PreviousSessionID) != strings.TrimSpace(opts.PreviousSessionID) {
return nil, fmt.Errorf(
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match rendered previous_session_id %q",
path,
strings.TrimSpace(opts.PreviousSessionID),
strings.TrimSpace(cfg.PreviousSessionID),
)
}
return &cfg, nil
}
@@ -114,24 +126,36 @@ var sessionTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)
func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) {
sessionID := strings.TrimSpace(opts.SessionID)
previousSessionID := strings.TrimSpace(opts.PreviousSessionID)
rendered := content
if sessionID != "" {
rendered = strings.ReplaceAll(rendered, "{{session_id}}", sessionID)
rendered = strings.ReplaceAll(rendered, "{{ session_id }}", sessionID)
rendered = replaceTemplateVariable(rendered, "session_id", sessionID)
}
if previousSessionID != "" {
rendered = replaceTemplateVariable(rendered, "previous_session_id", previousSessionID)
}
unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1)
if len(unresolved) > 0 {
seenVars := map[string]struct{}{}
vars := make([]string, 0, len(unresolved))
for _, m := range unresolved {
if len(m) > 1 {
vars = append(vars, m[1])
name := m[1]
if _, ok := seenVars[name]; ok {
continue
}
seenVars[name] = struct{}{}
vars = append(vars, name)
}
}
sort.Strings(vars)
if len(vars) > 0 {
hints := unresolvedTemplateHints(vars)
return "", fmt.Errorf(
"session file template rendering failed: unresolved template variable(s): %s; pass --session-id when using {{ session_id }}",
"session file template rendering failed: unresolved template variable(s): %s%s",
strings.Join(vars, ", "),
hints,
)
}
return "", fmt.Errorf("session file template rendering failed: unresolved template placeholders remain")
@@ -140,6 +164,35 @@ func renderSessionTemplate(content string, opts SessionLoadOptions) (string, err
return rendered, nil
}
func replaceTemplateVariable(content, name, value string) string {
rendered := strings.ReplaceAll(content, "{{"+name+"}}", value)
rendered = strings.ReplaceAll(rendered, "{{ "+name+" }}", value)
return rendered
}
func unresolvedTemplateHints(vars []string) string {
seen := map[string]struct{}{}
flags := make([]string, 0, 2)
for _, name := range vars {
switch name {
case "session_id":
if _, ok := seen["--session-id"]; !ok {
seen["--session-id"] = struct{}{}
flags = append(flags, "--session-id")
}
case "previous_session_id":
if _, ok := seen["--previous-session-id"]; !ok {
seen["--previous-session-id"] = struct{}{}
flags = append(flags, "--previous-session-id")
}
}
}
if len(flags) == 0 {
return ""
}
return "; pass " + strings.Join(flags, " and ") + " when using those template variable(s)"
}
func shortName(path, fallback string) string {
base := filepath.Base(path)
if base == "." || base == string(filepath.Separator) {

View File

@@ -187,6 +187,43 @@ inputs:
`,
wantValidate: "session config \"session.yml\" invalid: session.session_id is required",
},
{
name: "valid previous_session_id passes",
pipelineYAML: `workspace:
root: /tmp/narratio
whisperx:
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
seriatim:
binary: seriatim
`,
sessionYAML: `session_id: 2026-05-03
previous_session_id: 2026-04-26
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`,
},
{
name: "previous_session_id equal to session_id fails",
pipelineYAML: `workspace:
root: /tmp/narratio
whisperx:
transcribe_url: https://transcription.ai.rakestrawhome.com/transcribe
seriatim:
binary: seriatim
`,
sessionYAML: `session_id: 2026-05-03
previous_session_id: 2026-05-03
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`,
wantValidate: "session config \"session.yml\" invalid: session.previous_session_id must not equal session.session_id",
},
{
name: "missing transcribe_url fails",
pipelineYAML: `workspace:

View File

@@ -116,6 +116,75 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
output_kind: session_recap
`,
},
{
name: "canonical previous-session source is accepted",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
transcript:
source: narratio.transcript.polished
required: true
previous_recap:
source: narratio.previous_session.artifact.session_recap
required: false
vars:
session_id: true
output_kind: session_recap
`,
},
{
name: "canonical previous-session source missing artifact key fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
previous_recap:
source: narratio.previous_session.artifact.
required: false
`,
wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact." must reference configured artifact key matching ^[a-z][a-z0-9_]*$`,
},
{
name: "canonical previous-session source invalid artifact key fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
previous_recap:
source: narratio.previous_session.artifact.session-recap
required: false
`,
wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact.session-recap" must reference configured artifact key matching ^[a-z][a-z0-9_]*$`,
},
{
name: "canonical previous-session source unknown artifact fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
inputs:
previous_recap:
source: narratio.previous_session.artifact.quest_log
required: false
`,
wantValidateErr: `pipeline.scriptorium.artifacts.session_recap.inputs.previous_recap.source "narratio.previous_session.artifact.quest_log" references unknown artifact "quest_log"`,
},
{
name: "canonical artifact source is accepted",
scriptoriumYAML: `scriptorium:

View File

@@ -55,6 +55,34 @@ inputs:
}
}
func TestLoadSessionWithOptionsRendersPreviousSessionPlaceholder(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{
SessionID: "2026-04-04",
PreviousSessionID: "2026-03-28",
})
if err != nil {
t.Fatalf("LoadSessionWithOptions() error = %v", err)
}
if cfg.PreviousSessionID != "2026-03-28" {
t.Fatalf("PreviousSessionID = %q, want 2026-03-28", cfg.PreviousSessionID)
}
}
func TestLoadSessionWithOptionsUnresolvedPlaceholderFails(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
@@ -82,6 +110,37 @@ inputs:
}
}
func TestLoadSessionWithOptionsUnresolvedPreviousSessionPlaceholderFails(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: 2026-05-03
previous_session_id: "{{ previous_session_id }}"
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "unresolved template variable") {
t.Fatalf("error = %q, want unresolved-variable context", err.Error())
}
if !strings.Contains(err.Error(), "previous_session_id") {
t.Fatalf("error = %q, want previous_session_id variable", err.Error())
}
if !strings.Contains(err.Error(), "--previous-session-id") {
t.Fatalf("error = %q, want previous-session-id guidance", err.Error())
}
}
func TestLoadSessionWithOptionsMismatchFails(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
@@ -106,6 +165,34 @@ inputs:
}
}
func TestLoadSessionWithOptionsPreviousSessionMismatchFails(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")
sessionYAML := `session_id: 2026-05-03
previous_session_id: 2026-04-26
campaign: sample-campaign
inputs:
audio_dir: ./audio
speakers_file: ./speakers.yml
autocorrect_file: ./autocorrect.yml
glossary_file: ./glossary.yml
`
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
t.Fatalf("write session.yml: %v", err)
}
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{
SessionID: "2026-05-03",
PreviousSessionID: "2026-04-25",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "previous_session_id mismatch") {
t.Fatalf("error = %q, want mismatch context", err.Error())
}
}
func TestLoadSessionWithOptionsUnknownFieldStillRejectedAfterRendering(t *testing.T) {
dir := t.TempDir()
sessionPath := filepath.Join(dir, "session.yml")

View File

@@ -136,40 +136,86 @@ func TestSpoolAndArchiveDefaults(t *testing.T) {
if cfg.Pipeline.Archive.UploadRun == nil || !*cfg.Pipeline.Archive.UploadRun {
t.Fatalf("archive.upload_run = %#v, want true", cfg.Pipeline.Archive.UploadRun)
}
if len(cfg.Pipeline.Archive.PromoteArtifacts) != 2 {
t.Fatalf("archive.promote_artifacts len = %d, want 2 defaults", len(cfg.Pipeline.Archive.PromoteArtifacts))
if len(cfg.Pipeline.Archive.PromoteArtifacts) != 1 {
t.Fatalf("archive.promote_artifacts len = %d, want 1 default", len(cfg.Pipeline.Archive.PromoteArtifacts))
}
for i, item := range cfg.Pipeline.Archive.PromoteArtifacts {
if item.Required == nil || !*item.Required {
t.Fatalf("archive.promote_artifacts[%d].required = %#v, want true", i, item.Required)
}
item := cfg.Pipeline.Archive.PromoteArtifacts[0]
if item.Required == nil || !*item.Required {
t.Fatalf("archive.promote_artifacts[0].required = %#v, want true", item.Required)
}
if item.Source != "narratio.transcript.trimmed" {
t.Fatalf("archive.promote_artifacts[0].source = %q, want narratio.transcript.trimmed", item.Source)
}
if item.Dest != "transcripts/trimmed.json" {
t.Fatalf("archive.promote_artifacts[0].dest = %q, want transcripts/trimmed.json", item.Dest)
}
}
func TestArchivePromotionPathValidation(t *testing.T) {
func TestArchivePromotionValidation(t *testing.T) {
tests := []struct {
name string
ruleYML string
wantErr string
}{
{
name: "absolute from path rejected",
name: "absolute dest path rejected",
ruleYML: `archive:
promote_artifacts:
- from: "/transcripts/trimmed.json"
to: "transcripts/trimmed.json"
- source: "narratio.transcript.trimmed"
dest: "/transcripts/trimmed.json"
`,
wantErr: "must be a relative path",
},
{
name: "traversal to path rejected",
name: "traversal dest path rejected",
ruleYML: `archive:
promote_artifacts:
- from: "transcripts/trimmed.json"
to: "../trimmed.json"
- source: "narratio.transcript.trimmed"
dest: "../trimmed.json"
`,
wantErr: "must not contain path traversal",
},
{
name: "invalid source rejected",
ruleYML: `archive:
promote_artifacts:
- source: "narratio.unknown"
dest: "transcripts/trimmed.json"
`,
wantErr: "source \"narratio.unknown\" is unsupported",
},
{
name: "duplicate destination rejected",
ruleYML: `archive:
promote_artifacts:
- source: "narratio.transcript.trimmed"
dest: "artifacts/shared.md"
- source: "narratio.transcript.full"
dest: "artifacts/shared.md"
`,
wantErr: "duplicates another archive promotion destination",
},
{
name: "configured source requires configured artifact key",
ruleYML: `archive:
promote_artifacts:
- source: "narratio.artifact.session_recap"
dest: "artifacts/session_recap.md"
`,
wantErr: "configured artifact \"session_recap\" is not defined in pipeline.scriptorium.artifacts",
},
{
name: "configured source without output path fails when dest omitted",
ruleYML: `scriptorium:
artifacts:
session_recap:
enabled: false
archive:
promote_artifacts:
- source: "narratio.artifact.session_recap"
`,
wantErr: "destination cannot be derived",
},
}
for _, tt := range tests {
@@ -189,6 +235,72 @@ func TestArchivePromotionPathValidation(t *testing.T) {
}
}
func TestArchivePromotionDerivesDestinationWhenOmitted(t *testing.T) {
tests := []struct {
name string
pipelineYML string
wantDest string
}{
{
name: "built in source derives canonical destination",
pipelineYML: testPipelineBaseYAML + `
archive:
promote_artifacts:
- source: narratio.transcript.full
`,
wantDest: "transcripts/normalized.json",
},
{
name: "configured source derives configured output path",
pipelineYML: testPipelineBaseYAML + `
scriptorium:
artifacts:
session_recap:
enabled: true
prompt_id: dnd.session_recap
output_path: artifacts/session_recap.md
archive:
promote_artifacts:
- source: narratio.artifact.session_recap
`,
wantDest: "artifacts/session_recap.md",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pipelinePath, sessionPath := writeConfigFiles(t, tt.pipelineYML, testSessionBaseYAML)
cfg, err := Load(pipelinePath, sessionPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() error = %v", err)
}
if len(cfg.Pipeline.Archive.PromoteArtifacts) != 1 {
t.Fatalf("archive.promote_artifacts len = %d, want 1", len(cfg.Pipeline.Archive.PromoteArtifacts))
}
if cfg.Pipeline.Archive.PromoteArtifacts[0].Dest != tt.wantDest {
t.Fatalf("archive.promote_artifacts[0].dest = %q, want %q", cfg.Pipeline.Archive.PromoteArtifacts[0].Dest, tt.wantDest)
}
})
}
}
func TestArchiveLegacyFromToFailsStrictDecode(t *testing.T) {
pipelineYAML := testPipelineBaseYAML + `
archive:
promote_artifacts:
- from: transcripts/trimmed.json
to: transcripts/trimmed.json
`
pipelinePath, sessionPath := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
_, err := Load(pipelinePath, sessionPath)
if err == nil || !strings.Contains(err.Error(), "strict decode failed") {
t.Fatalf("Load() error = %v, want strict decode failed", err)
}
}
func TestSessionAudioS3Validation(t *testing.T) {
tests := []struct {
name string

View File

@@ -47,7 +47,7 @@ func validatePipeline(cfg *PipelineConfig) error {
if err := validateSpool(cfg.Spool); err != nil {
return err
}
if err := validateArchive(cfg.Archive); err != nil {
if err := validateArchive(cfg.Archive, cfg.Scriptorium); err != nil {
return err
}
if err := validateWhisperX(cfg.WhisperX); err != nil {
@@ -104,28 +104,91 @@ func validateSpool(cfg SpoolConfig) error {
return nil
}
func validateArchive(cfg *ArchiveConfig) error {
func validateArchive(cfg *ArchiveConfig, scriptorium *ScriptoriumConfig) error {
if cfg == nil {
return nil
}
seenDest := map[string]struct{}{}
for i, item := range cfg.PromoteArtifacts {
prefix := fmt.Sprintf("pipeline.archive.promote_artifacts[%d]", i)
if strings.TrimSpace(item.From) == "" {
return fmt.Errorf("%s.from is required", prefix)
source := strings.TrimSpace(item.Source)
if source == "" {
return fmt.Errorf("%s.source is required", prefix)
}
if strings.TrimSpace(item.To) == "" {
return fmt.Errorf("%s.to is required", prefix)
if _, err := archiveSourceKnown(source, scriptorium); err != nil {
return fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err)
}
if err := validateRelativeSafePath(prefix+".from", item.From); err != nil {
dest := strings.TrimSpace(item.Dest)
if dest == "" {
derivedDest, err := deriveArchivePromotionDest(source, scriptorium)
if err != nil {
return fmt.Errorf("%s.dest is required when destination cannot be derived from %q: %w", prefix, source, err)
}
dest = derivedDest
cfg.PromoteArtifacts[i].Dest = derivedDest
}
if err := validateRelativeSafePath(prefix+".dest", dest); err != nil {
return err
}
if err := validateRelativeSafePath(prefix+".to", item.To); err != nil {
return err
normalizedDest := filepath.ToSlash(filepath.Clean(dest))
if _, ok := seenDest[normalizedDest]; ok {
return fmt.Errorf("%s.dest %q duplicates another archive promotion destination", prefix, dest)
}
seenDest[normalizedDest] = struct{}{}
}
return nil
}
func archiveSourceKnown(source string, scriptorium *ScriptoriumConfig) (string, error) {
trimmed := strings.TrimSpace(source)
switch trimmed {
case "narratio.transcript.merged",
"narratio.transcript.polished",
"narratio.transcript.full",
"narratio.transcript.trimmed",
"narratio.bounds.session":
return "", nil
}
matches := narratioArtifactSourceRE.FindStringSubmatch(trimmed)
if len(matches) != 2 {
return "", fmt.Errorf("must be a built-in source id or narratio.artifact.<name>")
}
artifactKey := matches[1]
if scriptorium == nil || len(scriptorium.Artifacts) == 0 {
return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", artifactKey)
}
if _, ok := scriptorium.Artifacts[artifactKey]; !ok {
return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", artifactKey)
}
return artifactKey, nil
}
func deriveArchivePromotionDest(source string, scriptorium *ScriptoriumConfig) (string, error) {
trimmed := strings.TrimSpace(source)
switch trimmed {
case "narratio.transcript.merged":
return PathTranscriptMerged, nil
case "narratio.transcript.polished":
return PathTranscriptProcessed, nil
case "narratio.transcript.full":
return PathTranscriptNormalized, nil
case "narratio.transcript.trimmed":
return PathTranscriptTrimmed, nil
case "narratio.bounds.session":
return filepath.ToSlash(filepath.Join(PathArtifactsDirSegment, "session_bounds.json")), nil
}
artifactKey, err := archiveSourceKnown(trimmed, scriptorium)
if err != nil {
return "", err
}
artifactCfg := scriptorium.Artifacts[artifactKey]
outputPath := strings.TrimSpace(artifactCfg.OutputPath)
if outputPath == "" {
return "", fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is empty", artifactKey)
}
return outputPath, nil
}
func validateSecrets(cfg *SecretsConfig) error {
if cfg == nil {
return nil
@@ -424,8 +487,14 @@ func validateScriptorium(cfg *ScriptoriumConfig) error {
}
func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.SessionID) == "" {
return fmt.Errorf("session.session_id is required")
if err := validateSessionIdentifier("session.session_id", cfg.SessionID, true); err != nil {
return err
}
if err := validateSessionIdentifier("session.previous_session_id", cfg.PreviousSessionID, false); err != nil {
return err
}
if strings.TrimSpace(cfg.PreviousSessionID) != "" && strings.TrimSpace(cfg.PreviousSessionID) == strings.TrimSpace(cfg.SessionID) {
return fmt.Errorf("session.previous_session_id must not equal session.session_id")
}
if strings.TrimSpace(cfg.Campaign) == "" {
return fmt.Errorf("session.campaign is required")
@@ -462,6 +531,16 @@ func validateSession(cfg *SessionConfig) error {
return nil
}
func validateSessionIdentifier(fieldName, value string, required bool) error {
if strings.TrimSpace(value) == "" {
if required {
return fmt.Errorf("%s is required", fieldName)
}
return nil
}
return nil
}
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error {
if pipeline == nil || session == nil {
return nil
@@ -500,12 +579,38 @@ var windowsAbsPathRE = regexp.MustCompile(`^[A-Za-z]:[\\/].*`)
var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
var narratioArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
var narratioPreviousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) {
if isStaticSupportedScriptoriumInputSource(source) {
trimmedSource := strings.TrimSpace(source)
if isStaticSupportedScriptoriumInputSource(trimmedSource) {
return "", nil
}
matches := narratioArtifactSourceRE.FindStringSubmatch(source)
if strings.HasPrefix(trimmedSource, "narratio.previous_session.artifact") {
matches := narratioPreviousSessionArtifactSourceRE.FindStringSubmatch(trimmedSource)
if len(matches) != 2 {
return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$",
artifactName,
inputName,
source,
)
}
referenced := matches[1]
if _, ok := configuredArtifacts[referenced]; !ok {
return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
artifactName,
inputName,
source,
referenced,
)
}
return "", nil
}
matches := narratioArtifactSourceRE.FindStringSubmatch(trimmedSource)
if len(matches) != 2 {
return "", fmt.Errorf(
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
@@ -528,7 +633,7 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
}
func isStaticSupportedScriptoriumInputSource(source string) bool {
switch strings.TrimSpace(source) {
switch source {
case "previous_session_artifact":
return true
case "narratio.transcript.merged":

View File

@@ -631,6 +631,23 @@ func resolveScriptoriumInput(
runtimeCatalog *artifacts.ArtifactCatalog,
) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
source := strings.TrimSpace(inputCfg.Source)
if artifacts.IsPreviousSessionArtifactSource(source) {
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
if err == nil {
copy := resolved
return resolved.Path, true, &copy, nil
}
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
if inputCfg.Required {
return "", false, nil, fmt.Errorf(
"required previous-session input source %q is unavailable; run narratio run-stage --force prepare",
source,
)
}
return "", false, nil, nil
}
return "", false, nil, err
}
switch source {
case "previous_session_artifact":
if strings.TrimSpace(inputCfg.Path) == "" {

View File

@@ -10,6 +10,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
@@ -717,6 +718,146 @@ func TestAnalyzeOmitsOptionalMissingConfiguredArtifactInput(t *testing.T) {
}
}
func TestAnalyzeRequiredPreviousSessionArtifactInputGuidesPrepareForce(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
sessionRecap.Inputs["previous_recap"] = config.ScriptoriumInputConfig{
Source: "narratio.previous_session.artifact.session_recap",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "run narratio run-stage --force prepare") {
t.Fatalf("error = %q, want guidance to run force prepare", err.Error())
}
}
func TestAnalyzeResolvesCanonicalPreviousSessionArtifactFromManifestInput(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`)
previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousPath, "previous recap\n")
m.Inputs = append(m.Inputs, manifest.InputRecord{
Kind: "previous_artifact",
Path: previousPath,
})
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
sessionRecap.Inputs["previous_recap"] = config.ScriptoriumInputConfig{
Source: "narratio.previous_session.artifact.session_recap",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
_, 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 got := fake.RunRequests[0].InputPaths["previous_recap"]; got != previousPath {
t.Fatalf("previous_recap input = %q, want %q", got, previousPath)
}
}
func TestAnalyzeOmitsOptionalMissingCanonicalPreviousSessionArtifact(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`)
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
sessionRecap.Inputs["previous_recap"] = config.ScriptoriumInputConfig{
Source: "narratio.previous_session.artifact.session_recap",
Required: false,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
_, 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 _, exists := fake.RunRequests[0].InputPaths["previous_recap"]; exists {
t.Fatalf("optional canonical previous_recap should be omitted when unavailable")
}
}
func TestAnalyzeRenderDebugWithCanonicalPreviousSessionInput(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.RenderDebug = true
previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousPath, "previous recap\n")
m.Inputs = append(m.Inputs, manifest.InputRecord{
Kind: "previous_artifact",
Path: previousPath,
})
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
sessionRecap.Inputs["previous_recap"] = config.ScriptoriumInputConfig{
Source: "narratio.previous_session.artifact.session_recap",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(fake.RenderRequests) != 1 || len(fake.RunRequests) != 1 {
t.Fatalf("render/run requests = %d/%d, want 1/1", len(fake.RenderRequests), len(fake.RunRequests))
}
if got := fake.RenderRequests[0].InputPaths["previous_recap"]; got != previousPath {
t.Fatalf("render previous_recap input = %q, want %q", got, previousPath)
}
if got := fake.RunRequests[0].InputPaths["previous_recap"]; got != previousPath {
t.Fatalf("run previous_recap input = %q, want %q", got, previousPath)
}
}
func TestAnalyzeDoesNotCallObjectStoreForCanonicalPreviousSessionInput(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "trimmed.json"), `{"segments":[]}`)
previousPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
writeAnalyzeFile(t, previousPath, "previous recap\n")
m.Inputs = append(m.Inputs, manifest.InputRecord{
Kind: "previous_artifact",
Path: previousPath,
})
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
sessionRecap.Inputs["previous_recap"] = config.ScriptoriumInputConfig{
Source: "narratio.previous_session.artifact.session_recap",
Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
tracker := &analyzeObjectStoreTracker{}
env.ObjectStore = tracker
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if tracker.called {
t.Fatal("analyze should not call object store for previous-session input resolution")
}
}
func TestAnalyzeFailsWhenOutputPathMissing(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
@@ -1145,3 +1286,27 @@ func mustArtifactEntryList(t *testing.T, metadata map[string]any, key string) []
}
return out
}
type analyzeObjectStoreTracker struct {
called bool
}
func (s *analyzeObjectStoreTracker) List(context.Context, string) ([]storage.ObjectInfo, error) {
s.called = true
return nil, errors.New("unexpected object store list call")
}
func (s *analyzeObjectStoreTracker) Download(context.Context, string, string) error {
s.called = true
return errors.New("unexpected object store download call")
}
func (s *analyzeObjectStoreTracker) Upload(context.Context, string, string, storage.UploadOptions) (storage.ObjectInfo, error) {
s.called = true
return storage.ObjectInfo{}, errors.New("unexpected object store upload call")
}
func (s *analyzeObjectStoreTracker) Exists(context.Context, string) (bool, error) {
s.called = true
return false, errors.New("unexpected object store exists call")
}

View File

@@ -3,6 +3,7 @@ package stage
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
@@ -90,15 +91,15 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("archive: run root %q is not a directory", runRoot)
}
runPrefix, err := archiveRunPrefix(env, m)
runPrefix, err := artifacts.ResolveArchiveRunPrefix(env.Config, m)
if err != nil {
return nil, fmt.Errorf("archive: resolve s3 run prefix: %w", err)
}
sessionPrefix, err := archiveSessionPrefix(env, m)
sessionPrefix, err := artifacts.ResolveArchiveSessionPrefix(env.Config, m)
if err != nil {
return nil, fmt.Errorf("archive: resolve s3 session prefix: %w", err)
}
bucket := archiveBucket(env, m)
bucket := artifacts.ResolveArchiveBucket(env.Config, m)
if bucket == "" {
return nil, fmt.Errorf("archive: resolve s3 bucket: bucket is required")
}
@@ -116,11 +117,16 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil {
return nil, fmt.Errorf("archive: collect run files: %w", err)
}
sessionRoot, err := resolveArchiveSessionRoot(env, m)
sessionPaths := archiveSessionPaths(env, m)
previousFiles, err := collectArchivePreviousFiles(sessionPaths.PreviousDir)
if err != nil {
return nil, fmt.Errorf("archive: resolve session root for promotions: %w", err)
return nil, fmt.Errorf("archive: collect previous files: %w", err)
}
promotions, err := resolveArchivePromotions(sessionRoot, env.Config.Pipeline.Archive.PromoteArtifacts)
runtimeCatalog, err := buildArchiveRuntimeArtifactCatalog(sessionPaths, env.Config.Pipeline.Scriptorium)
if err != nil {
return nil, fmt.Errorf("archive: build runtime artifact catalog: %w", err)
}
promotions, skippedOptional, err := resolveArchivePromotions(sessionPaths, m, runtimeCatalog, env.Config.Pipeline.Archive.PromoteArtifacts)
if err != nil {
return nil, fmt.Errorf("archive: resolve promotion rules: %w", err)
}
@@ -134,29 +140,31 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
}
promotedUploaded := make([]string, 0, len(promotions))
skippedOptional := make([]string, 0)
for _, promotion := range promotions {
if !promotion.Exists {
if promotion.Required {
return nil, fmt.Errorf("archive: required promotion source missing: %q", promotion.From)
}
skippedOptional = append(skippedOptional, promotion.To)
continue
}
key := artifacts.S3PromotedArtifactKey(sessionPrefix, promotion.To)
key := artifacts.S3PromotedArtifactKey(sessionPrefix, promotion.Dest)
if _, err := env.ObjectStore.Upload(ctx, promotion.LocalPath, key, storage.UploadOptions{}); err != nil {
return nil, fmt.Errorf("archive: upload promoted output %q to %q: %w", promotion.From, key, err)
return nil, fmt.Errorf("archive: upload promoted output source %q to %q: %w", promotion.Source, key, err)
}
promotedUploaded = append(promotedUploaded, promotion.To)
promotedUploaded = append(promotedUploaded, promotion.Dest)
}
currentManifestKey := artifacts.S3CurrentManifestKey(sessionPrefix)
previousUploaded := make([]string, 0, len(previousFiles))
for _, file := range previousFiles {
key := artifacts.S3PromotedArtifactKey(sessionPrefix, file.RelativePath)
if _, err := env.ObjectStore.Upload(ctx, file.LocalPath, key, storage.UploadOptions{}); err != nil {
return nil, fmt.Errorf("archive: upload previous file %q to %q: %w", file.RelativePath, key, err)
}
previousUploaded = append(previousUploaded, file.RelativePath)
}
currentManifestKey, currentRunPointerKey := artifacts.ResolveArchiveCurrentStateKeys(sessionPrefix)
manifestTempPath, err := writeCurrentManifestSnapshot(m, archiveMetadataPreview(
bucket,
runPrefix,
sessionPrefix,
runUploaded,
promotedUploaded,
previousUploaded,
skippedOptional,
currentManifestKey,
))
@@ -171,7 +179,6 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("archive: upload current manifest to %q: %w", currentManifestKey, err)
}
currentRunPointerKey := artifacts.S3CurrentRunPointerKey(sessionPrefix)
runIDTempPath, err := writeCurrentRunIDPointer(runID)
if err != nil {
return nil, fmt.Errorf("archive: build current run id pointer: %w", err)
@@ -194,6 +201,8 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
"run_uploaded_paths": runUploaded,
"promoted_files_uploaded": len(promotedUploaded),
"promoted_paths": promotedUploaded,
"previous_files_uploaded": len(previousUploaded),
"previous_uploaded_paths": previousUploaded,
"skipped_optional_promotions": skippedOptional,
"current_manifest_key": currentManifestKey,
"current_run_id_key": currentRunPointerKey,
@@ -204,11 +213,11 @@ func (archiveStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
}
type archivePromotion struct {
From string
To string
Required bool
LocalPath string
Exists bool
Source string
Dest string
Required bool
LocalPath string
Provenance string
}
func archiveDisabled(env *Env) bool {
@@ -292,105 +301,151 @@ func resolveArchiveSessionRoot(env *Env, m *manifest.Manifest) (string, error) {
return filepath.Clean(artifacts.SessionWorkDirForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID)), nil
}
func archiveRunPrefix(env *Env, m *manifest.Manifest) (string, error) {
runPrefix := strings.TrimSpace(m.S3RunPrefix)
if runPrefix != "" {
return runPrefix, nil
}
sessionPrefix, err := archiveSessionPrefix(env, m)
if err != nil {
return "", err
}
runID := strings.TrimSpace(m.RunID)
if runID == "" {
return "", fmt.Errorf("run id is required")
}
return artifacts.S3RunPrefix(sessionPrefix, runID), nil
}
func archiveSessionPrefix(env *Env, m *manifest.Manifest) (string, error) {
if m != nil && strings.TrimSpace(m.S3SessionPrefix) != "" {
return strings.TrimSpace(m.S3SessionPrefix), nil
}
func archiveSessionPaths(env *Env, m *manifest.Manifest) artifacts.SessionPaths {
sessionID := strings.TrimSpace(env.Config.Session.SessionID)
if sessionID == "" {
if sessionID == "" && m != nil {
sessionID = strings.TrimSpace(m.SessionID)
}
campaign := strings.TrimSpace(env.Config.Session.Campaign)
if campaign == "" {
if campaign == "" && m != nil {
campaign = strings.TrimSpace(m.Campaign)
}
if env.Config.Pipeline.Storage.S3 == nil {
return "", fmt.Errorf("pipeline.storage.s3 configuration is required")
}
sessionPrefix := artifacts.S3SessionPrefix(env.Config.Pipeline.Storage.S3.RootPrefix, campaign, sessionID)
if strings.TrimSpace(sessionPrefix) == "" {
return "", fmt.Errorf("session prefix is required")
}
return sessionPrefix, nil
store := artifacts.NewLocalStore(env.Config.Pipeline.Workspace.Root)
return store.SessionPathsFor(campaign, sessionID)
}
func archiveBucket(env *Env, m *manifest.Manifest) string {
if m != nil && strings.TrimSpace(m.S3Bucket) != "" {
return strings.TrimSpace(m.S3Bucket)
}
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Pipeline.Storage.S3 == nil {
return ""
}
return strings.TrimSpace(env.Config.Pipeline.Storage.S3.Bucket)
}
func resolveArchivePromotions(sessionRoot string, rules []config.ArchivePromotionRule) ([]archivePromotion, error) {
sessionRoot = filepath.Clean(strings.TrimSpace(sessionRoot))
if sessionRoot == "" {
return nil, fmt.Errorf("session root is required")
}
func resolveArchivePromotions(
paths artifacts.SessionPaths,
m *manifest.Manifest,
catalog *artifacts.ArtifactCatalog,
rules []config.ArchivePromotionRule,
) ([]archivePromotion, []string, error) {
out := make([]archivePromotion, 0, len(rules))
skippedOptional := make([]string, 0)
for _, rule := range rules {
from := strings.TrimSpace(rule.From)
to := strings.TrimSpace(rule.To)
source := strings.TrimSpace(rule.Source)
required := rule.Required == nil || *rule.Required
resolvedPath, err := resolveWorkDirRelativePath(sessionRoot, from)
dest, err := resolveArchivePromotionDest(rule, catalog)
if err != nil {
return nil, fmt.Errorf("promotion from %q: %w", from, err)
return nil, nil, fmt.Errorf("source %q: %w", source, err)
}
info, err := os.Stat(resolvedPath)
exists := err == nil && !info.IsDir()
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("promotion source %q: %w", from, err)
resolved, err := artifacts.ResolveSessionArtifactWithCatalog(paths, m, source, catalog)
if err != nil {
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) && !required {
skippedOptional = append(skippedOptional, dest)
continue
}
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
return nil, nil, fmt.Errorf("required promotion source unavailable: %q", source)
}
return nil, nil, fmt.Errorf("resolve source %q: %w", source, err)
}
localPath := resolvedPath
out = append(out, archivePromotion{
From: from,
To: to,
Required: required,
LocalPath: localPath,
Exists: exists,
Source: source,
Dest: dest,
Required: required,
LocalPath: resolved.Path,
Provenance: resolved.Provenance,
})
}
return out, nil
return out, skippedOptional, nil
}
func resolveWorkDirRelativePath(workDir, rel string) (string, error) {
rel = filepath.Clean(filepath.FromSlash(strings.TrimSpace(rel)))
if rel == "." || rel == "" {
func resolveArchivePromotionDest(rule config.ArchivePromotionRule, catalog *artifacts.ArtifactCatalog) (string, error) {
dest := strings.TrimSpace(rule.Dest)
if dest == "" {
entry, ok := catalog.Lookup(strings.TrimSpace(rule.Source))
if !ok {
return "", fmt.Errorf("destination omitted and source is unknown")
}
dest = strings.TrimSpace(entry.CanonicalRelPath)
if dest == "" {
return "", fmt.Errorf("destination omitted and no canonical destination is available")
}
}
return normalizeArchiveRelativePath(dest)
}
func normalizeArchiveRelativePath(rel string) (string, error) {
trimmed := strings.TrimSpace(rel)
if trimmed == "" {
return "", fmt.Errorf("relative path is required")
}
full := filepath.Join(workDir, rel)
cleanedWork := filepath.Clean(workDir)
cleanedFull := filepath.Clean(full)
relative, err := filepath.Rel(cleanedWork, cleanedFull)
if err != nil {
return "", fmt.Errorf("compute relative path: %w", err)
cleaned := filepath.ToSlash(filepath.Clean(filepath.FromSlash(trimmed)))
if cleaned == "." || cleaned == "" {
return "", fmt.Errorf("relative path is required")
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes workdir")
if filepath.IsAbs(trimmed) || strings.HasPrefix(cleaned, "/") || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("path must be a clean relative path")
}
return cleanedFull, nil
return cleaned, nil
}
func buildArchiveRuntimeArtifactCatalog(
paths artifacts.SessionPaths,
scriptoriumCfg *config.ScriptoriumConfig,
) (*artifacts.ArtifactCatalog, error) {
catalog := artifacts.NewArtifactCatalog()
if err := catalog.RegisterBuiltIns(); err != nil {
return nil, err
}
if scriptoriumCfg == nil {
return catalog, nil
}
configured := map[string]artifacts.ConfiguredArtifactDefinition{}
for key, artifactCfg := range scriptoriumCfg.Artifacts {
configured[key] = artifacts.ConfiguredArtifactDefinition{
Enabled: artifactCfg.Enabled,
OutputPath: artifactCfg.OutputPath,
}
}
if err := catalog.RegisterConfiguredArtifacts(configured, nil); err != nil {
return nil, err
}
for _, entry := range catalog.ListConfigured() {
if strings.TrimSpace(entry.CanonicalRelPath) == "" {
continue
}
localPath, err := resolveConfiguredArtifactLocalPath(paths, entry.CanonicalRelPath)
if err != nil {
continue
}
info, statErr := os.Stat(localPath)
if statErr != nil {
if os.IsNotExist(statErr) {
continue
}
return nil, fmt.Errorf("stat configured artifact %q: %w", entry.SourceID, statErr)
}
if info.IsDir() {
continue
}
if err := catalog.MarkAvailableFromDisk(entry.SourceID, localPath); err != nil {
return nil, err
}
}
return catalog, nil
}
func resolveConfiguredArtifactLocalPath(paths artifacts.SessionPaths, configured string) (string, error) {
outputPath := strings.TrimSpace(configured)
if outputPath == "" {
return "", fmt.Errorf("configured artifact output path is required")
}
if filepath.IsAbs(outputPath) {
return filepath.Clean(outputPath), nil
}
rel := filepath.Clean(outputPath)
if rel == "." || rel == "" {
return "", fmt.Errorf("relative output path is required")
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("relative output path escapes session root: %q", configured)
}
return filepath.Join(paths.Root, rel), nil
}
func collectArchiveRunFiles(runRoot, manifestPath string) ([]archiveUploadFile, error) {
@@ -458,6 +513,51 @@ func collectArchiveRunFiles(runRoot, manifestPath string) ([]archiveUploadFile,
return files, nil
}
func collectArchivePreviousFiles(previousDir string) ([]archiveUploadFile, error) {
previousDir = filepath.Clean(strings.TrimSpace(previousDir))
if previousDir == "" {
return nil, fmt.Errorf("previous directory is required")
}
info, err := os.Stat(previousDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("stat %q: %w", previousDir, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("previous path %q is not a directory", previousDir)
}
files := make([]archiveUploadFile, 0, 16)
err = filepath.WalkDir(previousDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(previousDir, path)
if err != nil {
return fmt.Errorf("relative path from %q to %q: %w", previousDir, path, err)
}
rel = filepath.ToSlash(rel)
files = append(files, archiveUploadFile{
RelativePath: filepath.ToSlash(filepath.Join(config.PathPreviousDirSegment, rel)),
LocalPath: path,
})
return nil
})
if err != nil {
return nil, fmt.Errorf("walk %q: %w", previousDir, err)
}
sort.Slice(files, func(i, j int) bool {
return files[i].RelativePath < files[j].RelativePath
})
return files, nil
}
func resolveArchiveRunManifestSource(runRoot string) (string, error) {
path := filepath.Join(filepath.Clean(runRoot), "manifest.json")
info, err := os.Stat(path)
@@ -561,6 +661,7 @@ func archiveMetadataPreview(
bucket, runPrefix, sessionPrefix string,
runUploaded []string,
promotedUploaded []string,
previousUploaded []string,
skippedOptional []string,
currentManifestKey string,
) map[string]any {
@@ -573,6 +674,8 @@ func archiveMetadataPreview(
"run_uploaded_paths": append([]string(nil), runUploaded...),
"promoted_files_uploaded": len(promotedUploaded),
"promoted_paths": append([]string(nil), promotedUploaded...),
"previous_files_uploaded": len(previousUploaded),
"previous_uploaded_paths": append([]string(nil), previousUploaded...),
"skipped_optional_promotions": append([]string(nil), skippedOptional...),
"current_manifest_key": currentManifestKey,
"current_run_id_key": artifacts.S3CurrentRunPointerKey(sessionPrefix),

View File

@@ -130,13 +130,57 @@ func TestArchiveUploadsRunRecordPromotionsAndCurrentPointer(t *testing.T) {
if result.Metadata["promoted_files_uploaded"] != 2 {
t.Fatalf("metadata promoted_files_uploaded = %#v, want 2", result.Metadata["promoted_files_uploaded"])
}
if result.Metadata["previous_files_uploaded"] != 0 {
t.Fatalf("metadata previous_files_uploaded = %#v, want 0", result.Metadata["previous_files_uploaded"])
}
}
func TestArchiveUploadsPreviousCacheWhenPresent(t *testing.T) {
env, m, _ := archiveFixture(t)
fake := env.ObjectStore.(*storage.FakeBackend)
sessionRoot := artifacts.SessionWorkDirForCampaign(
env.Config.Pipeline.Workspace.Root,
env.Config.Session.Campaign,
env.Config.Session.SessionID,
)
writeStageTestFile(t, filepath.Join(sessionRoot, "previous", "manifest.json"), "{\"session_id\":\"2026-04-12\"}\n")
writeStageTestFile(t, filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md"), "# previous recap\n")
result, err := archiveStage{}.Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
previousManifestKey := m.S3SessionPrefix + "previous/manifest.json"
previousRecapKey := m.S3SessionPrefix + "previous/artifacts/session_recap.md"
if _, ok := fake.Objects[previousManifestKey]; !ok {
t.Fatalf("missing archived previous manifest key %q", previousManifestKey)
}
if _, ok := fake.Objects[previousRecapKey]; !ok {
t.Fatalf("missing archived previous artifact key %q", previousRecapKey)
}
if result.Metadata["previous_files_uploaded"] != 2 {
t.Fatalf("metadata previous_files_uploaded = %#v, want 2", result.Metadata["previous_files_uploaded"])
}
}
func TestArchiveToleratesMissingPreviousCache(t *testing.T) {
env, m, _ := archiveFixture(t)
result, err := archiveStage{}.Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if result.Metadata["previous_files_uploaded"] != 0 {
t.Fatalf("metadata previous_files_uploaded = %#v, want 0", result.Metadata["previous_files_uploaded"])
}
}
func TestArchiveUsesCustomPromotionRules(t *testing.T) {
env, m, _ := archiveFixture(t)
env.Config.Pipeline.Archive.PromoteArtifacts = []config.ArchivePromotionRule{
{From: "transcripts/trimmed.json", To: "published/trimmed.json", Required: boolPtr(true)},
{From: "artifacts/session_recap.md", To: "published/recap.md", Required: boolPtr(true)},
{Source: "narratio.transcript.trimmed", Dest: "published/trimmed.json", Required: boolPtr(true)},
{Source: "narratio.artifact.session_recap", Dest: "published/recap.md", Required: boolPtr(true)},
}
_, err := archiveStage{}.Run(context.Background(), env, m)
@@ -156,8 +200,8 @@ func TestArchiveUsesCustomPromotionRules(t *testing.T) {
func TestArchiveSkipsOptionalMissingPromotion(t *testing.T) {
env, m, _ := archiveFixture(t)
env.Config.Pipeline.Archive.PromoteArtifacts = []config.ArchivePromotionRule{
{From: "transcripts/trimmed.json", To: "transcripts/trimmed.json", Required: boolPtr(true)},
{From: "artifacts/optional.md", To: "artifacts/optional.md", Required: boolPtr(false)},
{Source: "narratio.transcript.trimmed", Dest: "transcripts/trimmed.json", Required: boolPtr(true)},
{Source: "narratio.transcript.merged", Dest: "transcripts/merged.json", Required: boolPtr(false)},
}
result, err := archiveStage{}.Run(context.Background(), env, m)
@@ -165,7 +209,7 @@ func TestArchiveSkipsOptionalMissingPromotion(t *testing.T) {
t.Fatalf("Run() error = %v", err)
}
got, _ := result.Metadata["skipped_optional_promotions"].([]string)
want := []string{"artifacts/optional.md"}
want := []string{"transcripts/merged.json"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("skipped_optional_promotions = %#v, want %#v", got, want)
}
@@ -174,12 +218,12 @@ func TestArchiveSkipsOptionalMissingPromotion(t *testing.T) {
func TestArchiveFailsWhenRequiredPromotionMissing(t *testing.T) {
env, m, _ := archiveFixture(t)
env.Config.Pipeline.Archive.PromoteArtifacts = []config.ArchivePromotionRule{
{From: "artifacts/missing.md", To: "artifacts/missing.md", Required: boolPtr(true)},
{Source: "narratio.transcript.merged", Dest: "transcripts/merged.json", Required: boolPtr(true)},
}
_, err := archiveStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "required promotion source missing") {
t.Fatalf("Run() error = %v, want required promotion missing failure", err)
if err == nil || !strings.Contains(err.Error(), "required promotion source unavailable") {
t.Fatalf("Run() error = %v, want required promotion source unavailable failure", err)
}
}
@@ -259,13 +303,13 @@ func archiveFixture(t *testing.T) (*Env, *manifest.Manifest, string) {
sessionRoot := artifacts.SessionWorkDirForCampaign(root, campaign, sessionID)
runRoot := artifacts.SessionRunRootForCampaign(root, campaign, sessionID, runID)
writeStageTestFile(t, filepath.Join(sessionRoot, "transcripts", "trimmed.json"), "{}\n")
writeStageTestFile(t, filepath.Join(sessionRoot, "transcripts", "trimmed.json"), "{\"segments\":[]}\n")
writeStageTestFile(t, filepath.Join(sessionRoot, "artifacts", "session_recap.md"), "# recap\n")
writeStageTestFile(t, filepath.Join(runRoot, "prepare", "inputs", "session.yml"), "session_id: 2026-04-19\n")
writeStageTestFile(t, filepath.Join(runRoot, "prepare", "outputs", "audio", "speaker.flac"), "flac\n")
writeStageTestFile(t, filepath.Join(runRoot, "transcribe", "outputs", "transcripts", "raw", "speaker.json"), "{}\n")
writeStageTestFile(t, filepath.Join(runRoot, "trim", "outputs", "transcripts", "trimmed.json"), "{}\n")
writeStageTestFile(t, filepath.Join(runRoot, "trim", "outputs", "transcripts", "trimmed.json"), "{\"segments\":[]}\n")
writeStageTestFile(t, filepath.Join(runRoot, "analyze", "outputs", "artifacts", "session_recap.md"), "# recap\n")
writeStageTestFile(t, filepath.Join(runRoot, "polish", "reports", "audita.report.json"), "{}\n")
writeStageTestFile(t, filepath.Join(runRoot, "merge", "config", "seriatim.generated.yml"), "key: value\n")
@@ -298,8 +342,17 @@ func archiveFixture(t *testing.T) (*Env, *manifest.Manifest, string) {
Enabled: boolPtr(true),
UploadRun: boolPtr(true),
PromoteArtifacts: []config.ArchivePromotionRule{
{From: "transcripts/trimmed.json", To: "transcripts/trimmed.json", Required: boolPtr(true)},
{From: "artifacts/session_recap.md", To: "artifacts/session_recap.md", Required: boolPtr(true)},
{Source: "narratio.transcript.trimmed", Dest: "transcripts/trimmed.json", Required: boolPtr(true)},
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
},
},
Scriptorium: &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
PromptID: "dnd.session_recap",
OutputPath: "artifacts/session_recap.md",
},
},
},
},

View File

@@ -145,6 +145,20 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
}
}
previousRequirements := collectPreparePreviousRequirements(env.Config)
var previousHydration *previousSessionHydrationResult
if len(previousRequirements) > 0 {
if err := clearManagedPreviousState(paths); err != nil {
return nil, fmt.Errorf("prepare: clear previous-session cache: %w", err)
}
hydration, err := hydratePreviousSessionArtifacts(ctx, env, paths, previousRequirements)
if err != nil {
return nil, fmt.Errorf("prepare: hydrate previous-session artifacts: %w", err)
}
previousHydration = hydration
inputs = append(inputs, hydration.Inputs...)
}
sort.Slice(inputs, func(i, j int) bool {
if inputs[i].Kind != inputs[j].Kind {
return inputs[i].Kind < inputs[j].Kind
@@ -153,13 +167,27 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
})
m.Inputs = inputs
metadata := map[string]any{
"prepared": true,
"stage": "prepare",
"inputs_count": len(inputs),
"audio_files_resolved": countAudioInputs(inputs),
}
if len(previousRequirements) > 0 {
metadata["previous_requirements_count"] = len(previousRequirements)
if previousHydration != nil {
metadata["previous_artifacts_hydrated"] = append([]string(nil), previousHydration.Hydrated...)
metadata["previous_artifacts_hydrated_count"] = len(previousHydration.Hydrated)
metadata["previous_artifacts_missing_optional"] = append([]string(nil), previousHydration.SkippedMissing...)
metadata["previous_artifacts_missing_optional_count"] = len(previousHydration.SkippedMissing)
if strings.TrimSpace(previousHydration.PreviousRunID) != "" {
metadata["previous_session_run_id"] = strings.TrimSpace(previousHydration.PreviousRunID)
}
}
}
return &StageResult{
Metadata: map[string]any{
"prepared": true,
"stage": "prepare",
"inputs_count": len(inputs),
"audio_files_resolved": countAudioInputs(inputs),
},
Metadata: metadata,
}, nil
}
@@ -354,6 +382,35 @@ func countAudioInputs(inputs []manifest.InputRecord) int {
return count
}
func collectPreparePreviousRequirements(cfg *config.Config) []artifacts.PreviousArtifactRequirement {
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Scriptorium == nil {
return nil
}
return artifacts.CollectPreviousArtifactRequirements(cfg.Pipeline.Scriptorium.Artifacts)
}
func clearManagedPreviousState(paths artifacts.SessionPaths) error {
previousDir := filepath.Clean(paths.PreviousDir)
sessionRoot := filepath.Clean(paths.Root)
if strings.TrimSpace(previousDir) == "" || strings.TrimSpace(sessionRoot) == "" {
return fmt.Errorf("previous/session root paths are required")
}
if previousDir == sessionRoot {
return fmt.Errorf("refusing to clear session root as previous cache: %q", previousDir)
}
prefix := sessionRoot + string(filepath.Separator)
if !strings.HasPrefix(previousDir, prefix) {
return fmt.Errorf("refusing to clear path outside session root: %q", previousDir)
}
if filepath.Base(previousDir) != config.PathPreviousDirSegment {
return fmt.Errorf("refusing to clear non-previous path %q", previousDir)
}
if err := os.RemoveAll(previousDir); err != nil {
return err
}
return os.MkdirAll(previousDir, 0o755)
}
func pathsWorkDirForManifest(env *Env, m *manifest.Manifest, sessionID string) string {
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil {
return ""

View File

@@ -0,0 +1,475 @@
package stage
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
const (
preparePreviousInputKindManifest = "previous_manifest"
preparePreviousInputKindArtifact = "previous_artifact"
preparePreviousInputSource = "previous_session_archive.current"
)
// previousSessionHydrationResult captures prepare-time previous-session cache materialization.
type previousSessionHydrationResult struct {
Inputs []manifest.InputRecord
Hydrated []string
SkippedMissing []string
PreviousRunID string
}
func hydratePreviousSessionArtifacts(
ctx context.Context,
env *Env,
paths artifacts.SessionPaths,
requirements []artifacts.PreviousArtifactRequirement,
) (*previousSessionHydrationResult, error) {
if len(requirements) == 0 {
return &previousSessionHydrationResult{}, nil
}
if env == nil || env.Config == nil || env.Config.Session == nil || env.Config.Pipeline == nil {
return nil, fmt.Errorf("resolved config with session/pipeline is required")
}
if env.ArtifactStore == nil {
return nil, fmt.Errorf("artifact store is required")
}
orderedRequirements := append([]artifacts.PreviousArtifactRequirement(nil), requirements...)
sort.Slice(orderedRequirements, func(i, j int) bool {
return orderedRequirements[i].Name < orderedRequirements[j].Name
})
requiredNames := requiredPreviousArtifactNames(orderedRequirements)
optionalNames := optionalPreviousArtifactNames(orderedRequirements)
previousSessionID := strings.TrimSpace(env.Config.Session.PreviousSessionID)
if previousSessionID == "" {
if len(requiredNames) > 0 {
return nil, fmt.Errorf(
"previous_session_id is required for required previous-session artifacts: %s",
strings.Join(requiredNames, ", "),
)
}
return &previousSessionHydrationResult{SkippedMissing: optionalNames}, nil
}
if env.ObjectStore == nil {
return nil, fmt.Errorf("previous-session artifact hydration requires object store backend")
}
if env.Config.Pipeline.Storage.S3 == nil {
return nil, fmt.Errorf("pipeline.storage.s3 configuration is required for previous-session artifact hydration")
}
campaign := strings.TrimSpace(env.Config.Session.Campaign)
if campaign == "" {
return nil, fmt.Errorf("session campaign is required for previous-session artifact hydration")
}
bucket := strings.TrimSpace(env.Config.Pipeline.Storage.S3.Bucket)
if bucket == "" {
return nil, fmt.Errorf("pipeline.storage.s3.bucket is required for previous-session artifact hydration")
}
previousSessionPrefix := artifacts.S3SessionPrefix(
env.Config.Pipeline.Storage.S3.RootPrefix,
campaign,
previousSessionID,
)
currentManifestKey, currentRunIDKey := artifacts.ResolveArchiveCurrentStateKeys(previousSessionPrefix)
result := &previousSessionHydrationResult{}
runPointerExists, err := env.ObjectStore.Exists(ctx, currentRunIDKey)
if err != nil {
return nil, fmt.Errorf("check previous-session current run pointer %q: %w", currentRunIDKey, err)
}
if !runPointerExists {
if len(requiredNames) > 0 {
return nil, fmt.Errorf("required previous-session artifacts unavailable: remote current run pointer missing: %q", currentRunIDKey)
}
result.SkippedMissing = optionalNames
return result, nil
}
runIDTemp, err := downloadObjectToTempStage(ctx, env.ObjectStore, currentRunIDKey, "narratio-prepare-previous-run-id-*.txt")
if err != nil {
return nil, fmt.Errorf("download previous-session current run pointer %q: %w", currentRunIDKey, err)
}
defer func() { _ = os.Remove(runIDTemp) }()
runIDBytes, err := os.ReadFile(runIDTemp)
if err != nil {
return nil, fmt.Errorf("read previous-session current run pointer %q: %w", currentRunIDKey, err)
}
previousRunID := strings.TrimSpace(string(runIDBytes))
if previousRunID == "" {
return nil, fmt.Errorf("previous-session current run pointer %q is empty", currentRunIDKey)
}
result.PreviousRunID = previousRunID
manifestExists, err := env.ObjectStore.Exists(ctx, currentManifestKey)
if err != nil {
return nil, fmt.Errorf("check previous-session current manifest %q: %w", currentManifestKey, err)
}
if !manifestExists {
if len(requiredNames) > 0 {
return nil, fmt.Errorf("required previous-session artifacts unavailable: remote current manifest missing: %q", currentManifestKey)
}
result.SkippedMissing = optionalNames
return result, nil
}
if err := os.MkdirAll(filepath.Dir(paths.PreviousManifestPath), 0o755); err != nil {
return nil, fmt.Errorf("create previous manifest directory: %w", err)
}
if err := env.ObjectStore.Download(ctx, currentManifestKey, paths.PreviousManifestPath); err != nil {
return nil, fmt.Errorf("download previous-session current manifest %q: %w", currentManifestKey, err)
}
manifestStore := &manifest.LocalStore{}
previousManifest, err := manifestStore.Load(ctx, paths.PreviousManifestPath)
if err != nil {
return nil, fmt.Errorf("decode downloaded previous-session manifest %q: %w", currentManifestKey, err)
}
if strings.TrimSpace(previousManifest.SessionID) != previousSessionID {
return nil, fmt.Errorf(
"previous-session manifest session_id %q does not match configured previous_session_id %q",
strings.TrimSpace(previousManifest.SessionID),
previousSessionID,
)
}
if strings.TrimSpace(previousManifest.Campaign) != campaign {
return nil, fmt.Errorf(
"previous-session manifest campaign %q does not match current campaign %q",
strings.TrimSpace(previousManifest.Campaign),
campaign,
)
}
if strings.TrimSpace(previousManifest.RunID) == "" {
return nil, fmt.Errorf("previous-session manifest run_id is required")
}
if strings.TrimSpace(previousManifest.RunID) != previousRunID {
return nil, fmt.Errorf(
"previous-session current run pointer %q references run %q but current manifest run_id is %q",
currentRunIDKey,
previousRunID,
strings.TrimSpace(previousManifest.RunID),
)
}
manifestChecksum, err := env.ArtifactStore.Checksum(paths.PreviousManifestPath)
if err != nil {
return nil, fmt.Errorf("checksum downloaded previous-session manifest: %w", err)
}
result.Inputs = append(result.Inputs, manifest.InputRecord{
Kind: preparePreviousInputKindManifest,
Path: paths.PreviousManifestPath,
Checksum: manifestChecksum,
Source: preparePreviousInputSource,
S3Bucket: bucket,
S3Key: currentManifestKey,
})
for _, requirement := range orderedRequirements {
candidates := previousArtifactRelativePathCandidates(requirement.Name, previousManifest, env.Config)
if len(candidates) == 0 {
if requirement.Required {
return nil, fmt.Errorf(
"required previous-session artifact %q is unavailable in previous-session manifest/archive",
requirement.Name,
)
}
result.SkippedMissing = append(result.SkippedMissing, requirement.Name)
continue
}
selectedRel := ""
selectedKey := ""
for _, candidate := range candidates {
remoteKey := artifacts.S3PromotedArtifactKey(previousSessionPrefix, candidate)
exists, err := env.ObjectStore.Exists(ctx, remoteKey)
if err != nil {
return nil, fmt.Errorf("check previous-session artifact object %q: %w", remoteKey, err)
}
if !exists {
continue
}
selectedRel = candidate
selectedKey = remoteKey
break
}
if selectedRel == "" {
if requirement.Required {
return nil, fmt.Errorf(
"required previous-session artifact %q object missing from archive candidate keys",
requirement.Name,
)
}
result.SkippedMissing = append(result.SkippedMissing, requirement.Name)
continue
}
localPath := artifacts.SessionPreviousArtifactPath(paths, selectedRel)
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
return nil, fmt.Errorf("create previous-session artifact directory for %q: %w", localPath, err)
}
if err := env.ObjectStore.Download(ctx, selectedKey, localPath); err != nil {
return nil, fmt.Errorf("download previous-session artifact %q from %q: %w", requirement.Name, selectedKey, err)
}
if err := requireNonEmptyFile(localPath, "previous-session artifact "+requirement.Name); err != nil {
return nil, err
}
checksum, err := env.ArtifactStore.Checksum(localPath)
if err != nil {
return nil, fmt.Errorf("checksum previous-session artifact %q: %w", requirement.Name, err)
}
result.Inputs = append(result.Inputs, manifest.InputRecord{
Kind: preparePreviousInputKindArtifact,
Path: localPath,
Checksum: checksum,
Source: preparePreviousInputSource,
S3Bucket: bucket,
S3Key: selectedKey,
})
result.Hydrated = append(result.Hydrated, requirement.Name)
}
sort.Strings(result.Hydrated)
sort.Strings(result.SkippedMissing)
sort.Slice(result.Inputs, func(i, j int) bool {
if result.Inputs[i].Kind != result.Inputs[j].Kind {
return result.Inputs[i].Kind < result.Inputs[j].Kind
}
return result.Inputs[i].Path < result.Inputs[j].Path
})
return result, nil
}
func requiredPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequirement) []string {
names := make([]string, 0, len(requirements))
for _, requirement := range requirements {
if requirement.Required {
names = append(names, strings.TrimSpace(requirement.Name))
}
}
sort.Strings(names)
return names
}
func optionalPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequirement) []string {
names := make([]string, 0, len(requirements))
for _, requirement := range requirements {
if requirement.Required {
continue
}
names = append(names, strings.TrimSpace(requirement.Name))
}
sort.Strings(names)
return names
}
func previousArtifactRelativePathCandidates(
artifactName string,
previousManifest *manifest.Manifest,
cfg *config.Config,
) []string {
candidates := []string{}
appendCandidate := func(v string) {
normalized, err := normalizeArchiveRelativePath(v)
if err != nil {
return
}
candidates = append(candidates, normalized)
}
sourceID := artifacts.ConfiguredArtifactSourceID(artifactName)
if rel, ok := previousManifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok {
appendCandidate(rel)
base := path.Base(rel)
for _, promoted := range previousManifestPromotedPaths(previousManifest) {
if path.Base(promoted) == base {
appendCandidate(promoted)
}
}
}
if cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
if artifactCfg, ok := cfg.Pipeline.Scriptorium.Artifacts[artifactName]; ok {
appendCandidate(artifactCfg.OutputPath)
}
}
return dedupeOrderedStrings(candidates)
}
func previousManifestArtifactRelativePathBySourceID(previousManifest *manifest.Manifest, sourceID string) (string, bool) {
if previousManifest == nil || len(previousManifest.Stages) == 0 {
return "", false
}
sourceID = strings.TrimSpace(sourceID)
if sourceID == "" {
return "", false
}
stageNames := make([]string, 0, len(previousManifest.Stages))
if _, ok := previousManifest.Stages["analyze"]; ok {
stageNames = append(stageNames, "analyze")
}
for stageName := range previousManifest.Stages {
if stageName == "analyze" {
continue
}
stageNames = append(stageNames, stageName)
}
start := 0
if len(stageNames) > 0 && stageNames[0] == "analyze" {
start = 1
}
sort.Strings(stageNames[start:])
for _, stageName := range stageNames {
sr := previousManifest.Stages[stageName]
if sr == nil {
continue
}
for _, out := range sr.Outputs {
if strings.TrimSpace(out.SourceID) != sourceID {
continue
}
rel, ok := derivePreviousManifestRelativePath(previousManifest, out.LocalPath)
if ok {
return rel, true
}
}
}
return "", false
}
func derivePreviousManifestRelativePath(previousManifest *manifest.Manifest, localPath string) (string, bool) {
trimmed := strings.TrimSpace(localPath)
if trimmed == "" {
return "", false
}
if !filepath.IsAbs(trimmed) {
normalized, err := normalizeArchiveRelativePath(filepath.ToSlash(trimmed))
if err != nil {
return "", false
}
return normalized, true
}
sessionRoot, ok := previousManifestSessionRoot(previousManifest)
if !ok {
return "", false
}
rel, err := filepath.Rel(sessionRoot, trimmed)
if err != nil {
return "", false
}
normalized, err := normalizeArchiveRelativePath(filepath.ToSlash(rel))
if err != nil {
return "", false
}
return normalized, true
}
func previousManifestSessionRoot(previousManifest *manifest.Manifest) (string, bool) {
if previousManifest == nil {
return "", false
}
runRoot := filepath.Clean(strings.TrimSpace(previousManifest.LocalWorkDir))
runID := strings.TrimSpace(previousManifest.RunID)
if runRoot == "" || runID == "" {
return "", false
}
if filepath.Base(runRoot) != runID {
return "", false
}
runsDir := filepath.Dir(runRoot)
if filepath.Base(runsDir) != config.PathRunsDirSegment {
return "", false
}
return filepath.Dir(runsDir), true
}
func previousManifestPromotedPaths(previousManifest *manifest.Manifest) []string {
if previousManifest == nil || len(previousManifest.Stages) == 0 {
return nil
}
sr := previousManifest.Stages["archive"]
if sr == nil || sr.Metadata == nil {
return nil
}
raw, ok := sr.Metadata["promoted_paths"]
if !ok {
return nil
}
values, ok := raw.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(values))
for _, value := range values {
asString, ok := value.(string)
if !ok {
continue
}
normalized, err := normalizeArchiveRelativePath(asString)
if err != nil {
continue
}
out = append(out, normalized)
}
return dedupeOrderedStrings(out)
}
func dedupeOrderedStrings(values []string) []string {
if len(values) == 0 {
return nil
}
seen := map[string]struct{}{}
out := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
if _, ok := seen[trimmed]; ok {
continue
}
seen[trimmed] = struct{}{}
out = append(out, trimmed)
}
return out
}
func downloadObjectToTempStage(
ctx context.Context,
store interface {
Download(context.Context, string, string) error
},
key, pattern string,
) (string, error) {
tmp, err := os.CreateTemp("", pattern)
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
path := tmp.Name()
if err := tmp.Close(); err != nil {
_ = os.Remove(path)
return "", fmt.Errorf("close temp file: %w", err)
}
if err := store.Download(ctx, key, path); err != nil {
_ = os.Remove(path)
return "", err
}
return path, nil
}

View File

@@ -0,0 +1,415 @@
package stage
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestHydratePreviousSessionArtifactsDownloadsManifestAndRequiredArtifact(t *testing.T) {
env, sessionPaths, fake := previousHydrationFixture(t)
requirements := []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}
seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{
includeRunPointerObject: true,
includeArtifactObject: true,
artifactBody: "# previous recap\n",
})
result, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements)
if err != nil {
t.Fatalf("hydratePreviousSessionArtifacts() error = %v", err)
}
if result == nil {
t.Fatal("result is nil")
}
if len(result.Inputs) != 2 {
t.Fatalf("inputs len = %d, want 2", len(result.Inputs))
}
if !containsString(result.Hydrated, "session_recap") {
t.Fatalf("hydrated = %#v, want session_recap", result.Hydrated)
}
if len(result.SkippedMissing) != 0 {
t.Fatalf("skipped missing = %#v, want none", result.SkippedMissing)
}
if _, err := os.Stat(sessionPaths.PreviousManifestPath); err != nil {
t.Fatalf("previous manifest missing: %v", err)
}
recapPath := artifacts.SessionPreviousArtifactPath(sessionPaths, "artifacts/session_recap.md")
if _, err := os.Stat(recapPath); err != nil {
t.Fatalf("previous artifact missing: %v", err)
}
manifestInput := findInputByKind(result.Inputs, preparePreviousInputKindManifest)
if manifestInput == nil {
t.Fatalf("missing input kind %q", preparePreviousInputKindManifest)
}
if manifestInput.Source != preparePreviousInputSource {
t.Fatalf("manifest input source = %q, want %q", manifestInput.Source, preparePreviousInputSource)
}
artifactInput := findInputByKind(result.Inputs, preparePreviousInputKindArtifact)
if artifactInput == nil {
t.Fatalf("missing input kind %q", preparePreviousInputKindArtifact)
}
if artifactInput.Source != preparePreviousInputSource {
t.Fatalf("artifact input source = %q, want %q", artifactInput.Source, preparePreviousInputSource)
}
if artifactInput.Path != recapPath {
t.Fatalf("artifact input path = %q, want %q", artifactInput.Path, recapPath)
}
}
func TestHydratePreviousSessionArtifactsSkipsMissingOptionalArtifact(t *testing.T) {
env, sessionPaths, fake := previousHydrationFixture(t)
requirements := []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: false},
}
seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{
includeRunPointerObject: true,
includeManifestObject: true,
includeArtifactObject: false,
})
result, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements)
if err != nil {
t.Fatalf("hydratePreviousSessionArtifacts() error = %v", err)
}
if result == nil {
t.Fatal("result is nil")
}
if !containsString(result.SkippedMissing, "session_recap") {
t.Fatalf("skipped missing = %#v, want session_recap", result.SkippedMissing)
}
if len(result.Hydrated) != 0 {
t.Fatalf("hydrated = %#v, want none", result.Hydrated)
}
manifestInput := findInputByKind(result.Inputs, preparePreviousInputKindManifest)
if manifestInput == nil {
t.Fatalf("missing input kind %q", preparePreviousInputKindManifest)
}
if findInputByKind(result.Inputs, preparePreviousInputKindArtifact) != nil {
t.Fatalf("unexpected %q input for missing optional artifact", preparePreviousInputKindArtifact)
}
}
func TestHydratePreviousSessionArtifactsFailsMissingRequiredArtifact(t *testing.T) {
env, sessionPaths, fake := previousHydrationFixture(t)
requirements := []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}
seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{
includeRunPointerObject: true,
includeArtifactObject: false,
})
_, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements)
if err == nil || !strings.Contains(err.Error(), "required previous-session artifact") {
t.Fatalf("error = %v, want required artifact failure", err)
}
}
func TestHydratePreviousSessionArtifactsFailsRequiredWhenPreviousSessionIDUnset(t *testing.T) {
env, sessionPaths, _ := previousHydrationFixture(t)
env.Config.Session.PreviousSessionID = ""
requirements := []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}
_, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements)
if err == nil || !strings.Contains(err.Error(), "previous_session_id is required") {
t.Fatalf("error = %v, want previous_session_id required failure", err)
}
}
func TestHydratePreviousSessionArtifactsOptionalWithNoPreviousSessionID(t *testing.T) {
env, sessionPaths, _ := previousHydrationFixture(t)
env.Config.Session.PreviousSessionID = ""
requirements := []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: false},
}
result, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements)
if err != nil {
t.Fatalf("hydratePreviousSessionArtifacts() error = %v", err)
}
if result == nil {
t.Fatal("result is nil")
}
if len(result.Inputs) != 0 {
t.Fatalf("inputs len = %d, want 0", len(result.Inputs))
}
if !containsString(result.SkippedMissing, "session_recap") {
t.Fatalf("skipped missing = %#v, want session_recap", result.SkippedMissing)
}
if _, err := os.Stat(sessionPaths.PreviousManifestPath); !os.IsNotExist(err) {
t.Fatalf("previous manifest should not be created, stat err = %v", err)
}
}
func TestHydratePreviousSessionArtifactsRespectsCurrentCommitMarker(t *testing.T) {
env, sessionPaths, fake := previousHydrationFixture(t)
requirements := []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}
seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{
includeRunPointerObject: false,
includeArtifactObject: true,
artifactBody: "# previous recap\n",
})
_, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements)
if err == nil || !strings.Contains(err.Error(), "remote current run pointer missing") {
t.Fatalf("error = %v, want current run pointer missing failure", err)
}
}
func TestHydratePreviousSessionArtifactsDoesNotUseLocalPreviousWorkspaceState(t *testing.T) {
env, sessionPaths, fake := previousHydrationFixture(t)
requirements := []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}
seed := seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{
includeRunPointerObject: true,
includeManifestObject: true,
includeArtifactObject: false,
})
// Write a local previous-session workspace file that should be ignored.
writeFile(t, filepath.Join(seed.PreviousSessionRoot, "artifacts", "session_recap.md"), "# local stale recap\n")
_, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements)
if err == nil || !strings.Contains(err.Error(), "object missing from archive") {
t.Fatalf("error = %v, want remote-object-missing failure", err)
}
}
func TestHydratePreviousSessionArtifactsUsesExplicitStorageKeys(t *testing.T) {
env, sessionPaths, fake := previousHydrationFixture(t)
requirements := []artifacts.PreviousArtifactRequirement{
{Name: "session_recap", Required: true},
}
seed := seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{
includeRunPointerObject: true,
includeArtifactObject: true,
artifactBody: "# previous recap\n",
})
capture := &preparePreviousCaptureStore{delegate: fake}
env.ObjectStore = capture
_, err := hydratePreviousSessionArtifacts(context.Background(), env, sessionPaths, requirements)
if err != nil {
t.Fatalf("hydratePreviousSessionArtifacts() error = %v", err)
}
if !containsString(capture.existsKeys, seed.RunPointerKey) {
t.Fatalf("exists keys = %#v, want %q", capture.existsKeys, seed.RunPointerKey)
}
if !containsString(capture.existsKeys, seed.ManifestKey) {
t.Fatalf("exists keys = %#v, want %q", capture.existsKeys, seed.ManifestKey)
}
if !containsString(capture.existsKeys, seed.ArtifactKey) {
t.Fatalf("exists keys = %#v, want %q", capture.existsKeys, seed.ArtifactKey)
}
if !containsString(capture.downloadKeys, seed.RunPointerKey) {
t.Fatalf("download keys = %#v, want %q", capture.downloadKeys, seed.RunPointerKey)
}
if !containsString(capture.downloadKeys, seed.ManifestKey) {
t.Fatalf("download keys = %#v, want %q", capture.downloadKeys, seed.ManifestKey)
}
if !containsString(capture.downloadKeys, seed.ArtifactKey) {
t.Fatalf("download keys = %#v, want %q", capture.downloadKeys, seed.ArtifactKey)
}
}
type previousStateSeedResult struct {
PreviousSessionPrefix string
PreviousSessionRoot string
ManifestKey string
RunPointerKey string
ArtifactKey string
}
type previousStateSeedOptions struct {
includeRunPointerObject bool
includeManifestObject bool
includeArtifactObject bool
artifactBody string
}
func seedPreviousCurrentState(
t *testing.T,
env *Env,
fake *storage.FakeBackend,
options previousStateSeedOptions,
) previousStateSeedResult {
t.Helper()
if !options.includeRunPointerObject && !options.includeManifestObject && !options.includeArtifactObject {
// Keep default behavior deterministic when caller omits explicit flags.
options.includeRunPointerObject = true
options.includeManifestObject = true
}
if options.includeManifestObject == false && options.includeArtifactObject {
options.includeManifestObject = true
}
previousSessionID := strings.TrimSpace(env.Config.Session.PreviousSessionID)
campaign := strings.TrimSpace(env.Config.Session.Campaign)
rootPrefix := strings.TrimSpace(env.Config.Pipeline.Storage.S3.RootPrefix)
previousSessionPrefix := artifacts.S3SessionPrefix(rootPrefix, campaign, previousSessionID)
manifestKey, runPointerKey := artifacts.ResolveArchiveCurrentStateKeys(previousSessionPrefix)
previousRunID := "20260510T010203Z-a1b2c3d4"
previousSessionRoot := filepath.Join(t.TempDir(), "work", campaign, previousSessionID)
artifactLocalPath := filepath.Join(previousSessionRoot, "artifacts", "session_recap.md")
previousManifest := buildPreviousManifestForSeed(
t,
previousSessionID,
campaign,
previousRunID,
filepath.Join(previousSessionRoot, "runs", previousRunID),
artifactLocalPath,
)
if options.includeRunPointerObject || (!options.includeRunPointerObject && !options.includeManifestObject && !options.includeArtifactObject) {
fake.SeedObject(storage.FakeObject{
Key: runPointerKey,
Data: []byte(previousRunID + "\n"),
})
}
if options.includeManifestObject || options.includeArtifactObject {
fake.SeedObject(storage.FakeObject{
Key: manifestKey,
Data: previousManifest,
})
}
artifactKey := artifacts.S3PromotedArtifactKey(previousSessionPrefix, "artifacts/session_recap.md")
if options.includeArtifactObject {
body := options.artifactBody
if body == "" {
body = "# previous recap\n"
}
fake.SeedObject(storage.FakeObject{
Key: artifactKey,
Data: []byte(body),
})
}
return previousStateSeedResult{
PreviousSessionPrefix: previousSessionPrefix,
PreviousSessionRoot: previousSessionRoot,
ManifestKey: manifestKey,
RunPointerKey: runPointerKey,
ArtifactKey: artifactKey,
}
}
func previousHydrationFixture(t *testing.T) (*Env, artifacts.SessionPaths, *storage.FakeBackend) {
t.Helper()
env, m := setupPrepareEnv(t)
env.Config.Session.Campaign = "forsaken"
env.Config.Session.PreviousSessionID = "2026-05-10"
env.Config.Pipeline.Storage.S3 = &config.StorageS3Config{
Bucket: "my-dnd-archive",
RootPrefix: "dnd",
}
env.Config.Pipeline.Scriptorium = &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
OutputPath: "artifacts/session_recap.md",
},
},
}
fake := &storage.FakeBackend{}
env.ObjectStore = fake
paths, err := ensureLayoutForEnv(env, m.SessionID)
if err != nil {
t.Fatalf("ensureLayoutForEnv() error = %v", err)
}
return env, paths, fake
}
func buildPreviousManifestForSeed(
t *testing.T,
sessionID, campaign, runID, runRoot, artifactPath string,
) []byte {
t.Helper()
now := time.Date(2026, 5, 19, 22, 0, 0, 0, time.UTC)
m := manifest.New(sessionID, now)
m.Campaign = campaign
m.RunID = runID
m.LocalWorkDir = runRoot
m.MarkStageSucceeded("analyze", now, []manifest.ArtifactRecord{
{
Kind: "scriptorium_artifact",
SourceID: artifacts.ConfiguredArtifactSourceID("session_recap"),
LocalPath: artifactPath,
},
})
m.MarkStageSucceeded("archive", now, nil)
m.Stages["archive"].Metadata = map[string]any{
"promoted_paths": []string{"artifacts/session_recap.md"},
}
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
t.Fatalf("marshal manifest: %v", err)
}
return append(data, '\n')
}
type preparePreviousCaptureStore struct {
delegate storage.ObjectStore
existsKeys []string
downloadKeys []string
}
func (s *preparePreviousCaptureStore) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) {
return s.delegate.List(ctx, prefix)
}
func (s *preparePreviousCaptureStore) Download(ctx context.Context, key, localPath string) error {
s.downloadKeys = append(s.downloadKeys, key)
return s.delegate.Download(ctx, key, localPath)
}
func (s *preparePreviousCaptureStore) Upload(ctx context.Context, localPath, key string, opts storage.UploadOptions) (storage.ObjectInfo, error) {
return s.delegate.Upload(ctx, localPath, key, opts)
}
func (s *preparePreviousCaptureStore) Exists(ctx context.Context, key string) (bool, error) {
s.existsKeys = append(s.existsKeys, key)
return s.delegate.Exists(ctx, key)
}
func findInputByKind(inputs []manifest.InputRecord, kind string) *manifest.InputRecord {
for i := range inputs {
if inputs[i].Kind == kind {
return &inputs[i]
}
}
return nil
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -284,6 +284,188 @@ func TestPrepareStageAudioSourceConflictFails(t *testing.T) {
}
}
func TestPrepareStageWithoutPreviousRequirementsDoesNotTouchPreviousState(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "a")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
paths := sessionPathsForEnv(env, m.SessionID)
stalePath := filepath.Join(paths.PreviousDir, "stale.txt")
writeFile(t, stalePath, "stale")
_, err := (prepareStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("prepare.Run() error = %v", err)
}
if _, err := os.Stat(stalePath); err != nil {
t.Fatalf("expected previous stale file to remain untouched: %v", err)
}
}
func TestPrepareStageOptionalPreviousArtifactWithoutPreviousSessionIDSucceeds(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "a")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
configurePreparePreviousArtifactSource(env, false)
env.Config.Session.PreviousSessionID = ""
paths := sessionPathsForEnv(env, m.SessionID)
writeFile(t, filepath.Join(paths.PreviousArtifactsDir, "stale.txt"), "stale")
result, err := (prepareStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("prepare.Run() error = %v", err)
}
if result.Metadata["previous_requirements_count"] != 1 {
t.Fatalf("metadata previous_requirements_count = %#v, want 1", result.Metadata["previous_requirements_count"])
}
if result.Metadata["previous_artifacts_hydrated_count"] != 0 {
t.Fatalf("metadata previous_artifacts_hydrated_count = %#v, want 0", result.Metadata["previous_artifacts_hydrated_count"])
}
if result.Metadata["previous_artifacts_missing_optional_count"] != 1 {
t.Fatalf("metadata previous_artifacts_missing_optional_count = %#v, want 1", result.Metadata["previous_artifacts_missing_optional_count"])
}
if _, err := os.Stat(filepath.Join(paths.PreviousArtifactsDir, "stale.txt")); !os.IsNotExist(err) {
t.Fatalf("expected stale previous state to be cleared, stat err = %v", err)
}
for _, in := range m.Inputs {
if in.Kind == preparePreviousInputKindManifest || in.Kind == preparePreviousInputKindArtifact {
t.Fatalf("unexpected previous input record: %#v", in)
}
}
}
func TestPrepareStageRequiredPreviousArtifactWithoutPreviousSessionIDFails(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "a")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
configurePreparePreviousArtifactSource(env, true)
env.Config.Session.PreviousSessionID = ""
_, err := (prepareStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "previous_session_id is required") {
t.Fatalf("error = %v, want previous_session_id required failure", err)
}
}
func TestPrepareStageHydratesRequiredPreviousArtifactAndRecordsInputs(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "a")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
env.Config.Session.Campaign = "forsaken"
env.Config.Session.PreviousSessionID = "2026-05-10"
env.Config.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "my-dnd-archive", RootPrefix: "dnd"}
configurePreparePreviousArtifactSource(env, true)
fake := &storage.FakeBackend{}
env.ObjectStore = fake
seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{
includeRunPointerObject: true,
includeArtifactObject: true,
artifactBody: "# prior recap\n",
})
paths := sessionPathsForEnv(env, m.SessionID)
result, err := (prepareStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("prepare.Run() error = %v", err)
}
if _, err := os.Stat(paths.PreviousManifestPath); err != nil {
t.Fatalf("expected previous manifest: %v", err)
}
recapPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
if _, err := os.Stat(recapPath); err != nil {
t.Fatalf("expected previous artifact: %v", err)
}
var hasPreviousManifest, hasPreviousArtifact bool
for _, in := range m.Inputs {
if in.Kind == preparePreviousInputKindManifest {
hasPreviousManifest = true
}
if in.Kind == preparePreviousInputKindArtifact {
hasPreviousArtifact = true
}
}
if !hasPreviousManifest || !hasPreviousArtifact {
t.Fatalf("manifest inputs missing previous provenance: %#v", m.Inputs)
}
if result.Metadata["previous_artifacts_hydrated_count"] != 1 {
t.Fatalf("metadata previous_artifacts_hydrated_count = %#v, want 1", result.Metadata["previous_artifacts_hydrated_count"])
}
}
func TestPrepareStageRerunOverwritesPreviousCache(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "a")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
env.Config.Session.Campaign = "forsaken"
env.Config.Session.PreviousSessionID = "2026-05-10"
env.Config.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "my-dnd-archive", RootPrefix: "dnd"}
configurePreparePreviousArtifactSource(env, true)
fake := &storage.FakeBackend{}
env.ObjectStore = fake
seed := seedPreviousCurrentState(t, env, fake, previousStateSeedOptions{
includeRunPointerObject: true,
includeArtifactObject: true,
artifactBody: "# old recap\n",
})
paths := sessionPathsForEnv(env, m.SessionID)
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("first prepare run error = %v", err)
}
recapPath := artifacts.SessionPreviousArtifactPath(paths, "artifacts/session_recap.md")
firstBytes, err := os.ReadFile(recapPath)
if err != nil {
t.Fatalf("read first hydrated artifact: %v", err)
}
if strings.TrimSpace(string(firstBytes)) != "# old recap" {
t.Fatalf("first hydrated content = %q, want %q", strings.TrimSpace(string(firstBytes)), "# old recap")
}
writeFile(t, filepath.Join(paths.PreviousArtifactsDir, "stale.txt"), "stale")
fake.SeedObject(storage.FakeObject{Key: seed.ArtifactKey, Data: []byte("# new recap\n")})
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("second prepare run error = %v", err)
}
secondBytes, err := os.ReadFile(recapPath)
if err != nil {
t.Fatalf("read second hydrated artifact: %v", err)
}
if strings.TrimSpace(string(secondBytes)) != "# new recap" {
t.Fatalf("second hydrated content = %q, want %q", strings.TrimSpace(string(secondBytes)), "# new recap")
}
if _, err := os.Stat(filepath.Join(paths.PreviousArtifactsDir, "stale.txt")); !os.IsNotExist(err) {
t.Fatalf("expected stale previous cache file to be removed, stat err = %v", err)
}
}
func configurePreparePreviousArtifactSource(env *Env, required bool) {
env.Config.Pipeline.Scriptorium = &config.ScriptoriumConfig{
Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {
Enabled: true,
OutputPath: "artifacts/session_recap.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"previous_recap": {
Source: "narratio.previous_session.artifact.session_recap",
Required: required,
},
},
},
},
}
}
func setupPrepareEnv(t *testing.T) (*Env, *manifest.Manifest) {
t.Helper()
workspace := t.TempDir()

View File

@@ -7,6 +7,7 @@ import (
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
@@ -99,6 +100,9 @@ func runLocalPathForCanonical(layout runStageLayout, sessionPaths artifacts.Sess
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("canonical path %q is outside session root %q", cleanCanonical, sessionPaths.Root)
}
if rel == config.PathPreviousDirSegment || strings.HasPrefix(rel, config.PathPreviousDirSegment+string(filepath.Separator)) {
return cleanCanonical, nil
}
localPath := filepath.Join(layout.OutputsDir, rel)
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
return "", fmt.Errorf("create run-local output parent for %q: %w", localPath, err)

View File

@@ -33,3 +33,24 @@ func TestRunLocalPathForCanonicalCreatesParentDirectories(t *testing.T) {
t.Fatalf("expected run-local parent directory to exist: %v", err)
}
}
func TestRunLocalPathForCanonicalKeepsPreviousStateSessionDurable(t *testing.T) {
root := t.TempDir()
sessionRoot := filepath.Join(root, "work", "dilfs", "2026-05-17")
layout := runStageLayout{
Enabled: true,
OutputsDir: filepath.Join(sessionRoot, "runs", "run-1", "prepare", "outputs"),
}
if err := os.MkdirAll(layout.OutputsDir, 0o755); err != nil {
t.Fatalf("mkdir outputs dir: %v", err)
}
canonical := filepath.Join(sessionRoot, "previous", "artifacts", "session_recap.md")
got, err := runLocalPathForCanonical(layout, artifacts.SessionPaths{Root: sessionRoot}, canonical)
if err != nil {
t.Fatalf("runLocalPathForCanonical() error = %v", err)
}
if got != canonical {
t.Fatalf("runLocalPathForCanonical() = %q, want canonical %q", got, canonical)
}
}