Compare commits
23 Commits
v0.10.0
...
bc2ade38d9
| Author | SHA1 | Date | |
|---|---|---|---|
| bc2ade38d9 | |||
| 5be831eb13 | |||
| cae4d99a89 | |||
| e09dc0512d | |||
| ae82bc1ce0 | |||
| 01eb7aa1aa | |||
| 2ca700195c | |||
| 2b08c34539 | |||
| 79f1fc1e09 | |||
| 9c753270bd | |||
| b907cb01aa | |||
| 7824afd4a5 | |||
| 2a4e1e912c | |||
| dd03c09d75 | |||
| 5bc8e8683f | |||
| 648001a8fe | |||
| 6684774f52 | |||
| f3b63bd5e5 | |||
| 23d6470b0f | |||
| 128449040f | |||
| 02ab106ade | |||
| c128970f58 | |||
| d001baa660 |
79
docs/cli.md
79
docs/cli.md
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
101
docs/internal/command-restore.md
Normal file
101
docs/internal/command-restore.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 session’s 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
629
docs/roadmap/previous.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
155
internal/app/restore.go
Normal 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
|
||||
}
|
||||
139
internal/app/restore_discovery.go
Normal file
139
internal/app/restore_discovery.go
Normal 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
|
||||
}
|
||||
239
internal/app/restore_discovery_test.go
Normal file
239
internal/app/restore_discovery_test.go
Normal 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
|
||||
}
|
||||
180
internal/app/restore_execute.go
Normal file
180
internal/app/restore_execute.go
Normal 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
|
||||
}
|
||||
413
internal/app/restore_execution_test.go
Normal file
413
internal/app/restore_execution_test.go
Normal 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)
|
||||
}
|
||||
347
internal/app/restore_plan.go
Normal file
347
internal/app/restore_plan.go
Normal 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
|
||||
}
|
||||
220
internal/app/restore_plan_test.go
Normal file
220
internal/app/restore_plan_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
243
internal/app/restore_report.go
Normal file
243
internal/app/restore_report.go
Normal 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
|
||||
}
|
||||
409
internal/app/restore_test.go
Normal file
409
internal/app/restore_test.go
Normal 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
|
||||
}
|
||||
300
internal/app/restore_workflow_test.go
Normal file
300
internal/app/restore_workflow_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
77
internal/artifacts/archive_identity.go
Normal file
77
internal/artifacts/archive_identity.go
Normal 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)
|
||||
}
|
||||
130
internal/artifacts/archive_identity_test.go
Normal file
130
internal/artifacts/archive_identity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
112
internal/artifacts/previous_requirements.go
Normal file
112
internal/artifacts/previous_requirements.go
Normal 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
|
||||
}
|
||||
190
internal/artifacts/previous_requirements_test.go
Normal file
190
internal/artifacts/previous_requirements_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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, ©, 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) == "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
475
internal/stage/prepare_previous.go
Normal file
475
internal/stage/prepare_previous.go
Normal 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
|
||||
}
|
||||
415
internal/stage/prepare_previous_test.go
Normal file
415
internal/stage/prepare_previous_test.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user