39 Commits

Author SHA1 Message Date
8b4b328c4e Prepare the v1.5.0 release notes
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-08-29 23:13:39 +00:00
ee2b8e63e6 Report the embedded release version 2026-08-29 23:13:01 +00:00
51edd384c0 Remove completed feature roadmaps 2026-08-29 23:11:19 +00:00
b804d0f2c8 Share analysis dependent staling traversal 2026-08-29 20:50:43 +00:00
fd5ccc668b Use one execution plan throughout the runner 2026-08-29 20:49:43 +00:00
0dc8ff9b52 Exclude external paths from analysis fingerprints 2026-08-29 20:45:27 +00:00
8657a28bdb Preserve flag-based artifact regeneration arguments 2026-08-29 20:43:44 +00:00
a9c5e4ad4e Recheck bounded prerequisites under lock 2026-08-29 20:42:51 +00:00
2176b4371d Close out the artifact workflow roadmap 2026-08-29 20:19:19 +00:00
effc10d75b Finalize artifact workflow documentation 2026-08-29 20:14:28 +00:00
5887839aa1 Add assembled workflow compatibility coverage 2026-08-29 20:08:48 +00:00
3128bef20a Add artifact-aware analyze resume planning 2026-08-29 20:01:57 +00:00
4e4e2b7d96 Preserve incremental analysis state on failure 2026-08-29 19:49:48 +00:00
99f4f9a0db Execute incremental analysis artifact plans 2026-08-29 19:43:28 +00:00
6abdd67bb5 Add incremental analysis work planning 2026-08-29 19:26:10 +00:00
c32e0c401f Add versioned analysis fingerprint reconciliation 2026-08-29 19:18:28 +00:00
ab5751459a Add deterministic analysis input identities 2026-08-29 19:02:16 +00:00
62de6abdbf Make configured artifacts manifest authoritative 2026-08-29 18:46:40 +00:00
903dc70682 Persist partial analysis artifact results 2026-08-29 18:36:21 +00:00
23c714da66 Add versioned analysis artifact state 2026-08-29 18:27:44 +00:00
6639775d7d Add the artifact regeneration command 2026-08-29 18:19:54 +00:00
966b95b176 Enforce bounded run prerequisites 2026-08-29 18:17:05 +00:00
3bcf2c08dd Add bounded run and plan commands 2026-08-29 18:07:24 +00:00
700ab655ca Add shared bounded pipeline plans 2026-08-29 18:02:37 +00:00
85c5647385 Separate stage order from invalidation dependencies 2026-08-29 18:00:22 +00:00
2ef7c76d99 Add post-transcript implementation plan 2026-08-29 17:47:15 +00:00
9bc1b0feda Plan post-transcript artifact regeneration 2026-08-29 17:29:08 +00:00
5cec84a4a7 Remove redundant prepared input APIs
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-08-29 16:19:22 +00:00
abfbe42d61 Snapshot verified references for extraction 2026-08-29 16:18:40 +00:00
e7e3bef1e4 Reject special files before preparing inputs 2026-08-29 16:13:36 +00:00
a68e8e31a4 Complete Notarius reference compatibility audit 2026-08-29 15:49:36 +00:00
3a9e60cda9 Document prepared Notarius references 2026-08-29 15:40:23 +00:00
905ff03ccc Prove prepared reference extraction lifecycle 2026-08-29 15:31:33 +00:00
495f7bcde4 Bind prepared references to extraction identity 2026-08-29 15:24:31 +00:00
51e0e8c5d0 Pass reference bindings to Notarius 2026-08-29 15:16:06 +00:00
a2409a1fd1 Verify prepared inputs from manifest evidence 2026-08-29 15:11:59 +00:00
b3363f87d6 Prepare optional spell catalog inputs 2026-08-29 15:03:44 +00:00
5831c0c9e6 Add Notarius reference configuration vocabulary 2026-08-29 14:55:24 +00:00
42ed81cbe1 Update Notarius integration and plan references 2026-08-29 14:41:22 +00:00
126 changed files with 11723 additions and 1064 deletions

View File

@@ -58,6 +58,16 @@ steps:
build_binary windows amd64 ".exe"
build_binary windows arm64 ".exe"
smoke_binary="$dist/narratio-version-smoke"
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=$version" \
-o "$smoke_binary" "$pkg"
reported_version="$("$smoke_binary" version)"
rm -f "$smoke_binary"
if [ "$reported_version" != "narratio $version" ]; then
echo "release binary reported unexpected version: $reported_version" >&2
exit 1
fi
publish-release:
image: woodpeckerci/plugin-release
depends_on:

View File

@@ -12,7 +12,9 @@ This runs the canonical full pipeline for session `2026-04-04`.
Top-level commands:
- `run <session_id>`: run full stage order.
- `version`: print the Narratio build version.
- `run <session_id>`: run all or one contiguous range of the canonical stage order.
- `regenerate-artifacts <session_id>`: force-run extraction through analysis.
- `run-stage <stage> <session_id>`: run one stage.
- `analyze <session_id>`: force-run analyze.
- `publish <session_id>`: force-run publish.
@@ -70,22 +72,63 @@ Commands with additional positionals keep their command-specific order:
## Command Reference
### `version`
```bash
narratio version
```
Official release binaries report their exact Git tag. Binaries built directly
from source without release linker metadata report `dev`.
### `run`
```bash
narratio run <session_id> [--force] [--artifacts <name[,name...]>] [...common config flags]
narratio run <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
```
Behavior:
- evaluates full stage order;
- runs `extract` between `trim` and `render`; an omitted or disabled Notarius
- evaluates one inclusive contiguous range of the canonical stage order;
- defaults an omitted `--from` to `prepare` and an omitted `--through` to
`notify`, so omitting both retains full-pipeline behavior;
- rejects unknown endpoints and a `--from` endpoint after `--through`;
- runs `render` before `extract`; an omitted or disabled Notarius
configuration records an explicit `notarius_disabled` self-skip;
- skips already-succeeded stages unless `--force` is set or a stage-specific
resume check finds its durable result obsolete;
- applies `--force` only to stages in the selected range;
- rejects repeated `--from`, `--through`, or `--force` options, including
`--name=value` spellings;
- continues interrupted or partially completed sessions by running non-succeeded stages;
- writes session and run manifests.
When `--artifacts` is present, the selected range must contain `analyze` or
`publish`. Either consumer is sufficient, including a one-stage range.
### `regenerate-artifacts`
```bash
narratio regenerate-artifacts <session_id> [--artifacts <name[,name...]>] [...common config flags]
```
Exactly equivalent to:
```bash
narratio run <session_id> --force --from extract --through analyze [caller options]
```
The command always reruns extraction. Analysis rebuilds the selected configured
artifacts and any prerequisites required by those targets; without
`--artifacts`, it uses the normal default analysis selection. Publish and notify
never run. Common session/configuration options and repeatable artifact values
pass through unchanged.
Because the expansion owns `--force`, `--from`, and `--through`, callers cannot
supply those options. The shared `run` parser reports them as duplicate
singleton flags. The alias has no private execution options or behavior, and
runtime diagnostics may identify the operation as `run`.
### `run-stage`
```bash
@@ -100,8 +143,8 @@ Valid stage names:
- `polish`
- `normalize`
- `trim`
- `extract`
- `render`
- `extract`
- `analyze`
- `publish`
- `notify`
@@ -153,10 +196,17 @@ post-publish cleanup behavior.
### `session plan`
```bash
narratio session plan <session_id> [--force] [...common config flags]
narratio session plan <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
```
Validates config, prepares local workdir layout, and prints run/skip decisions for each stage.
Uses the same inclusive bounds, endpoint validation, force scope, and artifact
selection contract as `run`. It validates config and prints run/skip decisions
for selected stages only without creating the local workdir or changing the
manifest. Resume-capable selected stages are checked against durable evidence.
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
execution order, and reusable current artifacts with concise reasons. These
artifact decisions come from the same reconciliation and work planner used by
execution; the preview does not predict output identities.
### `session validate`
@@ -252,14 +302,17 @@ and precedence.
## `--artifacts` Selection Rules
- accepted on `run`, `run-stage`, `analyze`, and `publish`;
- accepted on `run`, `session plan`, `run-stage`, `analyze`, and `publish`;
- repeatable and comma-separated values are combined, surrounding whitespace
is removed, and duplicate names are collapsed;
- names must exist in `pipeline.scriptorium.artifacts`;
- empty entries are invalid;
- repeated names are deduplicated.
- on `run-stage`, only `analyze` and `publish` accept the option.
Effects:
- filters analyze execution to selected configured artifacts;
- selects explicit analyze targets; required configured prerequisites may be
reused or rebuilt before them;
- filters publish rules that source `narratio.artifact.<name>`;
- does not filter built-in transcript/bounds or explicitly configured
`narratio.extraction.<name>` publish sources; and
@@ -291,6 +344,12 @@ Force publish only:
narratio publish 2026-04-04
```
Regenerate post-transcript artifacts without publishing:
```bash
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
```
## Output And Exit Behavior
- Successful commands write their result or summary to standard output and

View File

@@ -60,7 +60,10 @@ remote state with an unsafe legacy identity must be migrated before use.
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
- Pipeline defaults are applied before validation.
- Campaign and session identities must agree.
- Stable files (`speakers_file`, `autocorrect_file`, `glossary_file`, `players_file`, `party_file`) resolve from session overrides when provided, otherwise from campaign defaults.
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
`players_file`, `party_file`) and the optional `spell_catalog_file` resolve
from session overrides when provided, otherwise from campaign defaults. An
empty or omitted session spell-catalog value inherits the campaign value.
- Exactly one audio mode must be configured in session input:
- local (`audio_dir` or `audio_files`), or
- S3 (`audio_s3.prefix`).
@@ -227,6 +230,7 @@ Rules:
| `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled |
| `pipeline.notarius.timeout` | duration | No | `3h`; must be positive |
| `pipeline.notarius.working_directory` | string | No | directory containing resolved `config_path`; relative paths resolve from the pipeline file directory |
| `pipeline.notarius.references` | map[string]string | No | empty; maps normalized Notarius selectors to supported prepared Narratio source IDs; maximum 256 entries |
| `pipeline.notarius.outputs` | map | Conditional | at least one entry when enabled |
| `pipeline.render.enabled` | bool | No | `true` |
| `pipeline.render.format` | string | No | `markdown` (only supported value) |
@@ -241,6 +245,44 @@ Rules:
| `pipeline.scriptorium.artifacts` | map | No | empty |
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
### Notarius Reference Bindings
`pipeline.notarius.references` maps a Notarius CLI selector to a prepared
Narratio source, not to a filesystem path:
```yaml
notarius:
references:
glossary: narratio.input.glossary
party: narratio.input.party
players: narratio.input.players
spell_catalog: narratio.input.spell_catalog
```
Supported sources are `narratio.input.party`, `narratio.input.players`,
`narratio.input.glossary`, and `narratio.input.spell_catalog`. Each map entry is
required by its presence: omit a binding when the selected Notarius pipeline
does not need it. A spell-catalog binding additionally requires an effective
campaign or session `spell_catalog_file`.
Selectors accept Notarius's `slot`, `chunk.slot`, `lane.slot`,
`lane.extract.slot`, `lane.merge.slot`, and `lane.normalize.slot` forms.
Narratio trims whitespace around
selectors and their dot-separated components, rejects empty components and
`=`, rejects duplicate normalized selectors, and limits the map to 256 entries.
It validates only selector structure and the prepared source vocabulary;
Notarius owns target-slot declarations and media compatibility.
Before extraction, Narratio resolves every binding from the current prepared
session manifest and streams it into a verified invocation-local snapshot whose
absolute path is passed to Notarius. Missing, unsafe, empty,
changed-during-copy, or checksum-inconsistent prepared evidence fails with
guidance to force `prepare`. Bindings are sorted by normalized selector and are
part of extraction fingerprint and resume identity. See the
[Notarius integration contract](./integrations/notarius.md) for the subprocess
boundary and the [complete example](../examples/pipeline.full.annotated.yml)
for a copyable configuration.
### Notarius Output Entries
For each `pipeline.notarius.outputs.<name>`:
@@ -268,7 +310,7 @@ For each `pipeline.scriptorium.artifacts.<name>`:
| Field | Type | Required | Rule |
| --- | --- | --- | --- |
| `enabled` | bool | No | `false` if omitted |
| `depends_on[]` | list[string] | No | must reference configured artifact keys; no self-reference; enabled graph must be acyclic |
| `depends_on[]` | list[string] | No | must reference configured artifact keys; no self-reference; configured graph must be acyclic |
| `render_debug` | bool | No | per-artifact override |
| `prompt_id` | string | Conditional | required when artifact is enabled |
| `profile_id` | string | No | empty |
@@ -281,12 +323,13 @@ Narratio adds `session_id=narratio-session-<session_id>` to every Scriptorium re
Without `--artifacts`, analyze executes enabled configured artifacts. With an
explicit `--artifacts` list, the exact named configured artifacts are the
one-invocation execution set even if their `enabled` values are false; the list
does not automatically include dependencies. Named artifacts must therefore be
configured with valid executable fields, and their configured dependencies must
already be available to analyze. This override affects analyze planning only;
publish uses the list only to filter configured
`narratio.artifact.<name>` output rules.
one-invocation targets even if their `enabled` values are false. Analyze closes
those targets over `depends_on`: a current prerequisite is reused, while a
stale, missing, failed, or legacy prerequisite is rebuilt before its dependent.
Unrelated artifacts are not executed. Named targets and any prerequisite that
may require rebuilding must therefore have valid executable fields. This
override affects analyze planning only; publish uses the list only to filter
configured `narratio.artifact.<name>` output rules.
For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_name>`:
@@ -318,6 +361,7 @@ integration.
| `inputs.glossary_file` | string | Yes | stable input default |
| `inputs.players_file` | string | Yes | stable input default |
| `inputs.party_file` | string | Yes | stable input default |
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
### Session
@@ -333,6 +377,7 @@ integration.
| `inputs.glossary_file` | string | No | overrides campaign stable input |
| `inputs.players_file` | string | No | overrides campaign stable input |
| `inputs.party_file` | string | No | overrides campaign stable input |
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
| `inputs.audio_dir` | string | Conditional | local audio mode |
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
| `inputs.audio_s3.prefix` | string | Conditional | S3 audio mode |

View File

@@ -7,12 +7,14 @@ lanes from the final trimmed Seriatim transcript. Narratio owns invocation,
safe bundle discovery, lane selection, and its own artifact metadata. Notarius
owns pipeline definitions, lane schemas, the receipt, and bundle formats.
Canonical Notarius references:
Canonical Notarius v0.6.0 references:
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/consumers/subprocess.md)
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/consumers/dnd-pipeline.md)
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/integrations/run-result.md)
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/integrations/json-output.md)
- [CLI reference](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/cli.md)
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/consumers/subprocess.md)
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/consumers/dnd-pipeline.md)
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/run-result.md)
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/json-output.md)
- [D&D spell-catalog overlay](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/dnd-spell-catalog-overlays.md)
The [complete Narratio example](../../examples/pipeline.full.annotated.yml)
records the exact current constraints for all ten D&D lanes. Treat the linked
@@ -23,12 +25,43 @@ duplicate the complete schemas.
When `pipeline.notarius.enabled` is true, Narratio resolves the executable,
configuration path, input path, output directory, and working directory to
absolute paths and invokes:
absolute paths. Narratio requires the Notarius v0.6.0 CLI contract when
references are configured and invokes each binding as a separate argument
before `--json`:
```text
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> --json
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> [--reference <selector>=<verified_snapshot_path>]... --json
```
Reference paths are absolute invocation-local snapshots streamed from the
manifest-verified canonical files prepared inside the current Narratio session
workspace. Narratio verifies snapshot checksum and size before and after the
subprocess, and passes only configured bindings, ordered lexically by normalized
selector, as direct argument-vector entries without shell interpretation. A CLI
binding takes precedence over a matching external path in Notarius
configuration. Narratio never emits `--without-reference`.
The maintained D&D boundary binds only the four campaign-owned external slots:
```text
notarius run dnd-session \
--config <absolute config path> \
--input <absolute trimmed transcript path> \
--output-dir <absolute staging directory> \
--reference glossary=<absolute verified glossary snapshot> \
--reference party=<absolute verified party snapshot> \
--reference players=<absolute verified players snapshot> \
--reference spell_catalog=<absolute verified spell catalog snapshot> \
--json
```
The spell-catalog binding is omitted when the campaign does not maintain that
optional overlay. Registry, scene-description, combat-turn, and occurrence
handoffs generated during the same Notarius run remain in Notarius pipeline
composition and must not be emitted as CLI references. The linked CLI and D&D
consumer documents own selector targeting, declared slots, media compatibility,
and generated-handoff collision rules.
Standard output is reserved for the JSON receipt. Standard error is captured
separately as diagnostic output. Narratio applies the configured timeout and
does not interpret stdout as a receipt unless the subprocess exits successfully.
@@ -38,24 +71,31 @@ environment apply to the subprocess.
## Accepted Result
Narratio currently accepts receipt schema `notarius.run-result.v1`. The receipt
Narratio's supported invocation baseline is Notarius v0.6.0. The accepted
receipt remains `notarius.run-result.v2`; reference flags do not change the
receipt or ten-lane output contract. The receipt
must identify the configured pipeline, and its `index_file` must be exactly
`index.json` beneath the reported bundle root. The production index must name
the management files exactly as `manifest.json`, `rejected.json`, and
`warnings.json`. All receipt, index, and lane paths must stay inside that
bundle; symlinks and non-regular lane payloads are rejected.
the management files exactly as `manifest.json`, `rejected.json`,
`warnings.json`, and `diagnostics.json`. All receipt, index, and lane paths must
stay inside that bundle; symlinks and non-regular lane payloads are rejected.
Supported receipt and index shapes tolerate unknown fields for forward
compatibility, while required identity, validation, count, manifest,
rejection, warning, and lane-list fields remain mandatory. Narratio applies
bounded reads to the receipt, index, rejection, and warning documents. Optional
chunk-map and evidence-context descriptors must carry their complete generic
contract metadata when present.
rejection, warning, diagnostic, and lane-list fields remain mandatory.
Narratio applies bounded reads to the receipt, index, rejection, warning, and
diagnostic documents. Warning and diagnostic envelopes, group counts,
occurrence counts, truncation state, framework-owned origins, and
receipt-to-bundle counts must be internally consistent. Optional chunk-map and
evidence-context descriptors must carry their complete generic contract
metadata when present.
For every entry in `pipeline.notarius.outputs`, Narratio requires exactly one
index descriptor with the configured lane ID, media type, schema ID, schema
version, and, when configured, module key. Missing, duplicate, rejected, or
incompatible required lanes fail extraction even if Notarius exited zero.
incompatible required lanes fail extraction even if Notarius exited zero. A
configured lane whose v2 validation summary is `rejected` or `incomplete` also
fails extraction.
Unconfigured lanes may remain in the preserved bundle but do not become
selectable Narratio sources.
@@ -72,10 +112,13 @@ only explicitly named lane sources; `--artifacts` never selects Notarius lanes.
staged bundle is promoted to durable storage.
- Contract and external provenance metadata are preserved on lane artifact
records and through explicit publication.
- Undeclared selectors, incompatible reference files, and external/generated
reference collisions are Notarius errors and fail extraction normally.
Rejection and warning summaries retain structured stage, scope, lane, and
reason-code fields for diagnostics without exposing free-form external messages
or reading lane payload bodies.
Rejection, validation, warning, and diagnostic summaries retain bounded stable
identity, category, origin, reason-code, status, and occurrence fields without
copying free-form external messages into Narratio manifest metadata or reading
lane payload bodies.
Configuration fields and defaults are in [Configuration](../config.md).
Operator paths, rerun procedures, and bundle retention are in

View File

@@ -35,18 +35,33 @@ Adapters do not own:
## Default Wiring
`internal/app/runner.go` initializes default adapters when not injected:
`internal/app/runner.go` initializes default adapters when not injected and
only when the selected execution plan needs them:
- WhisperX HTTP client from pipeline config.
- Seriatim subprocess runner.
- Audita subprocess runner.
- Scriptorium subprocess runner.
- Notarius subprocess runner when extraction is enabled.
- Noop notifier (`notify.NoopSender`).
- WhisperX HTTP client for `transcribe`.
- Seriatim subprocess runner for `merge`, `normalize`, `trim`, or `render`.
- Audita subprocess runner for `polish`.
- Scriptorium subprocess runner for `trim` or `analyze`.
- Notarius subprocess runner for `extract` when extraction is enabled.
- Noop notifier (`notify.NoopSender`) for `notify`.
- Object store only when required by selected stages/config.
Remote publish locks are loaded only for a selected, enabled publish that
uploads a run. Shared session lifecycle setup still applies to every selected
range, but an unselected integration is neither initialized nor validated by
runner composition. Each selected stage retains its own fail-fast configuration
and input validation.
`session plan` is outside production adapter composition. It performs
resume validation and models selected transitions against cloned manifest
state without constructing or invoking stage-execution adapters. The shared
command configuration loader may still use object storage to retrieve a missing
remote session file before planning begins.
Notarius is composed only when extraction is enabled; the extract stage owns
receipt, bundle, and configured-lane policy rather than the adapter.
prepared reference resolution, receipt, bundle, and configured-lane policy.
The adapter validates the ordered selector/absolute-path pairs and is the sole
owner of serializing them as repeated `--reference` arguments before `--json`.
Object-store construction goes through `newCommandObjectStore`, which loads
configured filesystem secrets before adapter initialization.

View File

@@ -36,28 +36,45 @@ unrecognized token into a valid source. Extraction sources are registered only
from `pipeline.notarius.outputs`; the Notarius index has no selectable source
ID.
Prepared stable source IDs are `narratio.input.players`,
`narratio.input.party`, `narratio.input.glossary`, and
`narratio.input.spell_catalog`. Artifact policy owns their canonical manifest
kind and prepared filename vocabulary.
## Runtime Catalog
`ArtifactCatalog` tracks:
- `planned`: source registered for run context;
- `executable`: included in the effective analyze artifact set;
- `available`: local file exists and validates;
- `available`: the source's canonical evidence owner validates its current
manifest record and durable bytes;
- `provenance`: availability source.
Configured definitions are always registered. Without an explicit selection,
the effective analyze set contains enabled definitions. With `--artifacts`, the
exact named configured definitions become the effective set for that invocation,
regardless of their `enabled` value; dependencies are not added implicitly.
Availability is separate from executability: a non-executable configured output
may be reused from a canonical non-empty file, while an executable definition
is generated by analyze. Extraction entries are registered from configuration
and become available only after compatible extraction evidence is hydrated.
regardless of their `enabled` value. The effective-set resolver itself does not
expand dependencies; the analyze work planner closes those targets over their
configured prerequisite graph. Availability is separate from executability.
Configured outputs, including non-executable prerequisites, become available
only when the versioned analyze state identifies a current result whose source,
contract, canonical configured path, size, and checksum match a confined
no-follow regular file. An incidental canonical file and a legacy aggregate
analyze output are unavailable.
Extraction entries are registered from configuration and become available only
after compatible extraction evidence is hydrated.
During an analyze invocation, a newly validated and atomically materialized
configured output is marked available with its producer run ID, contract,
checksum, and size. Later scheduled dependents therefore observe the same
semantic identity whether their prerequisite was reused from current manifest
evidence or produced earlier in the invocation.
Current provenance values:
- `generated.current_analyze_run`
- `filesystem.disabled_artifact_output`
- `manifest.current_analyze_artifact`
- `manifest.inputs.previous_cache`
- `current_session.previous_cache`
@@ -70,7 +87,26 @@ Built-ins:
Configured sources (`narratio.artifact.*`):
- resolve only through runtime catalog availability.
- resolve only through runtime catalog availability;
- use the shared typed analyze-evidence inspection in
`analyze_evidence.go` for prior current-session results;
- require the supported analyze-state and fingerprint versions, a `current`
record for the exact configured key and source ID, a complete contract, the
configured canonical relative path, positive stored size, and stored
checksum matching bytes read from a confined no-follow regular file; and
- treat non-current statuses, legacy or malformed records, removed keys,
unsafe or missing files, and size/checksum mismatches as unavailable without
rewriting manifest state. Catalog construction iterates current
configuration, so removed or renamed records are not advertised.
Prepared stable sources (`narratio.input.*`):
- resolve only from the current manifest's exact prepared-input record;
- require the policy-owned canonical path below the session root, a confined
non-symlink regular file, a non-empty payload, and a matching SHA-256
checksum; and
- return an immutable source/path/checksum/size identity shared by extract and
analyze rather than falling back to campaign/session source paths.
Extraction sources (`narratio.extraction.*`):
@@ -199,7 +235,8 @@ physical layout.
`internal/artifacts/catalog.go`, `internal/artifacts/transcripts.go`,
`internal/artifacts/extraction_catalog.go`,
`internal/artifacts/extraction_evidence.go`,
`internal/artifacts/extraction_input.go`
`internal/artifacts/extraction_input.go`,
`internal/artifacts/prepared_input.go`
- Current state: `internal/artifacts/current_state.go`,
`internal/artifacts/current_state_commit.go`,
`internal/artifacts/current_state_legacy.go`

View File

@@ -35,6 +35,79 @@ The model admits these stage states:
- `stale`
- `interrupted`
### Analyze-owned artifact state
The `analyze` stage record may carry `analyze_state_version: 1` and an
`analyze_artifacts` map keyed by normalized configured artifact key. The
version is the authority marker: version 1 with no entries is a valid evaluated
empty set, while an absent version is legacy aggregate-only state and provides
no current configured-artifact evidence.
Each analyze artifact record has one disposition:
- `current`: the configured artifact is available and carries a versioned
fingerprint plus a complete output record and separate output size;
- `stale`: the recorded semantic identity is no longer current;
- `missing`: no validated current result exists;
- `failed`: the attempted work failed and carries a bounded diagnostic; or
- `unselected`: the artifact was intentionally outside the evaluated set.
Records bind their normalized key and dependencies, fingerprint contract when
evaluated, canonical session-relative output identity when current, producing
Narratio run, update time, and bounded non-secret Scriptorium provenance and
diagnostic paths. A current output includes its configured source ID, contract,
checksum, and positive byte size. Non-current records cannot carry an output,
so an older file is not advertised through stale, missing, failed, or
unselected state.
The session-stage collection is the reconciled authority across invocations.
The corresponding collection on an invocation's `analyze` stage record is an
audit of only the artifacts evaluated or attempted by that run. These records
remain analyze-owned data inside the fixed stage; they are not dynamic stages
or generic subtasks.
The stage result contract has one analyze-specific projection boundary. On
success, the runner validates and deep-copies the complete reconciled session
collection and the invocation subset. Aggregate session outputs are rebuilt in
configured-key order from current session records only; invocation outputs are
limited to current records produced by that invocation's run ID. Ordinary
stage outputs cannot accompany this projection, so there is one source of
artifact authority.
Successful incremental execution replaces only evaluated artifact records and
preserves valid unrelated current records. Rebuilt outputs are compared by
bytes and contract: an unchanged identity permits an unselected dependent with
the same recomputed fingerprint to remain current, while a changed identity
removes output authority from every unselected transitive dependent by marking
it stale. A partial analyze invocation can therefore succeed while unrelated
configured records remain stale. Existing canonical files never create current
records without validated execution and projection.
Aggregate analyze status is deliberately coarser than this collection. Resume
validation may skip a succeeded aggregate record when the selected artifact
closure is current even if unrelated records are stale. Conversely, a stale
aggregate record may cross the ordinary runner boundary and perform zero
Scriptorium calls when reconciliation proves every selected artifact current;
the successful projection then restores the aggregate status.
Analyze may return a projection together with an error. That restricted result
cannot carry ordinary outputs, skip state, aggregate logs, generated configs,
or metadata. The runner persists only the validated per-artifact collections,
then marks the aggregate analyze and run state failed and invalidates delivery
dependents conservatively. Unrelated current records survive because the
session projection is complete. A malformed projection is not applied, and a
failed session projection save restores the prior per-artifact authority before
terminal failure persistence.
The incremental executor constructs this restricted projection at each
scheduled artifact boundary. The active record is failed without output,
current transitive dependents are stale, unrelated current records survive, and
only earlier validated and materialized completions remain current in the
invocation subset. Session failure state is persisted before invocation failure
state. If either terminal save fails, its persistence error is joined with the
original adapter, validation, or filesystem cause; a failed projection save
does not turn incidental canonical bytes into manifest authority.
## Run Manifest
`manifest.RunManifest` is created for each invocation and records:
@@ -86,7 +159,10 @@ failed in both manifests, persisting each transition. On success it records
outputs, logs, generated configuration references, and metadata. Artifact
records may include optional contract and external provenance objects; old
manifests remain compatible when those fields are absent. A successful forced
rerun marks only succeeded downstream session-stage records stale.
rerun marks only succeeded transitive dependent session-stage records stale.
The application owns a fixed dependency relation distinct from execution order;
dependents are returned in canonical order. Render and extract therefore never
stale one another, while either can stale analyze, publish, and notify.
Starting an execution clears the current session-stage record's prior outputs,
logs, generated configuration references, and metadata. Failed and skipped
@@ -96,6 +172,12 @@ those details because resume validation and diagnosis may still require them
before execution begins. Invocation run manifests remain immutable audit
records of their own outcomes.
Aggregate lifecycle clearing deliberately preserves the analyze-owned
per-artifact collection. This lets later reconciliation replace only evaluated
entries without erasing unrelated current results. Other stages retain their
existing aggregate-only lifecycle behavior and are forbidden from carrying the
analyze-specific fields.
A stage may explicitly return a skipped disposition and stable reason. The
runner persists that outcome in both manifests, clears older outputs for the
session-stage record along with older logs, generated configuration references,
@@ -107,19 +189,29 @@ cannot contain outputs.
When an already-succeeded stage is skipped, the invocation run manifest records
the `skip` action and reason. The session manifest deliberately retains its
existing succeeded record because it remains the cross-invocation progress
authority. Stages with a resume validator, currently extraction, may reject an
otherwise eligible skip when the recorded durable result is obsolete; the
runner marks it stale and executes it.
authority. Extraction and analyze have resume validators and may reject an
otherwise eligible skip when their selected durable evidence is obsolete; the
runner marks the aggregate record stale and executes it. Analyze's validator
can still accept a partial selection when only unrelated artifact records are
stale.
Session manifest is the authoritative stage-progress ledger across invocations.
Run manifest is invocation-scoped audit state.
Before an explicitly bounded execution starts after `prepare`, the application
reads the session manifest and accepts only `succeeded` or `skipped` for every
excluded canonical prefix stage. The first other status or absent record fails
the request before layout mutation, adapter initialization, session-manifest
writes, or run-manifest creation. Excluded prefix records are not passed to
resume validators. Records after the selected end are not prerequisites and
may be made stale by selected work without being scheduled.
After a publish commits remotely, any configured local cleanup is first recorded
as a session-manifest obligation before deletion begins. Each target becomes
complete only after its confined deletion (or safe absence check) and a
successful manifest save. An incomplete obligation is retried on later
invocations independently of their selected stages and retains the committed
run and remote identity that authorized it.
successful manifest save. An incomplete obligation is retried when publish
executes again and retains the committed run and remote identity that authorized
it; an invocation that does not execute publish does not perform cleanup.
Each invocation derives campaign, session, run, local-path, and remote-prefix
metadata from the validated resolved configuration as one projection. A persisted
@@ -139,7 +231,7 @@ where a durable running record can require operator interpretation.
- running, failed, and self-skipped stages do not retain result payloads from
an earlier success.
- stale stages retain prior details until replacement execution starts.
- force reruns stale downstream succeeded stages.
- force reruns stale succeeded stages in the fixed dependency relation.
- run manifest does not replace session manifest as progress authority.
- remote commitment is established by a verified current pointer and remote
commit relationship, never by a mutable session-manifest boolean.

View File

@@ -43,6 +43,14 @@ Narratio-level contracts; external transport and SDK details remain in
adapters. The normative rules for these relationships remain in
[Architecture](../policy/architecture.md).
Pipeline execution and `session plan` share the same inclusive contiguous-range
model. Planning clones session state and applies selected-stage transitions and
resume validation in memory; it does not create invocation state or initialize
stage-execution adapters. Command configuration loading can still retrieve a
missing session file through configured remote storage. Analyze planning
additionally exposes the artifact closure's targets, prerequisite rebuilds,
execution order, and current reuse.
## Pipeline Stage Set
The implemented canonical order is:
@@ -53,8 +61,8 @@ The implemented canonical order is:
4. [`polish`](stage-polish.md)
5. [`normalize`](stage-normalize.md)
6. [`trim`](stage-trim.md)
7. [`extract`](stage-extract.md)
8. [`render`](stage-render.md)
7. [`render`](stage-render.md)
8. [`extract`](stage-extract.md)
9. [`analyze`](stage-analyze.md)
10. [`publish`](stage-publish.md)
11. `notify` (no-op)
@@ -65,6 +73,13 @@ mechanics. The
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
and execution semantics.
Execution order and invalidation are separate application contracts. The stage
registry owns the flat execution sequence. The application orchestration owner
uses a fixed, validated dependency relation to find transitive dependents in
canonical order. In particular, `render` and `extract` are sibling consumers of
trimmed transcript state: neither invalidates the other, while either can stale
`analyze`, `publish`, and `notify`.
## Focused Documentation
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
@@ -84,8 +99,8 @@ and execution semantics.
- [`polish`](stage-polish.md)
- [`normalize`](stage-normalize.md)
- [`trim`](stage-trim.md)
- [`extract`](stage-extract.md)
- [`render`](stage-render.md)
- [`extract`](stage-extract.md)
- [`analyze`](stage-analyze.md)
- [`publish`](stage-publish.md)

View File

@@ -2,7 +2,8 @@
## Purpose
Execute selected configured Scriptorium artifacts in dependency order and materialize outputs.
Reconcile configured Scriptorium artifacts, execute only required work in
dependency order, and safely materialize validated outputs.
## Inputs
@@ -14,35 +15,111 @@ Execute selected configured Scriptorium artifacts in dependency order and materi
Supported source families:
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`
- prepared stable inputs: `narratio.input.players`, `narratio.input.party`,
`narratio.input.glossary`
`narratio.input.glossary`, `narratio.input.spell_catalog`
- configured artifacts: `narratio.artifact.<key>`
- extraction lanes: `narratio.extraction.<key>`
- previous-session cache: `narratio.previous_session.artifact.<key>`
## Outputs
- one materialized output per executed configured artifact (`output_path`)
- one current per-artifact manifest record per validated materialized output
- stage metadata describing selected/generated/reused artifacts
## Key Behavior
- when Scriptorium is absent or no configured artifact is executable, completes
successfully with no outputs and records explanatory metadata. This is not an
explicit self-skip: both manifests record success, satisfy publish's
prerequisite, and an ordinary later run reuses the result until forced.
- when `pipeline.scriptorium` is absent or no configured artifact is
executable, completes successfully with no outputs and records explanatory
metadata. This is not an explicit self-skip: both manifests record success,
satisfy publish's prerequisite, and an ordinary later run reuses the result
while the effective set remains empty. Enabling or selecting an artifact
later makes missing versioned evidence non-resumable and schedules it without
requiring force.
- builds a runtime artifact catalog containing built-ins, configured artifacts,
and configured extraction lanes. Extraction availability is hydrated only
from compatible successful extraction evidence.
- uses enabled configured artifacts by default. An explicit `--artifacts`
selection is a one-invocation override: it makes exactly the named configured
artifacts executable even when disabled, and does not automatically include
dependencies. A selected artifact's dependencies must instead already be
available to the catalog.
- marks non-executable configured artifacts as reusable when output files already exist.
selection is a one-invocation override that makes exactly the named
configured artifacts explicit targets even when disabled. The work planner
adds required configured prerequisites, reuses current ones, and schedules
stale, missing, or otherwise non-current prerequisites before dependents.
- makes a non-executable configured artifact reusable only when its current
manifest record and durable output pass the configured-artifact evidence
contract; an incidental or stale canonical file is unavailable.
- validates selected artifact dependency order (cycle-safe topo ordering).
- resolves required/optional inputs per artifact source definition.
- omits an unavailable optional input; an unavailable required input fails.
- resolves prepared stable input sources from `inputs/*.yml` materialized by `prepare`.
- resolves required/optional inputs per artifact source definition into an
ordered semantic identity. Each identity records the configured input name,
canonical source ID, required policy, explicit presence, source contract,
checksum, size, and a source-based logical identity. Workspace paths and
producer run IDs are excluded.
- orders input identities by configured input name independently of Go map
iteration. Runtime adapter paths remain a separate execution-only map.
- omits an unavailable optional input from the adapter request while retaining
explicit absence in its semantic identity; an unavailable required input
fails.
- resolves prepared stable input sources through the shared manifest-authoritative
identity resolver; it does not accept incidental files or fall back to
campaign/session source paths.
- reuses checksums and sizes from validated prepared, extraction, and current
configured-artifact evidence. Other resolved inputs are hashed as confined
regular files with streaming reads and the central resolved-artifact size
limit.
- owns a versioned SHA-256 fingerprint contract with one fixed-field canonical
JSON payload and no map serialization. Configured artifacts are fingerprinted
in deterministic dependency order.
- fingerprints the normalized artifact key, prompt and profile identifiers,
effective render-debug behavior, session-relative output identity, sorted
dependency keys, ordered input declarations and semantic identities,
validated current dependency-output identities, and sorted effective
Scriptorium variables (including Narratio's sticky session variable).
- provides read-only reconciliation that classifies each configured record as
current, stale, missing, failed, legacy, or otherwise non-resumable, and
separately identifies manifest records removed from current configuration.
A record is current only when its fingerprint version and value match and its
configured output still passes manifest-authoritative evidence validation.
- owns a read-only typed work planner. Its explicit targets are enabled
artifacts by default or the exact normalized `--artifacts` selection when
supplied. It closes targets over configured prerequisites, orders the closure
topologically, reuses current members, and schedules every non-current member
before its dependents.
- force applies only to explicit targets. A current prerequisite is reused
unless it is itself an explicit forced target; disabled prerequisites may be
rebuilt when required, while unrelated disabled artifacts are excluded.
- the work plan carries explicit targets, prerequisite-only work, deterministic
execution and reuse lists, invalidated and removed records, and a cloned
projected record collection. Valid unrelated configured records survive the
projection, removed records are omitted, and legacy files never become
current without regeneration.
- implements aggregate resume validation by running the same read-only catalog,
fingerprint reconciliation, and work planner used by execution. A succeeded
aggregate record is reusable exactly when the selected closure schedules no
artifact work; stale unrelated records do not block a partial selection.
- exposes the typed artifact decision to `session plan`. Planning applies it to
a cloned manifest after modeling earlier selected stage transitions, so
aggregate run/skip and artifact execute/reuse decisions match the ordinary
runner without creating durable state or invoking Scriptorium.
- executes only the work plan's scheduled entries. Manifest-validated current
prerequisites remain available through the runtime catalog without invoking
Scriptorium; newly produced prerequisites enter that catalog with the same
contract, checksum, and size identity used for persisted current evidence.
- keeps adapter output in the invocation's run-local analyze directory until
it is a safe, non-empty, bounded regular file with a calculated checksum and
complete output contract. Canonical replacement uses the shared atomic file
operation boundary and verifies that the installed checksum matches the
validated run-local bytes.
- records each successful artifact's freshly computed fingerprint, canonical
relative output path, contract, checksum, size, producer run ID, bounded
Scriptorium provenance, logs, and generated configuration references in the
analyze-owned projection.
- preserves valid unrelated current records during partial execution. If a
rebuilt output's bytes and contract are unchanged, unselected dependents may
remain current. If that semantic identity changes, unselected transitive
dependents become stale without being executed; dependents included in the
invocation are evaluated in dependency order instead.
- reports all evaluated targets and prerequisites in invocation state. The
runner reconstructs aggregate session outputs from every current session
record and invocation outputs from only records produced by the current run.
Unrelated stale records do not make an otherwise successful partial
invocation fail.
- resolves previous-session sources from local `previous/` cache only.
- runs optional render-debug, then artifact execution.
- validates non-empty output files and materializes canonical outputs.
@@ -57,11 +134,33 @@ Supported source families:
guidance.
- dependency cycles or unavailable required dependencies fail.
- adapter validation failures fail stage.
- a scheduled artifact failure returns the restricted analyze-state projection
with the active artifact marked `failed`, a bounded error, and no output
authority. Current transitive dependents become stale without execution.
- earlier artifacts from the invocation remain current only after their
run-local output passed validation and canonical materialization. They remain
in invocation history; unattempted later artifacts do not appear there.
- unrelated current records survive a partial failure. Old canonical bytes for
the failed artifact and newly materialized bytes whose projection cannot be
persisted are incidental, not current evidence.
- the runner persists a valid partial projection before it marks aggregate
analyze failed and invalidates publish and notify through the application
dependency relation. Projection-persistence errors retain the last durable
per-artifact authority and are joined with the original failure context.
## Invariants
- `analyze` performs no remote storage calls for previous-session source resolution.
- input-identity resolution is read-only: it does not invoke adapters,
materialize outputs, update status, or create run records.
- fingerprints exclude timeouts, retries, timestamps, producer and Narratio run
IDs, executable and config paths, workspace roots, diagnostic locations, and
executable or private transitive configuration contents. A change that is
visible only inside Scriptorium—such as a file privately loaded by its config
path—requires an explicit forced regeneration.
- output provenance and metadata are deterministic per execution.
- a canonical file without current per-artifact manifest evidence is never
promoted to current state.
## Related Contracts And Tests
@@ -70,4 +169,13 @@ Supported source families:
- [CLI](../cli.md) owns user-visible artifact selection.
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
- Implementation and tests: `internal/stage/analyze.go`,
`internal/stage/analyze_test.go`
`internal/stage/analyze_input_identity.go`, `internal/stage/analyze_test.go`,
`internal/stage/analyze_input_identity_test.go`,
`internal/stage/analyze_fingerprint.go`,
`internal/stage/analyze_fingerprint_test.go`,
`internal/stage/analyze_reconciliation.go`, and
`internal/stage/analyze_reconciliation_test.go`,
`internal/stage/analyze_plan.go`, `internal/stage/analyze_plan_test.go`, and
`internal/stage/analyze_incremental_execution_test.go`, and
`internal/stage/analyze_failure_test.go`,
`internal/stage/analyze_resume.go`, and `internal/stage/analyze_resume_test.go`

View File

@@ -2,7 +2,7 @@
## Responsibility
`extract` runs after `trim` and before `render`. It converts the canonical
`extract` runs after `render` and before `analyze`. It converts the canonical
`narratio.transcript.final_trimmed` JSON into configured Notarius lane artifacts.
An omitted or disabled Notarius section makes the stage explicitly self-skip
with reason `notarius_disabled`, no outputs, and no Notarius runner.
@@ -17,15 +17,21 @@ procedures belong in [Operations](../operations.md).
`internal/stage/extract.go`:
1. resolves the final trimmed transcript from the shared artifact catalog;
2. resolves and fingerprints the Notarius invocation contract;
3. creates a run-local staging directory and invokes the injected
2. resolves every configured prepared reference through the shared
manifest-authoritative identity resolver before creating run-local output;
3. streams each verified reference into an invocation-local snapshot and
rejects any source change observed while copying;
4. fingerprints the Notarius invocation contract, including sorted reference
identities;
5. creates a run-local staging directory and invokes the injected
`notarius.Runner`;
4. validates the successful receipt, confined index, configured required lane
descriptors, and regular payload files;
5. atomically promotes the complete bundle to its immutable durable location;
6. records one non-selectable `notarius_index` output and one selectable
6. revalidates the reference snapshots, then validates the v2 successful
receipt, confined index, management documents, configured required lane
descriptors, validation summaries, and regular payload files;
7. atomically promotes the complete bundle to its immutable durable location;
8. records one non-selectable `notarius_index` output and one selectable
`notarius_lane` output per configured lane; and
7. registers each lane as `narratio.extraction.<output_key>` for downstream
9. registers each lane as `narratio.extraction.<output_key>` for downstream
Scriptorium and publish resolution.
Lane records retain checksum, contract, producer run ID, and Notarius system,
@@ -34,23 +40,35 @@ root, receipt, diagnostic paths, rejection/warning summaries, producing
Narratio run ID, the resolved trimmed-input identity, and invocation
fingerprint. The input identity binds the exact transcript bytes, canonical
source ID, producer stage/output/run identity, and resolution provenance.
Reference metadata contains only selector, source ID, canonical session-relative
path, checksum, and size; adapter requests receive selector and absolute
invocation-local snapshot path, never payload contents. Snapshot bytes must
match the prepared identity both before and after Notarius runs, so a concurrent
prepared-file replacement cannot make recorded provenance describe different
bytes from those supplied to Notarius.
Validation completes before
promotion, so a rejected result cannot expose a partial durable bundle.
Any executed extraction outcome that replaces a different effective outcome
marks succeeded downstream stages stale. Repeating the same disabled self-skip
with no outputs is stable and does not repeatedly invalidate downstream stages.
marks succeeded analysis and delivery dependents stale. Render is an independent
sibling and remains current. Repeating the same disabled self-skip with no
outputs is stable and does not repeatedly invalidate dependent stages.
## Resume Validation
`internal/stage/extract_resume.go` permits a skip only when the existing stage
record succeeded and still matches the current invocation fingerprint. The
fingerprint covers the resolved executable and config paths, pipeline ID,
timeout, working directory, sorted configured output contracts, and the current
direct trimmed-transcript identity. The same identity is resolved again for
timeout, working directory, sorted configured output contracts, the current
direct trimmed-transcript identity, and sorted prepared-reference identities.
The same reference helper and transcript identity are resolved again for
artifact evidence, so changing the current transcript bytes or producer
identity makes the prior extraction obsolete.
A valid prepared-reference change makes extraction non-resumable. Missing,
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
with prepare-force guidance because an immediate extract rerun cannot succeed.
The validator then checks the producing run identity, canonical immutable
bundle root, path confinement and absence of symlink components, receipt
identity, exactly one canonical index, the exact configured source set,
@@ -65,8 +83,10 @@ Operators must force extraction after changing any such input.
## Failure Behavior
Adapter startup, timeout, nonzero exit, receipt decoding, path confinement,
index compatibility, required-lane rejection, payload inspection, checksum, or
promotion errors fail the stage through ordinary manifest transition handling.
index compatibility, inconsistent warning or diagnostic envelopes,
required-lane rejection or incomplete validation, payload inspection,
checksum, or promotion errors fail the stage through ordinary manifest
transition handling.
Stdout receipt and stderr diagnostics remain separate. Downstream stages are
not given selectable extraction sources unless the complete configured result
has passed validation and promotion.

View File

@@ -8,6 +8,7 @@ Materialize canonical current-session inputs before processing stages.
- resolved campaign, session, and pipeline configuration
- stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
- optional spell-catalog overlay
- one resolved local or S3 audio source
- enabled configured artifact input requirements for previous-session sources
@@ -21,6 +22,7 @@ Materialize canonical current-session inputs before processing stages.
- `inputs/glossary.yml`
- `inputs/players.yml`
- `inputs/party.yml`
- optional `inputs/spell_catalog.json`
- `audio/*.flac`
- optional `previous/manifest.json`
- optional `previous/artifacts/**`
@@ -34,6 +36,9 @@ Materialize canonical current-session inputs before processing stages.
- gives distinct local source paths with the same basename deterministic unique
prepared filenames so neither source is overwritten.
- materializes S3 audio through spool/cache-aware logic.
- materializes a configured spell catalog with checksum and provenance, or
safely removes an obsolete canonical spell catalog and its manifest record
when the effective input is omitted.
- scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
- clears managed `previous/` state on every invocation, then, when requirements exist:
- resolves the pointer-selected previous source through the shared resolver;

View File

@@ -38,7 +38,13 @@ Exact remote placement and the operator workflow belong in
checks a declared checksum when present, then streams the opened descriptor.
- derives the durable previous-cache archive from its validated manifest using
the same confinement and regular-file checks.
- resolves publish output sources through runtime artifact catalog and manifest-aware resolution.
- resolves publish output sources through runtime artifact catalog and
manifest-aware resolution. Configured Scriptorium outputs are publishable
only from validated `current` per-artifact analyze evidence; an incidental
canonical file, legacy aggregate output, stale/failed/unselected record, or
mismatched path, size, or checksum remains unavailable. This does not change
the explicit compatibility policies owned by built-in, extraction, or
previous-session sources.
- publishes extraction lanes only through explicit configured output rules;
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
- selected artifact filter applies to configured artifact sources only.

View File

@@ -3,6 +3,10 @@
## Purpose
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
It runs after `trim` and before `extract` in the canonical sequence. Render and
extract are independent sibling consumers: replacing render output does not
invalidate extraction, but it does invalidate succeeded analysis and delivery
records that may consume rendered transcripts.
## Inputs

View File

@@ -36,7 +36,12 @@ narratio session init 2026-04-04 --remote --force
If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
Campaigns must provide stable input files for speakers, autocorrect, glossary, players, and party. Session files may override those paths for one session. The `prepare` stage materializes them under `inputs/`; configured Scriptorium artifacts can reference prepared `players`, `party`, and `glossary` files with `narratio.input.players`, `narratio.input.party`, and `narratio.input.glossary`.
Campaigns must provide stable input files for speakers, autocorrect, glossary,
players, and party, and may provide an optional spell-catalog overlay. Session
files may override those paths for one session. The `prepare` stage
materializes them under `inputs/`; configured consumers use the prepared files,
never the original campaign or session source paths. Field definitions and
source IDs are in [Configuration](./config.md#notarius-reference-bindings).
## Standard Session Workflow
@@ -75,8 +80,8 @@ Canonical stage order:
4. `polish`
5. `normalize`
6. `trim`
7. `extract`
8. `render`
7. `render`
8. `extract`
9. `analyze`
10. `publish`
11. `notify`
@@ -85,12 +90,12 @@ Execution rules:
- succeeded stages are skipped unless `--force` is set;
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
- forcing an upstream stage marks succeeded downstream stages as `stale` before
the replacement runs; and
- forcing a stage marks succeeded transitive dependents as `stale` before the
replacement runs; render and extract are independent siblings; and
- an executed failure, changed self-skip, or success that replaces a different
effective upstream outcome also marks succeeded downstream stages stale. A
effective outcome uses the same fixed dependency relation. A
repeated self-skip with the same reason and no outputs is stable and does not
perpetually rerun downstream work.
perpetually rerun dependent work.
An explicit self-skip is a durable `skipped` stage outcome that later runs
reconsider. It differs from successful no-output execution: disabled `render`
@@ -106,14 +111,81 @@ Single-stage execution:
narratio run-stage normalize 2026-04-04 --force
```
Contiguous bounded execution uses inclusive canonical endpoints:
```bash
narratio session plan 2026-04-04 --from extract --through analyze --force
narratio run 2026-04-04 --from extract --through analyze --force
```
Omitting `--from` selects from `prepare`; omitting `--through` selects through
`notify`. Force applies only within the selected range. Repeating `--from`,
`--through`, or `--force` is rejected instead of resolving by argument order.
The plan command uses the same selection contract and prints only the selected
range. Planning is read-only: it clones the loaded manifest, models selected
stage transitions and invalidation in memory, and invokes resume validation
without writing the manifest, creating run directories, materializing files,
or invoking pipeline adapters. Analyze detail separates explicit targets,
prerequisite rebuilds, scheduled execution, and current reuse. This lets a
coarsely stale aggregate analyze stage show zero artifact executions when its
selected artifact evidence is still semantically current.
Before a bounded run or plan whose range starts after `prepare`, every excluded
prefix stage must already have a session-manifest status of `succeeded` or
`skipped`. Narratio reports the first absent, pending, running, failed, stale,
or interrupted prerequisite without creating a run record or changing session
state. Widen `--from` to include that stage, or recover it explicitly before
retrying. Excluded prefix stages are not resume-validated or repaired as part
of the bounded invocation; selected stages still reject missing, unsafe, or
manifest-inconsistent inputs at their owning boundary.
Stages after `--through` are not prerequisites and are never scheduled by the
bounded invocation. A selected forced stage can mark one of those succeeded
dependents stale through the fixed invalidation relation, but the dependent
does not execute until a later invocation selects it. Production composition
likewise initializes only collaborators needed by the selected range and
shared session lifecycle. In particular, render does not require Notarius or
Scriptorium, extract does not require Scriptorium, and analyze does not require
the transcription, Seriatim, Audita, or Notarius adapters.
For the common post-transcript development loop, use:
```bash
narratio regenerate-artifacts 2026-04-04
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
```
This command is a transparent expansion to a forced bounded `run` from
`extract` through `analyze`. Extraction always rebuilds its complete configured
bundle. Analysis rebuilds the selected targets and their required analysis
prerequisites, or uses the normal default selection when no artifact names are
given. The command does not run publish or notify; delivery remains a separate
operator action.
Inspect current artifact evidence, then publish explicitly when the regenerated
set is ready:
```bash
narratio session artifacts 2026-04-04
narratio publish 2026-04-04
```
If planning or execution reports stale, missing, failed, legacy, or tampered
analysis evidence, regenerate the affected target instead of copying an older
canonical file into place or editing the manifest. See
[Troubleshooting: Analysis artifact evidence is not current](./troubleshooting.md#analysis-artifact-evidence-is-not-current).
## Artifact Selection
`--artifacts` can be used on `run`, `run-stage`, `analyze`, and `publish`.
`--artifacts` can be used on `run`, `session plan`, `run-stage`, `analyze`, and
`publish`. For a bounded run or plan, the selected range must contain `analyze`
or `publish`.
Selection behavior:
- validates names against `pipeline.scriptorium.artifacts`;
- filters analyze execution to selected configured artifacts;
- selects explicit analyze targets and permits their required configured
prerequisites to be reused or rebuilt first;
- filters publish rules for `narratio.artifact.<name>` sources only;
- does not suppress built-in transcript, bounds, or explicitly configured
`narratio.extraction.<name>` publish sources; and
@@ -135,6 +207,31 @@ The directory is immutable once promoted. Configured lanes become
the bundle and `index.json` are retained for audit and resume validation but
are not selectable or published implicitly.
Configured Notarius references resolve only from the current manifest-backed
prepared inputs. Their canonical locations are `inputs/party.yml`,
`inputs/players.yml`, `inputs/glossary.yml`, and, when configured,
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
prepared files cannot change the bytes consumed by an in-flight invocation.
Inspect the effective stable-input inventory and
prepared-file readiness with:
```bash
narratio session status 2026-04-04
narratio session validate 2026-04-04
```
Reference metadata records selector, source ID, session-relative path,
checksum, and byte size, but never payload contents. Changing a prepared
reference changes extraction identity: ordinary continuation rejects the old
result, reruns Notarius, and marks successful downstream stages stale. If the
prepared file is missing or inconsistent with its manifest checksum, repair
the source configuration and refresh prepared state first:
```bash
narratio run-stage prepare 2026-04-04 --force
```
Starting a replacement clears the previous extraction payload from the current
session-stage record. If that replacement fails or self-skips, the current
record does not fall back to the earlier outputs. The earlier run manifest and
@@ -181,11 +278,12 @@ To intentionally replace the current extraction result, run:
narratio run-stage extract 2026-04-04 --force
```
Narratio automatically reruns extraction when its recorded invocation contract
or durable output validation changes. It cannot fingerprint configuration
files, profiles, prompts, modules, or references loaded transitively by
Notarius. Force extraction after changing any of those inputs, even when the
top-level Narratio and Notarius config paths remain the same. A forced extract
Narratio automatically reruns extraction when its recorded invocation contract,
prepared Narratio reference identities, or durable output validation changes.
It cannot fingerprint configuration files, profiles, prompts, modules, or
other references loaded transitively by Notarius itself. Force extraction after
changing any of those inputs, even when the top-level Narratio and Notarius
config paths remain the same. A forced extract
marks successful downstream stages stale. Ordinary extraction failures or
outcome changes also stale affected downstream stages, while an identical
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
@@ -405,7 +503,11 @@ Rules:
- `pipeline.workspace.cleanup_after_publish=true`
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
reports incomplete, the remote committed snapshot remains current; rerun
Narratio to retry only the outstanding confined local cleanup.
publish to retry only the outstanding confined local cleanup.
Post-publish cleanup is evaluated only when `publish` actually executes in the
current invocation. A bounded range that excludes publish does not replay a
cleanup obligation as an unrelated side effect.
## Operational Caveats

View File

@@ -29,6 +29,10 @@ in the [integration documentation](../integrations/).
The pipeline has one canonical ordered stage set. Configuration may enable,
disable, or parameterize supported behavior, but it must not turn that sequence
into an arbitrary DAG or hide orchestration in generic workflow abstractions.
An invocation selects either the full sequence or one inclusive contiguous
range of it. Execution remains flat and canonical even though invalidation is
dependency-aware: the application owns a separate fixed relation used only to
stale transitive dependents, including dependents outside a selected range.
The implemented stage inventory belongs in the
[Internal Overview](../internal/overview.md).
@@ -87,8 +91,10 @@ merely on incidental files existing on disk.
A failed or interrupted stage must not be presented as successful. Failure
should preserve enough local state and diagnostics for inspection, recovery,
and resume. Forcing an upstream stage invalidates succeeded downstream work
according to the canonical stage order.
and resume. Forcing a stage invalidates succeeded transitive dependents
according to a fixed application-owned relation that is separate from canonical
execution order. The relation is validated against the stage inventory and is
not configurable.
A stage may explicitly self-skip with a stable reason and no outputs. That
outcome is persisted, clears older outputs owned by the stage, and is

7
docs/releases/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Release Notes
This directory contains the maintained release-note text for Narratio releases.
The corresponding Gitea release is the canonical source for downloadable
binaries and checksums.
- [v1.5.0](v1.5.0.md)

47
docs/releases/v1.5.0.md Normal file
View File

@@ -0,0 +1,47 @@
# Narratio v1.5.0
Narratio v1.5.0 makes repeated post-transcript artifact development faster and
more explicit while retaining the fixed, stage-driven pipeline model.
## Highlights
- The canonical pipeline now completes deterministic rendering before
extraction, cleanly separating transcript-generating stages from
artifact-generating stages.
- `narratio run` and `narratio session plan` accept inclusive `--from` and
`--through` bounds. Excluded transcript stages are not executed or
invalidated by a bounded artifact-regeneration run.
- `narratio regenerate-artifacts SESSION` is an exact convenience alias for a
forced run from `extract` through `analyze`, including focused
`--artifacts` selections.
- Configured Scriptorium artifacts now have independent,
manifest-authoritative freshness. Narratio reuses validated current work,
rebuilds stale prerequisites in dependency order, and persists successful,
failed, and newly stale artifact state when an analysis invocation only
partially succeeds.
- Publish consumes only configured artifacts backed by current manifest
evidence; incidental or tampered files are not promoted as current output.
## Reliability And Administration
- Bounded prerequisites are checked again under the session lock before any
run mutation, closing a concurrent-run race.
- Analysis fingerprints are stable across executable and configuration path
changes and continue to cover only Narratio-observable semantic inputs.
- Runner composition now carries one validated execution plan from command
parsing through prerequisite validation, adapter composition, manifest
recording, and stage execution.
- `narratio version` reports the exact tag embedded in official release
binaries; ordinary source builds report `dev`.
## Upgrade Notes
- Existing unbounded commands and direct `run-stage`, `analyze`, and `publish`
workflows retain their meanings.
- Manifests written before artifact-level analysis state remain readable.
Legacy aggregate analysis success is not sufficient freshness evidence, so
the first analysis evaluation after upgrading may regenerate configured
artifacts once.
- Narratio cannot observe executable contents or configuration, prompt,
profile, module, and other files loaded privately by Scriptorium. Explicitly
force affected artifacts after changing those private inputs.

View File

@@ -117,6 +117,40 @@ Safe fix:
Relevant reference: [CLI artifact selection](./cli.md).
## Bounded run prerequisite is unusable
Symptom:
- `run` or `session plan` reports that a prerequisite stage is absent or has a
pending, running, failed, stale, or interrupted status before the selected
start.
Likely cause:
- `--from` excludes upstream work that has not reached the terminal
`succeeded` or `skipped` state in the session manifest.
Diagnostics:
```bash
narratio session status 2026-04-04
narratio session plan 2026-04-04 --from render --through analyze
```
Safe fix:
- widen the bounded range to include the first reported stage, or recover that
stage explicitly with `run-stage` before retrying. The failed check does not
create a run record or modify the manifest. Narratio does not resume-validate
excluded prefix stages, and stages after `--through` are not prerequisites.
If prerequisite statuses are terminal but a selected stage reports a missing,
unsafe, or checksum-inconsistent artifact, repair the artifact at the stage
that owns it; do not edit the manifest to bypass the selected stage's concrete
input validation.
Relevant reference: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior).
## Notarius executable missing
Symptom:
@@ -158,6 +192,66 @@ is expected audit state, not a signal to relink the old bundle manually.
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
## Prepared Notarius reference missing or inconsistent
Symptom:
- extraction or resume validation reports that a configured reference source is
unavailable, unsafe, empty, or checksum-inconsistent and recommends
`prepare --force`.
Likely causes:
- `prepare` has not run since the campaign/session stable input changed;
- the configured source file is missing;
- a prepared `inputs/` file or its manifest record was modified independently;
- a spell-catalog binding exists without an effective `spell_catalog_file`.
Diagnostics:
```bash
narratio session status 2026-04-04
narratio session validate 2026-04-04
```
Safe fix:
- correct the campaign/session input path, then refresh canonical prepared
evidence before extraction:
```bash
narratio run-stage prepare 2026-04-04 --force
```
Do not point Notarius directly at the original source path or edit the manifest
checksum. Relevant references: [Notarius reference configuration](./config.md#notarius-reference-bindings)
and [Operations: Extraction Workflow](./operations.md#extraction-workflow).
## Notarius reference selector or generated-handoff collision
Symptom:
- Notarius exits nonzero with an undeclared reference-slot, incompatible media,
or external/generated reference collision error.
Likely causes:
- a selector does not identify a slot declared by the selected Notarius target;
- a prepared file does not satisfy that slot's Notarius media contract; or
- a CLI binding attempts to replace a same-run generated D&D handoff.
Safe fix:
- compare external bindings with the selected Notarius pipeline's canonical
consumer documentation;
- keep only campaign-owned external slots on the CLI; and
- leave registry, scene, combat, and occurrence handoffs to Notarius pipeline
composition.
Narratio validates selector structure and prepared evidence, while Notarius
owns slot declarations, media compatibility, and generated-handoff conflicts.
Relevant reference: [Notarius integration](./integrations/notarius.md).
## Atomic Notarius promotion unsupported
Symptom:
@@ -198,7 +292,7 @@ Safe fix:
- compare installed Notarius output with the canonical Notarius contracts,
including receipt `index_file: index.json` and index management names
`manifest.json`, `rejected.json`, and `warnings.json`; align
`manifest.json`, `rejected.json`, `warnings.json`, and `diagnostics.json`; align
`pipeline.notarius` constraints and rerun. Do not bypass confinement or schema
checks.
@@ -230,6 +324,8 @@ Likely causes:
- the executable/config path, pipeline ID, timeout, working directory, or
configured output contracts changed;
- a configured prepared reference selector, source, path, checksum, or byte
size changed;
- the durable bundle, index, lane set, provenance, regular-file status, or
checksum no longer validates.
@@ -252,12 +348,110 @@ Safe fix:
narratio run-stage extract 2026-04-04 --force
```
Narratio fingerprints its invocation contract, not the contents of transitive
Notarius inputs. Always force extraction after changing them; downstream
Narratio fingerprints its invocation contract and prepared Narratio reference
identities, not the contents of other transitive Notarius inputs. Always force
extraction after changing those external inputs; downstream
successful stages are then marked stale normally.
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
## Analysis artifact evidence is not current
Symptom:
- ordinary continuation or `session plan` schedules one or more configured
artifacts even though a canonical output file exists; or
- publish reports a configured artifact source unavailable.
Likely causes:
- the per-artifact record is stale, missing, failed, unselected, malformed, or
from the legacy aggregate-only manifest contract;
- a configured prompt/profile, dependency, input identity, output path, or
effective variable changed; or
- the recorded output is missing, unsafe, empty, or has a size/checksum that no
longer matches its manifest evidence.
Diagnostics:
```bash
narratio session status 2026-04-04
narratio session artifacts 2026-04-04
narratio session plan 2026-04-04 --from analyze --through analyze
```
Safe fix:
- investigate unexpected path or checksum changes as possible tampering;
- otherwise let the selected analyze work rerun, or explicitly regenerate only
the affected targets; and
- never edit the fingerprint/checksum in the manifest or copy an old file into
the canonical path as a substitute for current evidence.
```bash
narratio analyze 2026-04-04 --artifacts session_recap
```
Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection)
and [Artifact Internals](./internal/artifacts.md#resolution-rules).
## Legacy aggregate analysis requires regeneration
Symptom:
- a manifest from an older Narratio version reports aggregate analyze success
and the old files are present, but configured artifact sources remain
unavailable.
Likely cause:
- the manifest has no supported per-artifact analyze state. Aggregate output
lists do not establish current configured-artifact authority.
Safe fix:
- regenerate the required artifacts. A partial selection makes only its
targets and prerequisites eligible for current state; unselected legacy
files intentionally remain unavailable. Run full analysis later when every
enabled configured artifact must become current.
```bash
narratio analyze 2026-04-04 --artifacts session_recap
narratio analyze 2026-04-04
```
After current records exist, inspect them and publish explicitly. Do not delete
the legacy files merely to influence selection; availability is manifest-owned.
Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior)
and [Manifest Internals](./internal/manifest.md#analyze-owned-artifact-state).
## Scriptorium private input changed without a rerun
Symptom:
- a prompt, profile, imported configuration file, executable, or other input
loaded privately by Scriptorium changed, but Narratio still considers an
artifact current.
Likely cause:
- analysis fingerprints cover Narratio-observable semantic identities, not
executable contents or arbitrary files and transitive configuration that
Scriptorium loads behind its configured paths and identifiers.
Safe fix:
- explicitly force the affected target after changing an unobserved private
input. Force applies to explicit targets; current prerequisites remain
reusable unless selected themselves.
```bash
narratio analyze 2026-04-04 --artifacts session_recap
```
Relevant reference: [Analyze Internals](./internal/stage-analyze.md#invariants).
## Previous-session artifact input missing
Symptom:

View File

@@ -39,7 +39,9 @@ with the sample campaign and a compatible local- or S3-audio session.
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
[glossary](campaigns/sample-campaign/glossary.yml),
[players](campaigns/sample-campaign/players.yml), and
[party](campaigns/sample-campaign/party.yml) fixtures.
[party](campaigns/sample-campaign/party.yml) fixtures, plus an optional
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
follows the Notarius v0.6 contract.
- [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
reserves the expected filename and directory shape. Replace it with a real
FLAC file before running transcription.

View File

@@ -6,3 +6,4 @@ inputs:
glossary_file: ./glossary.yml
players_file: ./players.yml
party_file: ./party.yml
spell_catalog_file: ./spell_catalog.json

View File

@@ -0,0 +1,18 @@
{
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
"catalogs": [
{
"id": "narratio.sample-campaign",
"ruleset": "dnd-5e-2014",
"source": {
"title": "Narratio sample campaign spell names"
},
"spells": [
{
"name": "Aegis of Emberfall",
"aliases": ["Emberfall Aegis"]
}
]
}
]
}

View File

@@ -14,6 +14,11 @@ notarius:
config_path: /usr/local/etc/notarius/config.yml
pipeline_id: dnd-session
timeout: 3h
references:
glossary: narratio.input.glossary
party: narratio.input.party
players: narratio.input.players
spell_catalog: narratio.input.spell_catalog
outputs:
npc_registry:
lane_id: npc-registry
@@ -52,4 +57,3 @@ scriptorium:
scenes:
source: narratio.extraction.scene_descriptions
required: true

View File

@@ -136,6 +136,13 @@ notarius:
pipeline_id: dnd-session
timeout: 3h
working_directory: /usr/local/etc/notarius
# External campaign references use prepared Narratio source IDs. Omit an
# optional binding when the selected Notarius pipeline does not need it.
references:
glossary: narratio.input.glossary
party: narratio.input.party
players: narratio.input.players
spell_catalog: narratio.input.spell_catalog
# Each key creates source narratio.extraction.<key>. These constraints match
# the current Notarius D&D lane contracts; update them with Notarius.
outputs:

View File

@@ -14,7 +14,9 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error)
if err := ctx.Err(); err != nil {
return RunResult{}, err
}
f.Requests = append(f.Requests, req)
copyRequest := req
copyRequest.References = append([]ReferenceBinding(nil), req.References...)
f.Requests = append(f.Requests, copyRequest)
if f.Err != nil {
return RunResult{}, f.Err
}

View File

@@ -6,13 +6,20 @@ import (
"time"
)
const ReceiptSchemaVersion = "notarius.run-result.v1"
const ReceiptSchemaVersion = "notarius.run-result.v2"
// Runner is the adapter boundary for a complete Notarius pipeline invocation.
type Runner interface {
Run(ctx context.Context, req RunRequest) (RunResult, error)
}
// ReferenceBinding maps one normalized Notarius selector to an absolute
// external reference path.
type ReferenceBinding struct {
Selector string
Path string
}
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
type RunRequest struct {
Binary string
@@ -24,20 +31,41 @@ type RunRequest struct {
ReceiptPath string
LogPath string
Timeout time.Duration
References []ReferenceBinding
}
// Receipt is the transport-neutral successful run receipt.
type Receipt struct {
SchemaVersion string
RunID string
PipelineID string
OutputDirectory string
IndexFile string
NormalizedOutputCount int
RejectedOutputCount int
WarningCount int
ValidationStatus string
DebugDirectory string
SchemaVersion string
RunID string
PipelineID string
OutputDirectory string
IndexFile string
NormalizedOutputCount int
RejectedOutputCount int
WarningGroupCount int
WarningOccurrenceCount int
DiagnosticGroupCount int
DiagnosticOccurrenceCount int
DiagnosticsTruncated bool
ValidationStatus string
ValidationSummaries []ValidationSummary
DebugDirectory string
}
// ValidationSummary retains the bounded outcome of one Notarius producer result.
type ValidationSummary struct {
Stage string
StepID string
LaneID string
ModuleKey string
ChunkID string
Status string
RejectingValidators []string
ReasonCodes []string
IncompleteValidators []string
ProducerAttemptCount int
TerminalAction string
}
// LaneDescriptor identifies one normalized lane payload discovered through the index.
@@ -72,6 +100,8 @@ type Index struct {
RejectedPath string
WarningsFile string
WarningsPath string
DiagnosticsFile string
DiagnosticsPath string
Lanes []LaneDescriptor
ChunkMap *PipelineDescriptor
EvidenceContext *PipelineDescriptor
@@ -90,8 +120,29 @@ type RejectionSummary struct {
// WarningSummary retains structured warning identity without free-form messages.
type WarningSummary struct {
Scope string
ReasonCode string
Disposition string
Category string
ReasonCode string
Origin DiagnosticOrigin
OccurrenceCount int
}
// DiagnosticOrigin identifies the framework-owned pipeline location of a finding.
type DiagnosticOrigin struct {
Stage string
StepID string
LaneID string
ModuleKey string
ValidatorKey string
}
// DiagnosticSummary retains bounded advisory or observation group metadata.
type DiagnosticSummary struct {
Disposition string
Category string
ReasonCode string
Origin DiagnosticOrigin
OccurrenceCount int
}
// RunResult describes a successfully decoded and validated Notarius bundle.
@@ -105,4 +156,5 @@ type RunResult struct {
Duration time.Duration
Rejections []RejectionSummary
Warnings []WarningSummary
Diagnostics []DiagnosticSummary
}

View File

@@ -11,17 +11,24 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
const (
maxReceiptBytes = 1 << 20
maxIndexBytes = 4 << 20
maxSummaryBytes = 4 << 20
canonicalIndexFile = "index.json"
canonicalManifestFile = "manifest.json"
canonicalRejectedFile = "rejected.json"
canonicalWarningsFile = "warnings.json"
maxReceiptBytes = 1 << 20
maxIndexBytes = 4 << 20
maxSummaryBytes = 4 << 20
canonicalIndexFile = "index.json"
canonicalManifestFile = "manifest.json"
canonicalRejectedFile = "rejected.json"
canonicalWarningsFile = "warnings.json"
canonicalDiagnosticsFile = "diagnostics.json"
warningsSchemaVersion = "notarius.warnings.v2"
diagnosticsSchemaVersion = "notarius.diagnostics.v1"
maxWarningGroups = 128
maxDiagnosticGroups = 256
maxFindingSamples = 3
)
type subprocessRun func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error)
@@ -41,7 +48,8 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
if r == nil || r.run == nil {
return RunResult{}, fmt.Errorf("notarius subprocess runner is nil")
}
if err := validateRunRequest(req); err != nil {
references, err := validateRunRequest(req)
if err != nil {
return RunResult{}, err
}
@@ -50,8 +58,11 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
"--config", req.ConfigPath,
"--input", req.InputPath,
"--output-dir", req.OutputRoot,
"--json",
}
for _, reference := range references {
args = append(args, "--reference", reference.Selector+"="+reference.Path)
}
args = append(args, "--json")
processResult, err := r.run(ctx, subprocess.RunRequest{
Executable: req.Binary,
Args: args,
@@ -95,24 +106,42 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
if err != nil {
return baseResult, err
}
diagnostics, diagnosticOccurrences, diagnosticsTruncated, err := loadDiagnostics(index.DiagnosticsPath)
if err != nil {
return baseResult, err
}
if receipt.NormalizedOutputCount != len(index.Lanes) || receipt.RejectedOutputCount != len(rejections) ||
receipt.WarningGroupCount != len(warnings) || receipt.DiagnosticGroupCount != len(diagnostics) {
return baseResult, fmt.Errorf("notarius receipt counts do not match published bundle")
}
warningOccurrences, err := sumWarningOccurrences(warnings)
if err != nil {
return baseResult, err
}
if receipt.WarningOccurrenceCount != warningOccurrences ||
receipt.DiagnosticOccurrenceCount != diagnosticOccurrences ||
receipt.DiagnosticsTruncated != diagnosticsTruncated {
return baseResult, fmt.Errorf("notarius receipt occurrence counts do not match published bundle")
}
baseResult.Receipt = receipt
baseResult.Index = index
baseResult.BundleRoot = bundleRoot
baseResult.Rejections = rejections
baseResult.Warnings = warnings
baseResult.Diagnostics = diagnostics
return baseResult, nil
}
func validateRunRequest(req RunRequest) error {
func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
if strings.TrimSpace(req.Binary) == "" {
return fmt.Errorf("notarius binary is required")
return nil, fmt.Errorf("notarius binary is required")
}
if strings.TrimSpace(req.PipelineID) == "" {
return fmt.Errorf("notarius pipeline id is required")
return nil, fmt.Errorf("notarius pipeline id is required")
}
if req.Timeout <= 0 {
return fmt.Errorf("notarius timeout must be positive")
return nil, fmt.Errorf("notarius timeout must be positive")
}
for label, path := range map[string]string{
"config": req.ConfigPath,
@@ -123,47 +152,124 @@ func validateRunRequest(req RunRequest) error {
"log": req.LogPath,
} {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("notarius %s path is required", label)
return nil, fmt.Errorf("notarius %s path is required", label)
}
if !filepath.IsAbs(path) {
return fmt.Errorf("notarius %s path must be absolute", label)
return nil, fmt.Errorf("notarius %s path must be absolute", label)
}
}
if filepath.Clean(req.ReceiptPath) == filepath.Clean(req.LogPath) {
return fmt.Errorf("notarius receipt and log paths must be different")
return nil, fmt.Errorf("notarius receipt and log paths must be different")
}
references := make([]ReferenceBinding, 0, len(req.References))
selectors := make(map[string]struct{}, len(req.References))
for index, binding := range req.References {
selector, err := notariusref.NormalizeSelector(binding.Selector)
if err != nil {
return nil, fmt.Errorf("notarius reference %d selector: %w", index, err)
}
if _, duplicate := selectors[selector]; duplicate {
return nil, fmt.Errorf("notarius reference selector %q is duplicated", selector)
}
selectors[selector] = struct{}{}
if strings.TrimSpace(binding.Path) == "" {
return nil, fmt.Errorf("notarius reference %q path is required", selector)
}
if !filepath.IsAbs(binding.Path) {
return nil, fmt.Errorf("notarius reference %q path must be absolute", selector)
}
references = append(references, ReferenceBinding{Selector: selector, Path: binding.Path})
}
if err := requireRegularFile(req.ConfigPath); err != nil {
return fmt.Errorf("validate notarius config path: %w", err)
return nil, fmt.Errorf("validate notarius config path: %w", err)
}
if err := requireRegularFile(req.InputPath); err != nil {
return fmt.Errorf("validate notarius input path: %w", err)
return nil, fmt.Errorf("validate notarius input path: %w", err)
}
if err := requireDirectory(req.OutputRoot); err != nil {
return fmt.Errorf("validate notarius output root: %w", err)
return nil, fmt.Errorf("validate notarius output root: %w", err)
}
if err := requireDirectory(req.WorkingDirectory); err != nil {
return fmt.Errorf("validate notarius working directory: %w", err)
return nil, fmt.Errorf("validate notarius working directory: %w", err)
}
if err := validateLogDestination(req.ReceiptPath); err != nil {
return fmt.Errorf("validate notarius receipt path: %w", err)
return nil, fmt.Errorf("validate notarius receipt path: %w", err)
}
if err := validateLogDestination(req.LogPath); err != nil {
return fmt.Errorf("validate notarius log path: %w", err)
return nil, fmt.Errorf("validate notarius log path: %w", err)
}
return nil
return references, nil
}
type receiptDocument struct {
SchemaVersion string `json:"schema_version"`
RunID string `json:"run_id"`
PipelineID string `json:"pipeline_id"`
OutputDirectory string `json:"output_directory"`
IndexFile string `json:"index_file"`
NormalizedOutputCount *int `json:"normalized_output_count"`
RejectedOutputCount *int `json:"rejected_output_count"`
WarningCount *int `json:"warning_count"`
ValidationStatus string `json:"validation_status"`
DebugDirectory string `json:"debug_directory"`
SchemaVersion string `json:"schema_version"`
RunID string `json:"run_id"`
PipelineID string `json:"pipeline_id"`
OutputDirectory string `json:"output_directory"`
IndexFile string `json:"index_file"`
NormalizedOutputCount *int `json:"normalized_output_count"`
RejectedOutputCount *int `json:"rejected_output_count"`
WarningGroupCount *int `json:"warning_group_count"`
WarningOccurrenceCount *int `json:"warning_occurrence_count"`
DiagnosticGroupCount *int `json:"diagnostic_group_count"`
DiagnosticOccurrenceCount *int `json:"diagnostic_occurrence_count"`
DiagnosticsTruncated *bool `json:"diagnostics_truncated"`
ValidationStatus string `json:"validation_status"`
ValidationSummaries []validationSummaryDocument `json:"validation_summaries"`
DebugDirectory string `json:"debug_directory"`
}
type validationSummaryDocument struct {
Stage string `json:"stage"`
StepID string `json:"step_id"`
LaneID string `json:"lane_id"`
ModuleKey string `json:"module_key"`
ChunkID string `json:"chunk_id"`
Status string `json:"status"`
RejectingValidators []string `json:"rejecting_validators"`
ReasonCodes []string `json:"reason_codes"`
IncompleteValidators []string `json:"incomplete_validators"`
ProducerAttemptCount *int `json:"producer_attempt_count"`
TerminalAction string `json:"terminal_action"`
}
func validValidationStatus(value string) bool {
switch value {
case "approved", "rejected", "incomplete":
return true
default:
return false
}
}
func validateValidationSummaries(documents []validationSummaryDocument) ([]ValidationSummary, error) {
summaries := make([]ValidationSummary, 0, len(documents))
for _, document := range documents {
if document.Status != "complete" && document.Status != "rejected" && document.Status != "incomplete" {
return nil, fmt.Errorf("notarius validation summary status %q is invalid", document.Status)
}
if document.ProducerAttemptCount == nil || *document.ProducerAttemptCount <= 0 || !validTerminalAction(document.TerminalAction) {
return nil, fmt.Errorf("notarius validation summary is missing required fields")
}
summaries = append(summaries, ValidationSummary{
Stage: document.Stage, StepID: document.StepID, LaneID: document.LaneID,
ModuleKey: document.ModuleKey, ChunkID: document.ChunkID, Status: document.Status,
RejectingValidators: append([]string(nil), document.RejectingValidators...),
ReasonCodes: append([]string(nil), document.ReasonCodes...),
IncompleteValidators: append([]string(nil), document.IncompleteValidators...),
ProducerAttemptCount: *document.ProducerAttemptCount, TerminalAction: document.TerminalAction,
})
}
return summaries, nil
}
func validTerminalAction(value string) bool {
switch value {
case "accepted", "reject_output", "warn_continue", "fail_run":
return true
default:
return false
}
}
func loadReceipt(path, pipelineID string) (Receipt, error) {
@@ -177,7 +283,9 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
if strings.TrimSpace(document.RunID) == "" || strings.TrimSpace(document.PipelineID) == "" ||
strings.TrimSpace(document.OutputDirectory) == "" || strings.TrimSpace(document.ValidationStatus) == "" ||
document.NormalizedOutputCount == nil ||
document.RejectedOutputCount == nil || document.WarningCount == nil {
document.RejectedOutputCount == nil || document.WarningGroupCount == nil ||
document.WarningOccurrenceCount == nil || document.DiagnosticGroupCount == nil ||
document.DiagnosticOccurrenceCount == nil || document.DiagnosticsTruncated == nil {
return Receipt{}, fmt.Errorf("notarius receipt is missing required fields")
}
if document.IndexFile != canonicalIndexFile {
@@ -186,9 +294,18 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
if document.PipelineID != pipelineID {
return Receipt{}, fmt.Errorf("notarius receipt pipeline id %q does not match requested pipeline %q", document.PipelineID, pipelineID)
}
if *document.NormalizedOutputCount < 0 || *document.RejectedOutputCount < 0 || *document.WarningCount < 0 {
if *document.NormalizedOutputCount < 0 || *document.RejectedOutputCount < 0 ||
*document.WarningGroupCount < 0 || *document.WarningOccurrenceCount < 0 ||
*document.DiagnosticGroupCount < 0 || *document.DiagnosticOccurrenceCount < 0 {
return Receipt{}, fmt.Errorf("notarius receipt counts must be non-negative")
}
if !validValidationStatus(document.ValidationStatus) {
return Receipt{}, fmt.Errorf("notarius receipt validation_status %q is invalid", document.ValidationStatus)
}
validationSummaries, err := validateValidationSummaries(document.ValidationSummaries)
if err != nil {
return Receipt{}, err
}
if !filepath.IsAbs(document.OutputDirectory) {
return Receipt{}, fmt.Errorf("notarius receipt output directory must be absolute")
}
@@ -196,16 +313,21 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
return Receipt{}, fmt.Errorf("notarius receipt debug directory must be absolute when present")
}
return Receipt{
SchemaVersion: document.SchemaVersion,
RunID: document.RunID,
PipelineID: document.PipelineID,
OutputDirectory: filepath.Clean(document.OutputDirectory),
IndexFile: document.IndexFile,
NormalizedOutputCount: *document.NormalizedOutputCount,
RejectedOutputCount: *document.RejectedOutputCount,
WarningCount: *document.WarningCount,
ValidationStatus: document.ValidationStatus,
DebugDirectory: document.DebugDirectory,
SchemaVersion: document.SchemaVersion,
RunID: document.RunID,
PipelineID: document.PipelineID,
OutputDirectory: filepath.Clean(document.OutputDirectory),
IndexFile: document.IndexFile,
NormalizedOutputCount: *document.NormalizedOutputCount,
RejectedOutputCount: *document.RejectedOutputCount,
WarningGroupCount: *document.WarningGroupCount,
WarningOccurrenceCount: *document.WarningOccurrenceCount,
DiagnosticGroupCount: *document.DiagnosticGroupCount,
DiagnosticOccurrenceCount: *document.DiagnosticOccurrenceCount,
DiagnosticsTruncated: *document.DiagnosticsTruncated,
ValidationStatus: document.ValidationStatus,
ValidationSummaries: validationSummaries,
DebugDirectory: document.DebugDirectory,
}, nil
}
@@ -214,6 +336,7 @@ type indexDocument struct {
OutputFiles *[]laneDocument `json:"output_files"`
RejectedFile string `json:"rejected_file"`
WarningsFile string `json:"warnings_file"`
DiagnosticsFile string `json:"diagnostics_file"`
ChunkMap *pipelineDocument `json:"chunk_map"`
EvidenceContext *pipelineDocument `json:"evidence_context"`
}
@@ -250,6 +373,7 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
{name: "manifest_file", got: document.ManifestFile, want: canonicalManifestFile},
{name: "rejected_file", got: document.RejectedFile, want: canonicalRejectedFile},
{name: "warnings_file", got: document.WarningsFile, want: canonicalWarningsFile},
{name: "diagnostics_file", got: document.DiagnosticsFile, want: canonicalDiagnosticsFile},
} {
if field.got != field.want {
return Index{}, fmt.Errorf("notarius index %s %q is incompatible; want %q", field.name, field.got, field.want)
@@ -260,10 +384,11 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
}
index := Index{
Path: indexPath,
ManifestFile: document.ManifestFile,
RejectedFile: document.RejectedFile,
WarningsFile: document.WarningsFile,
Path: indexPath,
ManifestFile: document.ManifestFile,
RejectedFile: document.RejectedFile,
WarningsFile: document.WarningsFile,
DiagnosticsFile: document.DiagnosticsFile,
}
var err error
if index.ManifestPath, err = resolveRegularFile(bundleRoot, index.ManifestFile); err != nil {
@@ -275,6 +400,9 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
if index.WarningsPath, err = resolveRegularFile(bundleRoot, index.WarningsFile); err != nil {
return Index{}, fmt.Errorf("resolve notarius warning file: %w", err)
}
if index.DiagnosticsPath, err = resolveRegularFile(bundleRoot, index.DiagnosticsFile); err != nil {
return Index{}, fmt.Errorf("resolve notarius diagnostics file: %w", err)
}
seenLanes := make(map[string]struct{}, len(*document.OutputFiles))
for _, lane := range *document.OutputFiles {
@@ -362,12 +490,34 @@ func loadRejections(path string) ([]RejectionSummary, error) {
return summaries, nil
}
type warningDocument struct {
Warnings *[]struct {
type findingGroupDocument struct {
Disposition string `json:"disposition"`
Category string `json:"category"`
ReasonCode string `json:"reason_code"`
Origin diagnosticOriginDocument `json:"origin"`
OccurrenceCount *int `json:"occurrence_count"`
Samples *[]struct {
Scope string `json:"scope"`
ReasonCode string `json:"reason_code"`
Message string `json:"message"`
} `json:"warnings"`
ChunkID string `json:"chunk_id"`
ChunkIndex *int `json:"chunk_index"`
} `json:"samples"`
OmittedSampleCount *int `json:"omitted_sample_count"`
}
type diagnosticOriginDocument struct {
Stage string `json:"stage"`
StepID string `json:"step_id"`
LaneID string `json:"lane_id"`
ModuleKey string `json:"module_key"`
ValidatorKey string `json:"validator_key"`
}
type warningDocument struct {
SchemaVersion string `json:"schema_version"`
GroupCount *int `json:"group_count"`
OccurrenceCount *int `json:"occurrence_count"`
Groups *[]findingGroupDocument `json:"groups"`
}
func loadWarnings(path string) ([]WarningSummary, error) {
@@ -375,19 +525,151 @@ func loadWarnings(path string) ([]WarningSummary, error) {
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
return nil, fmt.Errorf("decode notarius warnings: %w", err)
}
if document.Warnings == nil {
return nil, fmt.Errorf("notarius warning document is missing warnings array")
if document.SchemaVersion != warningsSchemaVersion || document.GroupCount == nil ||
document.OccurrenceCount == nil || document.Groups == nil {
return nil, fmt.Errorf("notarius warning document is missing or incompatible required fields")
}
summaries := make([]WarningSummary, 0, len(*document.Warnings))
for _, item := range *document.Warnings {
if strings.TrimSpace(item.ReasonCode) == "" || strings.TrimSpace(item.Message) == "" {
return nil, fmt.Errorf("notarius warning entries require reason_code and message")
if *document.GroupCount < 0 || *document.GroupCount > maxWarningGroups || *document.OccurrenceCount < 0 ||
*document.GroupCount != len(*document.Groups) {
return nil, fmt.Errorf("notarius warning document counts are inconsistent")
}
summaries := make([]WarningSummary, 0, len(*document.Groups))
occurrences := 0
for _, group := range *document.Groups {
if err := validateFindingGroup(group); err != nil {
return nil, fmt.Errorf("notarius warning group: %w", err)
}
summaries = append(summaries, WarningSummary{Scope: item.Scope, ReasonCode: item.ReasonCode})
if group.Disposition != "warning" {
return nil, fmt.Errorf("notarius warning group disposition %q is invalid", group.Disposition)
}
if *group.OccurrenceCount > int(^uint(0)>>1)-occurrences {
return nil, fmt.Errorf("notarius warning occurrence count overflows")
}
occurrences += *group.OccurrenceCount
summaries = append(summaries, WarningSummary{
Disposition: group.Disposition, Category: group.Category, ReasonCode: group.ReasonCode,
Origin: diagnosticOrigin(group.Origin), OccurrenceCount: *group.OccurrenceCount,
})
}
if occurrences != *document.OccurrenceCount {
return nil, fmt.Errorf("notarius warning document occurrence count is inconsistent")
}
return summaries, nil
}
type diagnosticDocument struct {
SchemaVersion string `json:"schema_version"`
GroupCount *int `json:"group_count"`
OccurrenceCount *int `json:"occurrence_count"`
Truncated *bool `json:"truncated"`
UnrepresentedOccurrenceCount *int `json:"unrepresented_occurrence_count"`
Groups *[]findingGroupDocument `json:"groups"`
}
func loadDiagnostics(path string) ([]DiagnosticSummary, int, bool, error) {
var document diagnosticDocument
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
return nil, 0, false, fmt.Errorf("decode notarius diagnostics: %w", err)
}
if document.SchemaVersion != diagnosticsSchemaVersion || document.GroupCount == nil ||
document.OccurrenceCount == nil || document.Truncated == nil ||
document.UnrepresentedOccurrenceCount == nil || document.Groups == nil {
return nil, 0, false, fmt.Errorf("notarius diagnostics document is missing or incompatible required fields")
}
if *document.GroupCount < 0 || *document.GroupCount > maxDiagnosticGroups || *document.OccurrenceCount < 0 ||
*document.UnrepresentedOccurrenceCount < 0 || *document.GroupCount != len(*document.Groups) {
return nil, 0, false, fmt.Errorf("notarius diagnostics document counts are inconsistent")
}
if !*document.Truncated && *document.UnrepresentedOccurrenceCount != 0 {
return nil, 0, false, fmt.Errorf("notarius diagnostics document has unrepresented occurrences without truncation")
}
summaries := make([]DiagnosticSummary, 0, len(*document.Groups))
representedOccurrences := 0
for _, group := range *document.Groups {
if err := validateFindingGroup(group); err != nil {
return nil, 0, false, fmt.Errorf("notarius diagnostic group: %w", err)
}
if group.Disposition != "advisory" && group.Disposition != "observation" {
return nil, 0, false, fmt.Errorf("notarius diagnostic group disposition %q is invalid", group.Disposition)
}
if *group.OccurrenceCount > int(^uint(0)>>1)-representedOccurrences {
return nil, 0, false, fmt.Errorf("notarius diagnostic occurrence count overflows")
}
representedOccurrences += *group.OccurrenceCount
summaries = append(summaries, DiagnosticSummary{
Disposition: group.Disposition, Category: group.Category, ReasonCode: group.ReasonCode,
Origin: diagnosticOrigin(group.Origin), OccurrenceCount: *group.OccurrenceCount,
})
}
if *document.UnrepresentedOccurrenceCount > int(^uint(0)>>1)-representedOccurrences ||
representedOccurrences+*document.UnrepresentedOccurrenceCount != *document.OccurrenceCount {
return nil, 0, false, fmt.Errorf("notarius diagnostics document occurrence count is inconsistent")
}
return summaries, *document.OccurrenceCount, *document.Truncated, nil
}
func validateFindingGroup(group findingGroupDocument) error {
if strings.TrimSpace(group.Disposition) == "" || strings.TrimSpace(group.Category) == "" ||
strings.TrimSpace(group.ReasonCode) == "" || !validDiagnosticOriginStage(group.Origin.Stage) ||
!validDiagnosticCategory(group.Disposition, group.Category) ||
group.OccurrenceCount == nil || *group.OccurrenceCount <= 0 || group.Samples == nil ||
group.OmittedSampleCount == nil || *group.OmittedSampleCount < 0 {
return fmt.Errorf("missing required fields")
}
if len(*group.Samples) == 0 || len(*group.Samples) > maxFindingSamples ||
*group.OmittedSampleCount != *group.OccurrenceCount-len(*group.Samples) {
return fmt.Errorf("sample counts are inconsistent")
}
for _, sample := range *group.Samples {
if strings.TrimSpace(sample.Scope) == "" || strings.TrimSpace(sample.Message) == "" ||
(sample.ChunkIndex != nil && *sample.ChunkIndex < 0) {
return fmt.Errorf("samples require scope and message")
}
}
return nil
}
func validDiagnosticCategory(disposition, category string) bool {
switch disposition {
case "warning":
return category == "configuration" || category == "degradation" ||
category == "validation_incomplete" || category == "fallback"
case "advisory":
return category == "data_quality"
case "observation":
return category == "normalization"
default:
return false
}
}
func validDiagnosticOriginStage(stage string) bool {
switch stage {
case "references", "chunk", "extract", "merge", "normalize":
return true
default:
return false
}
}
func diagnosticOrigin(document diagnosticOriginDocument) DiagnosticOrigin {
return DiagnosticOrigin{
Stage: document.Stage, StepID: document.StepID, LaneID: document.LaneID,
ModuleKey: document.ModuleKey, ValidatorKey: document.ValidatorKey,
}
}
func sumWarningOccurrences(values []WarningSummary) (int, error) {
total := 0
for _, value := range values {
if value.OccurrenceCount > int(^uint(0)>>1)-total {
return 0, fmt.Errorf("notarius warning occurrence count overflows")
}
total += value.OccurrenceCount
}
return total, nil
}
func decodeBoundedJSON(path string, limit int64, destination any) error {
data, err := fileops.ReadRegularFile(path, limit)
if err != nil {

View File

@@ -52,6 +52,10 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
if result.Receipt.SchemaVersion != ReceiptSchemaVersion || result.Receipt.RunID != "notarius-run-1" {
t.Fatalf("receipt = %#v", result.Receipt)
}
if len(result.Receipt.ValidationSummaries) != 1 || result.Receipt.ValidationSummaries[0].LaneID != "npc-registry" ||
result.Receipt.ValidationSummaries[0].Status != "complete" {
t.Fatalf("validation summaries = %#v", result.Receipt.ValidationSummaries)
}
if len(result.Index.Lanes) != 1 || result.Index.Lanes[0].LaneID != "npc-registry" {
t.Fatalf("lanes = %#v", result.Index.Lanes)
}
@@ -64,9 +68,107 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
if len(result.Rejections) != 1 || result.Rejections[0].LaneID != "spells" || result.Rejections[0].ReasonCode != "invalid_spell" {
t.Fatalf("rejections = %#v", result.Rejections)
}
if len(result.Warnings) != 1 || result.Warnings[0].Scope != "lane:npc-registry" || result.Warnings[0].ReasonCode != "normalized_name" {
if len(result.Warnings) != 1 || result.Warnings[0].Category != "degradation" || result.Warnings[0].ReasonCode != "normalized_name" {
t.Fatalf("warnings = %#v", result.Warnings)
}
if len(result.Diagnostics) != 1 || result.Diagnostics[0].Category != "data_quality" || result.Diagnostics[0].ReasonCode != "low_confidence" {
t.Fatalf("diagnostics = %#v", result.Diagnostics)
}
}
func TestSubprocessRunnerBuildsOrderedReferenceArguments(t *testing.T) {
req := validRunRequest(t)
referenceRoot := t.TempDir()
req.References = []ReferenceBinding{
{Selector: " party ", Path: filepath.Join(referenceRoot, "party context=primary.json")},
{Selector: " npc-registry . extract . glossary ", Path: filepath.Join(referenceRoot, "glossary.json")},
}
originalReferences := append([]ReferenceBinding(nil), req.References...)
var captured sharedsubprocess.RunRequest
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
captured = processReq
writeValidBundleAndReceipt(t, req, false)
return sharedsubprocess.RunResult{ExitCode: 0}, nil
}}
if _, err := runner.Run(context.Background(), req); err != nil {
t.Fatalf("Run() error = %v", err)
}
wantArgs := []string{
"run", req.PipelineID,
"--config", req.ConfigPath,
"--input", req.InputPath,
"--output-dir", req.OutputRoot,
"--reference", "party=" + req.References[0].Path,
"--reference", "npc-registry.extract.glossary=" + req.References[1].Path,
"--json",
}
if !reflect.DeepEqual(captured.Args, wantArgs) {
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs)
}
if !reflect.DeepEqual(req.References, originalReferences) {
t.Fatalf("Run() mutated caller references = %#v, want %#v", req.References, originalReferences)
}
}
func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
tests := []struct {
name string
references func(string) []ReferenceBinding
wantErr string
}{
{
name: "invalid selector",
references: func(root string) []ReferenceBinding {
return []ReferenceBinding{{Selector: "lane.prepare.party", Path: filepath.Join(root, "party.json")}}
},
wantErr: "selector",
},
{
name: "duplicate normalized selector",
references: func(root string) []ReferenceBinding {
return []ReferenceBinding{
{Selector: "lane.party", Path: filepath.Join(root, "party.json")},
{Selector: " lane . party ", Path: filepath.Join(root, "party-2.json")},
}
},
wantErr: "duplicated",
},
{
name: "empty path",
references: func(string) []ReferenceBinding {
return []ReferenceBinding{{Selector: "party", Path: " "}}
},
wantErr: "path is required",
},
{
name: "relative path",
references: func(string) []ReferenceBinding {
return []ReferenceBinding{{Selector: "party", Path: "references/party.json"}}
},
wantErr: "path must be absolute",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := validRunRequest(t)
req.References = tt.references(t.TempDir())
started := false
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
started = true
return sharedsubprocess.RunResult{}, nil
}}
_, err := runner.Run(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Run() error = %v, want containing %q", err, tt.wantErr)
}
if started {
t.Fatal("subprocess started after request validation failure")
}
})
}
}
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
@@ -171,8 +273,10 @@ func TestLoadReceiptValidation(t *testing.T) {
valid := map[string]any{
"schema_version": ReceiptSchemaVersion, "run_id": "run-1", "pipeline_id": "pipeline-1",
"output_directory": filepath.Join(root, "outputs", "run-1"), "index_file": "index.json",
"normalized_output_count": 1, "rejected_output_count": 0, "warning_count": 0,
"validation_status": "approved", "future_field": true,
"normalized_output_count": 1, "rejected_output_count": 0,
"warning_group_count": 0, "warning_occurrence_count": 0,
"diagnostic_group_count": 0, "diagnostic_occurrence_count": 0,
"diagnostics_truncated": false, "validation_status": "approved", "future_field": true,
}
tests := []struct {
name string
@@ -183,11 +287,12 @@ func TestLoadReceiptValidation(t *testing.T) {
}{
{name: "unknown fields tolerated", wantOK: true},
{name: "malformed", raw: []byte("{")},
{name: "unsupported version", mutate: func(v map[string]any) { v["schema_version"] = "notarius.run-result.v2" }},
{name: "unsupported version", mutate: func(v map[string]any) { v["schema_version"] = "notarius.run-result.v1" }},
{name: "missing field", mutate: func(v map[string]any) { delete(v, "run_id") }},
{name: "pipeline mismatch", mutate: func(v map[string]any) { v["pipeline_id"] = "other" }},
{name: "relative output", mutate: func(v map[string]any) { v["output_directory"] = "run-1" }},
{name: "negative count", mutate: func(v map[string]any) { v["warning_count"] = -1 }},
{name: "negative count", mutate: func(v map[string]any) { v["warning_group_count"] = -1 }},
{name: "invalid validation status", mutate: func(v map[string]any) { v["validation_status"] = "valid" }},
{
name: "nested index", mutate: func(v map[string]any) { v["index_file"] = "nested/index.json" },
wantError: `index_file "nested/index.json"`,
@@ -369,22 +474,26 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
root := t.TempDir()
rejectedPath := filepath.Join(root, "rejected.json")
warningsPath := filepath.Join(root, "warnings.json")
diagnosticsPath := filepath.Join(root, "diagnostics.json")
writeJSONFile(t, rejectedPath, map[string]any{"rejected": []any{map[string]any{
"stage": "validate", "lane_id": "spells", "reason_code": "invalid", "message": "do not retain this", "future": true,
}}, "future": true})
writeJSONFile(t, warningsPath, map[string]any{"warnings": []any{map[string]any{
"scope": "lane:spells", "reason_code": "bounded", "message": "do not retain this", "future": true,
}}, "future": true})
writeJSONFile(t, warningsPath, findingEnvelope(warningsSchemaVersion, []any{findingGroup("warning", "degradation", "bounded", "normalize", 2)}, 2, false, 0))
writeJSONFile(t, diagnosticsPath, findingEnvelope(diagnosticsSchemaVersion, []any{findingGroup("advisory", "data_quality", "low_confidence", "normalize", 3)}, 4, true, 1))
rejections, err := loadRejections(rejectedPath)
if err != nil || len(rejections) != 1 || rejections[0].ReasonCode != "invalid" {
t.Fatalf("loadRejections() = %#v, %v", rejections, err)
}
warnings, err := loadWarnings(warningsPath)
if err != nil || len(warnings) != 1 || warnings[0].Scope != "lane:spells" {
if err != nil || len(warnings) != 1 || warnings[0].Category != "degradation" || warnings[0].OccurrenceCount != 2 {
t.Fatalf("loadWarnings() = %#v, %v", warnings, err)
}
diagnostics, occurrences, truncated, err := loadDiagnostics(diagnosticsPath)
if err != nil || len(diagnostics) != 1 || occurrences != 4 || !truncated || diagnostics[0].Category != "data_quality" {
t.Fatalf("loadDiagnostics() = %#v, %d, %t, %v", diagnostics, occurrences, truncated, err)
}
for name, path := range map[string]string{"rejections": rejectedPath, "warnings": warningsPath} {
for name, path := range map[string]string{"rejections": rejectedPath, "warnings": warningsPath, "diagnostics": diagnosticsPath} {
t.Run("malformed "+name, func(t *testing.T) {
if err := os.WriteFile(path, []byte("{"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
@@ -392,8 +501,10 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
var err error
if name == "rejections" {
_, err = loadRejections(path)
} else {
} else if name == "warnings" {
_, err = loadWarnings(path)
} else {
_, _, _, err = loadDiagnostics(path)
}
if err == nil {
t.Fatal("summary decoder error = nil")
@@ -410,16 +521,23 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
if _, err := loadRejections(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("loadRejections(oversized) error = %v", err)
}
if _, _, _, err := loadDiagnostics(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("loadDiagnostics(oversized) error = %v", err)
}
}
func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
req := RunRequest{PipelineID: "pipeline"}
req := RunRequest{PipelineID: "pipeline", References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}}
want := RunResult{BundleRoot: "/bundle"}
fake := &FakeRunner{Result: want}
got, err := fake.Run(context.Background(), req)
if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{req}) {
t.Fatalf("Run() = %#v, %v; requests = %#v", got, err, fake.Requests)
}
req.References[0].Path = "/references/changed.json"
if fake.Requests[0].References[0].Path != "/references/party.json" {
t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References)
}
wantErr := errors.New("configured failure")
fake.Err = wantErr
@@ -478,13 +596,12 @@ func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown boo
}
}
rejection := map[string]any{"stage": "validate", "lane_id": "spells", "reason_code": "invalid_spell", "message": strings.Repeat("external detail", 20)}
warning := map[string]any{"scope": "lane:npc-registry", "reason_code": "normalized_name", "message": strings.Repeat("external warning", 20)}
if includeUnknown {
rejection["future"] = true
warning["future"] = true
}
writeJSONFile(t, filepath.Join(bundle, "rejected.json"), map[string]any{"rejected": []any{rejection}, "future": true})
writeJSONFile(t, filepath.Join(bundle, "warnings.json"), map[string]any{"warnings": []any{warning}, "future": true})
writeJSONFile(t, filepath.Join(bundle, "warnings.json"), findingEnvelope(warningsSchemaVersion, []any{findingGroup("warning", "degradation", "normalized_name", "normalize", 2)}, 2, false, 0))
writeJSONFile(t, filepath.Join(bundle, "diagnostics.json"), findingEnvelope(diagnosticsSchemaVersion, []any{findingGroup("advisory", "data_quality", "low_confidence", "normalize", 3)}, 4, true, 1))
index := validIndexValue([]any{map[string]any{
"lane_id": "npc-registry", "file": "lanes/npc.json", "media_type": "application/json",
"module_key": "dnd/npc-registry", "schema_id": "notarius.dnd.npc_registry",
@@ -503,7 +620,13 @@ func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown boo
receipt := map[string]any{
"schema_version": ReceiptSchemaVersion, "run_id": "notarius-run-1", "pipeline_id": req.PipelineID,
"output_directory": bundle, "index_file": "index.json", "normalized_output_count": 1,
"rejected_output_count": 1, "warning_count": 1, "validation_status": "rejected",
"rejected_output_count": 1, "warning_group_count": 1, "warning_occurrence_count": 2,
"diagnostic_group_count": 1, "diagnostic_occurrence_count": 4,
"diagnostics_truncated": true, "validation_status": "rejected",
"validation_summaries": []any{map[string]any{
"stage": "normalize", "lane_id": "npc-registry", "status": "complete",
"producer_attempt_count": 1, "terminal_action": "accepted",
}},
}
if includeUnknown {
receipt["future"] = true
@@ -517,7 +640,7 @@ func createBundleSkeleton(t *testing.T) string {
if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil {
t.Fatalf("MkdirAll(bundle) error = %v", err)
}
for _, name := range []string{"manifest.json", "rejected.json", "warnings.json", "lanes/npc.json", "chunk-map.json"} {
for _, name := range []string{"manifest.json", "rejected.json", "warnings.json", "diagnostics.json", "lanes/npc.json", "chunk-map.json"} {
if err := os.WriteFile(filepath.Join(bundle, filepath.FromSlash(name)), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile(%q) error = %v", name, err)
}
@@ -528,10 +651,31 @@ func createBundleSkeleton(t *testing.T) string {
func validIndexValue(lanes []any) map[string]any {
return map[string]any{
"manifest_file": "manifest.json", "output_files": lanes,
"rejected_file": "rejected.json", "warnings_file": "warnings.json",
"rejected_file": "rejected.json", "warnings_file": "warnings.json", "diagnostics_file": "diagnostics.json",
}
}
func findingGroup(disposition, category, reasonCode, origin string, occurrences int) map[string]any {
return map[string]any{
"disposition": disposition, "category": category, "reason_code": reasonCode,
"origin": map[string]any{"stage": origin, "lane_id": "npc-registry"}, "occurrence_count": occurrences,
"samples": []any{map[string]any{"scope": "lane:npc-registry", "message": "external detail"}},
"omitted_sample_count": occurrences - 1,
}
}
func findingEnvelope(schema string, groups []any, occurrences int, truncated bool, unrepresented int) map[string]any {
value := map[string]any{
"schema_version": schema, "group_count": len(groups), "occurrence_count": occurrences,
"groups": groups,
}
if schema == diagnosticsSchemaVersion {
value["truncated"] = truncated
value["unrepresented_occurrence_count"] = unrepresented
}
return value
}
func writeJSONFile(t *testing.T, path string, value any) {
t.Helper()
data, err := json.Marshal(value)

View File

@@ -1,3 +1,2 @@
// Package subprocess provides reusable process execution and generated-config helpers.
package subprocess

View File

@@ -11,7 +11,6 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
@@ -43,8 +42,8 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
for _, s := range stages {
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
for _, s := range plan.Stages() {
capturedStages = append(capturedStages, s.Name())
}
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
@@ -115,8 +114,8 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
if err != nil {
t.Fatalf("RunStage() error = %v", err)
}
if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") {
t.Fatalf("output = %q, want analyze skip without force", out.String())
if !strings.Contains(out.String(), "stage=analyze executed=1 skipped=0 force=false") {
t.Fatalf("output = %q, want legacy analyze evidence rebuilt without implying force", out.String())
}
}
@@ -144,8 +143,8 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "executed=1 skipped=11") {
t.Fatalf("output = %q, want all stages skipped", out.String())
if !strings.Contains(out.String(), "executed=4 skipped=8") {
t.Fatalf("output = %q, want extract reconsidered and legacy analyze plus delivery rebuilt", out.String())
}
}
@@ -159,8 +158,8 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
for _, s := range stages {
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
for _, s := range plan.Stages() {
capturedStages = append(capturedStages, s.Name())
}
capturedForce = opts.Force
@@ -200,7 +199,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(_ context.Context, _ *config.Config, _ []stage.Stage, opts RunOptions) (*RunSummary, error) {
executeStagesFn = func(_ context.Context, _ *config.Config, _ BoundedPlan, opts RunOptions) (*RunSummary, error) {
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
}
@@ -293,8 +292,8 @@ func TestExecutePublishForceRunsPublish(t *testing.T) {
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
for _, s := range stages {
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
for _, s := range plan.Stages() {
capturedStages = append(capturedStages, s.Name())
}
capturedForce = opts.Force

View File

@@ -0,0 +1,37 @@
package app
import (
"crypto/sha256"
"encoding/hex"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func setAppAnalyzeEvidence(m *manifest.Manifest, key, relativePath string, body []byte) {
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC)
record := m.Stages["analyze"]
if record == nil {
record = &manifest.StageRecord{Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now}
m.Stages["analyze"] = record
}
if record.AnalyzeArtifacts == nil {
record.AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{}
}
digest := sha256.Sum256(body)
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
record.AnalyzeArtifacts[key] = manifest.AnalyzeArtifactRecord{
Key: key, Status: manifest.AnalyzeArtifactCurrent,
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("1", 64),
Output: &manifest.ArtifactRecord{
Kind: "scriptorium_artifact", SourceID: artifacts.ConfiguredArtifactSourceID(key), LocalPath: relativePath,
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
ProducerRunID: "run-1", Checksum: hex.EncodeToString(digest[:]),
},
OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now,
}
}

View File

@@ -0,0 +1,134 @@
package app
import (
"fmt"
"reflect"
"sort"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
type validatedAnalyzeProjection struct {
session map[string]manifest.AnalyzeArtifactRecord
invocation map[string]manifest.AnalyzeArtifactRecord
}
type analyzeStateSnapshot struct {
version int
records map[string]manifest.AnalyzeArtifactRecord
}
func captureAnalyzeState(manifestValue *manifest.Manifest, stageName string) analyzeStateSnapshot {
if manifestValue == nil || stageName != "analyze" || manifestValue.Stages["analyze"] == nil {
return analyzeStateSnapshot{}
}
record := manifestValue.Stages["analyze"]
return analyzeStateSnapshot{
version: record.AnalyzeStateVersion,
records: manifest.CloneAnalyzeArtifactCollection(record.AnalyzeArtifacts),
}
}
func restoreAnalyzeState(manifestValue *manifest.Manifest, snapshot analyzeStateSnapshot) {
if manifestValue == nil || manifestValue.Stages["analyze"] == nil {
return
}
record := manifestValue.Stages["analyze"]
record.AnalyzeStateVersion = snapshot.version
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(snapshot.records)
}
func validateSuccessfulAnalyzeProjection(stageName string, result *stage.StageResult) (*validatedAnalyzeProjection, error) {
if result == nil || result.AnalyzeState == nil {
return nil, nil
}
if stageName != "analyze" {
return nil, fmt.Errorf("stage %q returned analyze-owned state projection", stageName)
}
if result.Disposition == stage.StageDispositionSkipped {
return nil, fmt.Errorf("skipped analyze result cannot contain analyze-owned state projection")
}
if len(result.Outputs) != 0 {
return nil, fmt.Errorf("analyze result with state projection cannot contain ordinary outputs")
}
return validateAndCloneAnalyzeProjection(result.AnalyzeState)
}
func validateFailedAnalyzeProjection(stageName string, result *stage.StageResult) (*validatedAnalyzeProjection, error) {
if result == nil || result.AnalyzeState == nil {
return nil, nil
}
if stageName != "analyze" {
return nil, fmt.Errorf("stage %q returned analyze-owned state projection with an error", stageName)
}
if result.Disposition != stage.StageDispositionSucceeded || result.SkipReason != "" || len(result.Outputs) != 0 || len(result.Logs) != 0 || len(result.GeneratedConfigs) != 0 || len(result.Metadata) != 0 {
return nil, fmt.Errorf("analyze result with an error may contain only analyze-owned state projection")
}
return validateAndCloneAnalyzeProjection(result.AnalyzeState)
}
func validateAndCloneAnalyzeProjection(projection *stage.AnalyzeStateProjection) (*validatedAnalyzeProjection, error) {
session := manifest.CloneAnalyzeArtifactCollection(projection.Session)
invocation := manifest.CloneAnalyzeArtifactCollection(projection.Invocation)
if err := manifest.ValidateAnalyzeArtifactCollection(manifest.AnalyzeStateContractVersion, session); err != nil {
return nil, fmt.Errorf("validate reconciled session analyze state: %w", err)
}
if err := manifest.ValidateAnalyzeArtifactCollection(manifest.AnalyzeStateContractVersion, invocation); err != nil {
return nil, fmt.Errorf("validate invocation analyze state: %w", err)
}
for key, invocationRecord := range invocation {
sessionRecord, ok := session[key]
if !ok {
return nil, fmt.Errorf("invocation analyze artifact %q is absent from reconciled session state", key)
}
if !reflect.DeepEqual(invocationRecord, sessionRecord) {
return nil, fmt.Errorf("invocation analyze artifact %q contradicts reconciled session state", key)
}
}
return &validatedAnalyzeProjection{session: session, invocation: invocation}, nil
}
func applyAnalyzeProjection(
sessionManifest *manifest.Manifest,
runManifest *manifest.RunManifest,
projection *validatedAnalyzeProjection,
) {
if projection == nil {
return
}
if sessionManifest != nil && sessionManifest.Stages["analyze"] != nil {
record := sessionManifest.Stages["analyze"]
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(projection.session)
}
if runManifest != nil && runManifest.Stages["analyze"] != nil {
record := runManifest.Stages["analyze"]
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(projection.invocation)
}
}
func analyzeProjectionOutputs(records map[string]manifest.AnalyzeArtifactRecord, producerRunID string) []manifest.ArtifactRecord {
keys := make([]string, 0, len(records))
for key, record := range records {
if record.Status != manifest.AnalyzeArtifactCurrent || record.Output == nil {
continue
}
if producerRunID != "" && record.ProducerRunID != producerRunID {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
outputs := make([]manifest.ArtifactRecord, 0, len(keys))
for _, key := range keys {
record := manifest.CloneAnalyzeArtifactCollection(map[string]manifest.AnalyzeArtifactRecord{key: records[key]})[key]
output := *record.Output
if output.ProducerRunID == "" {
output.ProducerRunID = record.ProducerRunID
}
outputs = append(outputs, output)
}
return outputs
}

View File

@@ -0,0 +1,346 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
type projectionStage struct {
name string
run func(*stage.Env, *manifest.Manifest) (*stage.StageResult, error)
}
func (s projectionStage) Name() string { return s.name }
func (s projectionStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
return s.run(env, m)
}
func TestExecuteStagesProjectsSeparateSessionAndInvocationAnalyzeState(t *testing.T) {
cfg := testConfig(t)
oldAt := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
oldRecord := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", oldAt)
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
newRecord := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
staleRecord := appAnalyzeRecord("quest_log", manifest.AnalyzeArtifactStale, m.RunID, time.Now().UTC())
session := map[string]manifest.AnalyzeArtifactRecord{
"player_handout": oldRecord,
"quest_log": staleRecord,
"session_recap": newRecord,
}
return &stage.StageResult{
Logs: []string{"aggregate-analyze.log"},
AnalyzeState: &stage.AnalyzeStateProjection{
Session: session,
Invocation: map[string]manifest.AnalyzeArtifactRecord{
"player_handout": oldRecord,
"session_recap": newRecord,
},
},
}, nil
}}
summary, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
store := &manifest.LocalStore{}
sessionManifest, err := store.Load(context.Background(), summary.ManifestPath)
if err != nil {
t.Fatalf("Load(session) error = %v", err)
}
analyze := sessionManifest.Stages["analyze"]
if analyze.AnalyzeStateVersion != manifest.AnalyzeStateContractVersion || len(analyze.AnalyzeArtifacts) != 3 {
t.Fatalf("session analyze state = %#v", analyze)
}
if got := analyzeArtifactOutputKeys(analyze.Outputs); !reflect.DeepEqual(got, []string{"player_handout", "session_recap"}) {
t.Fatalf("session aggregate outputs = %#v, want current records only", got)
}
if len(analyze.Logs) != 1 || analyze.Logs[0] != "aggregate-analyze.log" {
t.Fatalf("session aggregate logs = %#v", analyze.Logs)
}
runManifest, err := store.LoadRun(context.Background(), summary.RunManifestPath)
if err != nil {
t.Fatalf("LoadRun() error = %v", err)
}
runAnalyze := runManifest.Stages["analyze"]
if len(runAnalyze.AnalyzeArtifacts) != 2 || runAnalyze.AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("invocation analyze state = %#v", runAnalyze.AnalyzeArtifacts)
}
if got := analyzeArtifactOutputKeys(runAnalyze.Outputs); !reflect.DeepEqual(got, []string{"session_recap"}) {
t.Fatalf("invocation outputs = %#v, want produced artifact only", got)
}
}
func TestExecuteStagesPersistsRestrictedAnalyzeStateOnPartialError(t *testing.T) {
cfg := testConfig(t)
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
seed := manifest.New(cfg.Session.SessionID, now)
seed.Campaign = cfg.Session.Campaign
seed.MarkStageSucceeded("analyze", now, nil)
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{
"player_handout": appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now),
}
seed.MarkStageSucceeded("publish", now, nil)
saveBoundedManifest(t, cfg, seed)
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
unrelated := seed.Stages["analyze"].AnalyzeArtifacts["player_handout"]
completed := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
failed := appAnalyzeRecord("quest_log", manifest.AnalyzeArtifactFailed, m.RunID, time.Now().UTC())
session := map[string]manifest.AnalyzeArtifactRecord{
"player_handout": unrelated,
"quest_log": failed,
"session_recap": completed,
}
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
Session: session,
Invocation: map[string]manifest.AnalyzeArtifactRecord{
"quest_log": failed,
"session_recap": completed,
},
}}, errors.New("quest log failed")
}}
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: true})
if err == nil || !strings.Contains(err.Error(), "quest log failed") {
t.Fatalf("executeStages() error = %v", err)
}
store := &manifest.LocalStore{}
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatalf("Load(session) error = %v", err)
}
analyze := loaded.Stages["analyze"]
if analyze.Status != manifest.StatusFailed || len(analyze.Outputs) != 0 {
t.Fatalf("aggregate analyze state = %#v, want failed without outputs", analyze)
}
if analyze.AnalyzeArtifacts["player_handout"].Status != manifest.AnalyzeArtifactCurrent ||
analyze.AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent ||
analyze.AnalyzeArtifacts["quest_log"].Status != manifest.AnalyzeArtifactFailed {
t.Fatalf("partial session projection = %#v", analyze.AnalyzeArtifacts)
}
if loaded.Stages["publish"].Status != manifest.StatusStale {
t.Fatalf("publish status = %q, want stale", loaded.Stages["publish"].Status)
}
runsDir := artifacts.SessionRunsDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
entries, err := os.ReadDir(runsDir)
if err != nil || len(entries) != 1 {
t.Fatalf("run directory entries = %#v, error = %v", entries, err)
}
runManifest, err := store.LoadRun(context.Background(), filepath.Join(runsDir, entries[0].Name(), "manifest.json"))
if err != nil {
t.Fatalf("LoadRun() error = %v", err)
}
runAnalyze := runManifest.Stages["analyze"]
if runAnalyze.Status != manifest.StatusFailed || len(runAnalyze.AnalyzeArtifacts) != 2 || len(runAnalyze.Outputs) != 0 {
t.Fatalf("partial invocation projection = %#v", runAnalyze)
}
}
func TestExecuteStagesRejectsInvalidAnalyzeProjectionWithoutReplacingPriorState(t *testing.T) {
cfg := testConfig(t)
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
prior := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now)
seed := manifest.New(cfg.Session.SessionID, now)
seed.Campaign = cfg.Session.Campaign
seed.MarkStageSucceeded("analyze", now, nil)
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{"player_handout": prior}
saveBoundedManifest(t, cfg, seed)
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
invalid := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
invalid.Output.Checksum = "invalid"
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": invalid},
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": invalid},
}}, errors.New("analysis failed")
}}
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: true})
if err == nil || !strings.Contains(err.Error(), "checksum") {
t.Fatalf("executeStages() error = %v, want projection validation failure", err)
}
loaded, loadErr := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
if loadErr != nil {
t.Fatalf("Load() error = %v", loadErr)
}
if len(loaded.Stages["analyze"].AnalyzeArtifacts) != 1 || !reflect.DeepEqual(loaded.Stages["analyze"].AnalyzeArtifacts["player_handout"], prior) {
t.Fatalf("prior state replaced by invalid projection: %#v", loaded.Stages["analyze"].AnalyzeArtifacts)
}
}
func TestExecuteStagesRollsBackAnalyzeAuthorityWhenProjectionSaveFails(t *testing.T) {
cfg := testConfig(t)
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
prior := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now)
seed := manifest.New(cfg.Session.SessionID, now)
seed.Campaign = cfg.Session.Campaign
seed.MarkStageSucceeded("analyze", now, nil)
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{"player_handout": prior}
saveBoundedManifest(t, cfg, seed)
stageReturned := false
store := &analyzeProjectionFailingStore{delegate: &manifest.LocalStore{}, shouldFail: func(m *manifest.Manifest) bool {
return stageReturned && m.Stages["analyze"] != nil && m.Stages["analyze"].Status == manifest.StatusSucceeded && m.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status == manifest.AnalyzeArtifactCurrent
}}
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
stageReturned = true
newRecord := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
Session: map[string]manifest.AnalyzeArtifactRecord{
"player_handout": prior,
"session_recap": newRecord,
},
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": newRecord},
}}, nil
}}
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{
Force: true,
Env: &Env{ManifestStore: store},
})
if err == nil || !strings.Contains(err.Error(), "injected analyze projection save failure") {
t.Fatalf("executeStages() error = %v", err)
}
if !store.failed {
t.Fatal("projection persistence failure was not injected")
}
loaded, loadErr := store.delegate.Load(context.Background(), manifestPathFor(cfg))
if loadErr != nil {
t.Fatalf("Load() error = %v", loadErr)
}
analyze := loaded.Stages["analyze"]
if analyze.Status != manifest.StatusFailed || len(analyze.AnalyzeArtifacts) != 1 || !reflect.DeepEqual(analyze.AnalyzeArtifacts["player_handout"], prior) {
t.Fatalf("durable analyze state after rollback = %#v", analyze)
}
}
func TestExecuteStagesRejectsAnalyzeProjectionFromOtherStage(t *testing.T) {
cfg := testConfig(t)
stageToRun := projectionStage{name: "prepare", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
record := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
}}, nil
}}
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
if err == nil || !strings.Contains(err.Error(), "returned analyze-owned state projection") {
t.Fatalf("executeStages() error = %v", err)
}
}
func TestExecuteStagesRejectsContradictoryAnalyzeResultWithError(t *testing.T) {
cfg := testConfig(t)
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
record := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
return &stage.StageResult{
Outputs: []artifacts.Ref{{Kind: "session_recap", RelativePath: "artifacts/session-recap.md"}},
AnalyzeState: &stage.AnalyzeStateProjection{
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
},
}, errors.New("analysis failed")
}}
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
if err == nil || !strings.Contains(err.Error(), "may contain only analyze-owned state projection") {
t.Fatalf("executeStages() error = %v", err)
}
}
func TestExecuteStagesExposesSelectedForceDecisionToStage(t *testing.T) {
for _, force := range []bool{false, true} {
t.Run(strings.ToLower(strings.TrimSpace(map[bool]string{false: "ordinary", true: "forced"}[force])), func(t *testing.T) {
cfg := testConfig(t)
captured := !force
stageToRun := projectionStage{name: "prepare", run: func(env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
captured = env.Force
return &stage.StageResult{}, nil
}}
if _, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: force}); err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if captured != force {
t.Fatalf("stage env force = %v, want %v", captured, force)
}
})
}
}
func appAnalyzeRecord(key string, status manifest.AnalyzeArtifactStatus, producerRunID string, at time.Time) manifest.AnalyzeArtifactRecord {
record := manifest.AnalyzeArtifactRecord{
Key: key,
Status: status,
ProducerRunID: producerRunID,
UpdatedAt: at,
}
if status == manifest.AnalyzeArtifactFailed {
record.Error = "scriptorium failed"
return record
}
if status != manifest.AnalyzeArtifactCurrent {
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
record.Fingerprint = strings.Repeat("b", 64)
return record
}
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
record.Fingerprint = strings.Repeat("a", 64)
record.OutputSize = 42
record.Output = &manifest.ArtifactRecord{
Kind: key,
SourceID: artifacts.ConfiguredArtifactSourceID(key),
LocalPath: "artifacts/" + strings.ReplaceAll(key, "_", "-") + ".md",
ProducerRunID: producerRunID,
Checksum: strings.Repeat("c", 64),
Contract: &artifactmodel.ContractMetadata{
MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1",
},
}
return record
}
func analyzeArtifactOutputKeys(outputs []manifest.ArtifactRecord) []string {
keys := make([]string, 0, len(outputs))
for _, output := range outputs {
keys = append(keys, strings.TrimPrefix(output.SourceID, "narratio.artifact."))
}
return keys
}
type analyzeProjectionFailingStore struct {
delegate *manifest.LocalStore
shouldFail func(*manifest.Manifest) bool
failed bool
}
func (s *analyzeProjectionFailingStore) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
return s.delegate.Create(ctx, sessionID)
}
func (s *analyzeProjectionFailingStore) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
return s.delegate.Load(ctx, path)
}
func (s *analyzeProjectionFailingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
if !s.failed && s.shouldFail != nil && s.shouldFail(m) {
s.failed = true
return errors.New("injected analyze projection save failure")
}
return s.delegate.Save(ctx, path, m)
}

View File

@@ -0,0 +1,295 @@
package app
import (
"bytes"
"context"
"path/filepath"
"reflect"
"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 TestAssembledFullRunUsesCanonicalOrderAndBoundedRunManifests(t *testing.T) {
cfg := testConfig(t)
canonical := []string{
"prepare", "transcribe", "merge", "polish", "normalize", "trim",
"render", "extract", "analyze", "publish", "notify",
}
var order []string
stages := make([]stage.Stage, 0, len(canonical))
for _, name := range canonical {
stages = append(stages, resultStage{name: name, result: &stage.StageResult{}, order: &order})
}
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if !reflect.DeepEqual(order, canonical) || !reflect.DeepEqual(summary.Executed, canonical) {
t.Fatalf("execution order=%#v summary=%#v, want %#v", order, summary.Executed, canonical)
}
runManifest, err := (&manifest.LocalStore{}).LoadRun(context.Background(), summary.RunManifestPath)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(runManifest.RequestedStages, canonical) {
t.Fatalf("requested stages = %#v, want canonical order", runManifest.RequestedStages)
}
}
func TestAssembledCanonicalAndAliasArtifactRegenerationRequestsMatch(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
type capturedRequest struct {
stages []string
artifacts []string
force bool
}
var captured []capturedRequest
original := executeStagesFn
t.Cleanup(func() { executeStagesFn = original })
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
captured = append(captured, capturedRequest{
stages: plan.Names(), artifacts: append([]string(nil), options.SelectedArtifacts...), force: options.Force,
})
return &RunSummary{SessionID: "2026-05-03", ManifestPath: manifestPathForConfig(workspaceRoot)}, nil
}
base := []string{
"2026-05-03", "--force", "--from", "extract", "--through", "analyze",
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
}
if err := Run(context.Background(), base, &bytes.Buffer{}); err != nil {
t.Fatalf("canonical unselected Run() error = %v", err)
}
selected := append(append([]string(nil), base...), "--artifacts", "session_recap")
if err := Run(context.Background(), selected, &bytes.Buffer{}); err != nil {
t.Fatalf("canonical selected Run() error = %v", err)
}
var stdout, stderr bytes.Buffer
alias := []string{
"regenerate-artifacts", "2026-05-03", "--artifacts", "session_recap",
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
}
if code := Execute(alias, &stdout, &stderr); code != 0 {
t.Fatalf("alias exit=%d stderr=%q", code, stderr.String())
}
if len(captured) != 3 {
t.Fatalf("captured requests = %#v", captured)
}
wantStages := []string{"extract", "analyze"}
if !reflect.DeepEqual(captured[0].stages, wantStages) || len(captured[0].artifacts) != 0 || !captured[0].force {
t.Fatalf("unselected request = %#v", captured[0])
}
if !reflect.DeepEqual(captured[1], captured[2]) || !reflect.DeepEqual(captured[1].stages, wantStages) ||
!reflect.DeepEqual(captured[1].artifacts, []string{"session_recap"}) || !captured[1].force {
t.Fatalf("canonical=%#v alias=%#v, want identical bounded request", captured[1], captured[2])
}
}
func TestAssembledForcedSiblingIndependenceAndFailureBoundary(t *testing.T) {
for _, selected := range []string{"render", "extract"} {
t.Run(selected, func(t *testing.T) {
cfg := testConfig(t)
seedAllStagesSucceeded(t, cfg)
plan := mustBoundedPlan(t, selected, selected)
runs := 0
plan.stages = []stage.Stage{countingStage{name: selected, runs: &runs}}
summary, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
if err != nil {
t.Fatal(err)
}
loaded := loadAssembledManifest(t, cfg)
sibling := "render"
if selected == "render" {
sibling = "extract"
}
if loaded.Stages[sibling].Status != manifest.StatusSucceeded {
t.Fatalf("%s sibling = %#v, want succeeded", sibling, loaded.Stages[sibling])
}
for _, dependent := range []string{"analyze", "publish", "notify"} {
if loaded.Stages[dependent].Status != manifest.StatusStale {
t.Fatalf("%s status = %q, want stale", dependent, loaded.Stages[dependent].Status)
}
}
runManifest, err := (&manifest.LocalStore{}).LoadRun(context.Background(), summary.RunManifestPath)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(runManifest.RequestedStages, []string{selected}) || len(runManifest.Stages) != 1 {
t.Fatalf("bounded run manifest = %#v", runManifest)
}
})
}
t.Run("stop on failure", func(t *testing.T) {
cfg := testConfig(t)
seedAllStagesSucceeded(t, cfg)
plan := mustBoundedPlan(t, "render", "render")
plan.stages = []stage.Stage{failingStage{name: "render", err: context.Canceled}}
_, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
if err == nil {
t.Fatal("forced render failure returned nil")
}
loaded := loadAssembledManifest(t, cfg)
if loaded.Stages["extract"].Status != manifest.StatusSucceeded {
t.Fatalf("extract sibling = %#v", loaded.Stages["extract"])
}
for _, outside := range []string{"analyze", "publish", "notify"} {
if loaded.Stages[outside].Status != manifest.StatusStale {
t.Fatalf("outside stage %s = %#v, want stale and unexecuted", outside, loaded.Stages[outside])
}
}
})
}
func TestAssembledLegacyAnalyzeTransitionPublishesOnlyCurrentRecords(t *testing.T) {
cfg := testConfig(t)
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{
"player_handout": {Enabled: true, PromptID: "dnd.player_handout", OutputPath: "artifacts/player_handout.md"},
"session_recap": {Enabled: true, PromptID: "dnd.session_recap", OutputPath: "artifacts/session_recap.md"},
}}
cfg.Pipeline.Storage.Backend = config.StorageBackendS3
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "archive", RootPrefix: "dnd"}
cfg.Pipeline.Publish = &config.PublishConfig{
Enabled: boolPtr(true), UploadRun: boolPtr(true),
Outputs: []config.PublishOutputRule{
{Source: artifacts.ConfiguredArtifactSourceID("player_handout"), Dest: "artifacts/player_handout.md", Required: boolPtr(true)},
{Source: artifacts.ConfiguredArtifactSourceID("session_recap"), Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
},
}
paths, err := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if err != nil {
t.Fatal(err)
}
legacyHandout := []byte("legacy handout\n")
legacyRecap := []byte("legacy recap\n")
mustWriteTestFile(t, filepath.Join(paths.ArtifactsDir, "player_handout.md"), string(legacyHandout))
mustWriteTestFile(t, filepath.Join(paths.ArtifactsDir, "session_recap.md"), string(legacyRecap))
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
m := manifest.New(cfg.Session.SessionID, now)
m.Campaign = cfg.Session.Campaign
for index, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
m.MarkStageSucceeded(name, now.Add(time.Duration(index)*time.Minute), nil)
}
// Historical manifests can show extract completing before render. Status,
// not the old relative timestamps, is the compatibility authority.
m.MarkStageSucceeded("extract", now.Add(10*time.Minute), nil)
m.MarkStageSucceeded("render", now.Add(11*time.Minute), nil)
m.MarkStageSucceeded("analyze", now.Add(12*time.Minute), []manifest.ArtifactRecord{
{Kind: "player_handout", LocalPath: "artifacts/player_handout.md"},
{Kind: "session_recap", LocalPath: "artifacts/session_recap.md"},
})
if err := (&manifest.LocalStore{}).Save(context.Background(), paths.ManifestPath, m); err != nil {
t.Fatal(err)
}
analyze, err := stage.Select("analyze")
if err != nil {
t.Fatal(err)
}
fake := &scriptorium.FakeRunner{}
_, err = executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
SelectedArtifacts: []string{"session_recap"}, Env: &Env{Scriptorium: fake},
})
if err != nil {
t.Fatalf("partial legacy regeneration: %v", err)
}
if len(fake.RunRequests) != 1 || fake.RunRequests[0].PromptID != "dnd.session_recap" {
t.Fatalf("partial requests = %#v", fake.RunRequests)
}
afterPartial := loadAssembledManifest(t, cfg)
if len(afterPartial.Stages["analyze"].AnalyzeArtifacts) != 1 ||
afterPartial.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("partial state = %#v", afterPartial.Stages["analyze"].AnalyzeArtifacts)
}
for _, transcriptStage := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"} {
if afterPartial.Stages[transcriptStage].Status != manifest.StatusSucceeded {
t.Fatalf("legacy transition invalidated %s: %#v", transcriptStage, afterPartial.Stages[transcriptStage])
}
}
configured := artifacts.ConfiguredArtifactDefinitions(cfg.Pipeline.Scriptorium.Artifacts)
effective, err := artifacts.ResolveEffectiveArtifactSet(configured, nil)
if err != nil {
t.Fatal(err)
}
catalog, err := artifacts.BootstrapRuntimeCatalog(configured, effective, nil)
if err != nil {
t.Fatal(err)
}
catalog.HydrateAnalyzeArtifacts(paths, afterPartial, configured)
if entry, ok := catalog.Lookup(artifacts.ConfiguredArtifactSourceID("player_handout")); !ok || entry.Available {
t.Fatalf("legacy unselected handout catalog entry = %#v, present=%v", entry, ok)
}
full, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{Env: &Env{Scriptorium: fake}})
if err != nil {
t.Fatalf("full regeneration: %v", err)
}
if len(fake.RunRequests) != 2 || fake.RunRequests[1].PromptID != "dnd.player_handout" {
t.Fatalf("full requests = %#v, want only missing handout added", fake.RunRequests)
}
fullRun, err := (&manifest.LocalStore{}).LoadRun(context.Background(), full.RunManifestPath)
if err != nil {
t.Fatal(err)
}
if got := analyzeArtifactOutputKeys(fullRun.Stages["analyze"].Outputs); !reflect.DeepEqual(got, []string{"player_handout"}) {
t.Fatalf("full invocation outputs = %#v, want newly generated handout only", got)
}
afterFull := loadAssembledManifest(t, cfg)
for _, key := range []string{"player_handout", "session_recap"} {
if afterFull.Stages["analyze"].AnalyzeArtifacts[key].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("%s state = %#v", key, afterFull.Stages["analyze"].AnalyzeArtifacts[key])
}
}
publish, err := stage.Select("publish")
if err != nil {
t.Fatal(err)
}
remote := &storage.FakeBackend{}
published, err := executeStages(context.Background(), cfg, []stage.Stage{publish}, RunOptions{Env: &Env{ObjectStore: remote}})
if err != nil {
t.Fatalf("publish current records: %v", err)
}
afterPublish := loadAssembledManifest(t, cfg)
if got := afterPublish.Stages["publish"].Metadata["published_files_uploaded"]; got != float64(2) {
t.Fatalf("published files = %#v, want 2", got)
}
publishRun, err := (&manifest.LocalStore{}).LoadRun(context.Background(), published.RunManifestPath)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(publishRun.RequestedStages, []string{"publish"}) || publishRun.Stages["analyze"] != nil {
t.Fatalf("publish run manifest = %#v", publishRun)
}
}
func seedAllStagesSucceeded(t *testing.T, cfg *config.Config) {
t.Helper()
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
m.Campaign = cfg.Session.Campaign
for _, name := range canonicalStageNames() {
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
saveBoundedManifest(t, cfg, m)
}
func loadAssembledManifest(t *testing.T, cfg *config.Config) *manifest.Manifest {
t.Helper()
m, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatal(err)
}
return m
}
func manifestPathForConfig(workspaceRoot string) string {
return artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
}

View File

@@ -0,0 +1,62 @@
package app
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func validateBoundedPrerequisites(plan BoundedPlan, m *manifest.Manifest) error {
if !plan.HasExplicitBounds() {
return nil
}
for _, name := range plan.PrefixNames() {
status := "absent"
if m != nil && m.Stages != nil && m.Stages[name] != nil {
stageStatus := m.Stages[name].Status
if stageStatus == manifest.StatusSucceeded || stageStatus == manifest.StatusSkipped {
continue
}
if stageStatus != "" {
status = string(stageStatus)
}
}
return fmt.Errorf(
"prerequisite stage %q has unusable status %q before selected start %q; widen the range with --from %s or recover %s explicitly",
name,
status,
plan.From(),
name,
name,
)
}
return nil
}
func inspectBoundedPrerequisites(ctx context.Context, cfg *config.Config, plan BoundedPlan, store manifest.Store) error {
if !plan.HasExplicitBounds() || len(plan.PrefixNames()) == 0 {
return nil
}
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
return fmt.Errorf("bounded prerequisite inspection requires resolved pipeline and session configuration")
}
if store == nil {
store = &manifest.LocalStore{}
}
path := artifacts.SessionManifestPathForCampaign(
cfg.Pipeline.Workspace.Root,
cfg.Session.Campaign,
cfg.Session.SessionID,
)
m, present, err := loadManifestAtPathIfPresent(ctx, store, path)
if err != nil {
return err
}
if !present {
m = nil
}
return validateBoundedPrerequisites(plan, m)
}

View File

@@ -0,0 +1,345 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestValidateBoundedPrerequisitesRejectsFirstUnusablePrefixStatus(t *testing.T) {
plan := mustBoundedPlan(t, "render", "extract")
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
for _, test := range []struct {
name string
status manifest.StageStatus
want string
}{
{name: "absent", want: "absent"},
{name: "pending", status: manifest.StatusPending, want: "pending"},
{name: "running", status: manifest.StatusRunning, want: "running"},
{name: "failed", status: manifest.StatusFailed, want: "failed"},
{name: "stale", status: manifest.StatusStale, want: "stale"},
{name: "interrupted", status: manifest.StatusInterrupted, want: "interrupted"},
} {
t.Run(test.name, func(t *testing.T) {
m := manifest.New("session", now)
if test.status != "" {
m.Stages["prepare"] = &manifest.StageRecord{Name: "prepare", Status: test.status}
}
// A later terminal prefix must not hide the first unusable one.
m.MarkStageSucceeded("transcribe", now, nil)
err := validateBoundedPrerequisites(plan, m)
if err == nil {
t.Fatal("validateBoundedPrerequisites() error = nil")
}
for _, detail := range []string{`stage "prepare"`, `status "` + test.want + `"`, `selected start "render"`, "--from prepare", "recover prepare"} {
if !strings.Contains(err.Error(), detail) {
t.Fatalf("error = %q, want detail %q", err, detail)
}
}
})
}
}
func TestValidateBoundedPrerequisitesAcceptsSucceededAndSkippedPrefix(t *testing.T) {
plan := mustBoundedPlan(t, "render", "render")
m := manifest.New("session", time.Now().UTC())
for index, name := range plan.PrefixNames() {
if index%2 == 0 {
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
} else {
m.MarkStageSkipped(name, time.Now().UTC(), "not applicable")
}
}
if err := validateBoundedPrerequisites(plan, m); err != nil {
t.Fatalf("validateBoundedPrerequisites() error = %v", err)
}
}
func TestValidateBoundedPrerequisitesHasNoPrefixAtPrepareAndIgnoresSuffix(t *testing.T) {
preparePlan := mustBoundedPlan(t, "prepare", "prepare")
if err := validateBoundedPrerequisites(preparePlan, nil); err != nil {
t.Fatalf("prepare prerequisite validation error = %v", err)
}
renderPlan := mustBoundedPlan(t, "render", "render")
m := manifest.New("session", time.Now().UTC())
markPrefixSucceeded(m, renderPlan)
m.MarkStageFailed("analyze", time.Now().UTC(), "later failure")
if err := validateBoundedPrerequisites(renderPlan, m); err != nil {
t.Fatalf("suffix status affected prerequisite validation: %v", err)
}
}
func TestExecuteStagesRejectsBoundedPrerequisitesBeforePersistentMutation(t *testing.T) {
cfg := testConfig(t)
plan := mustBoundedPlan(t, "render", "render")
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
m.Campaign = cfg.Session.Campaign
m.MarkStageRunning("prepare", time.Now().UTC())
manifestPath := saveBoundedManifest(t, cfg, m)
before, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("read seeded manifest: %v", err)
}
store := &prerequisiteMutationSpy{local: &manifest.LocalStore{}}
runs := 0
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
_, err = executePlan(context.Background(), cfg, plan, RunOptions{
Env: &Env{ManifestStore: store},
})
if err == nil || !strings.Contains(err.Error(), `stage "prepare" has unusable status "running"`) {
t.Fatalf("executeStages() error = %v, want running prerequisite", err)
}
if runs != 0 || store.creates != 0 || store.saves != 0 {
t.Fatalf("runs=%d manifest creates=%d saves=%d, want no mutation", runs, store.creates, store.saves)
}
after, err := os.ReadFile(manifestPath)
if err != nil {
t.Fatalf("read manifest after rejection: %v", err)
}
if string(after) != string(before) {
t.Fatal("manifest changed after prerequisite rejection")
}
if _, err := os.Stat(artifacts.SessionRunsDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)); !os.IsNotExist(err) {
t.Fatalf("runs directory stat error = %v, want not exist", err)
}
}
func TestExecuteStagesRechecksBoundedPrerequisitesUnderSessionLock(t *testing.T) {
cfg := testConfig(t)
plan := mustBoundedPlan(t, "render", "render")
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
m.Campaign = cfg.Session.Campaign
markPrefixSucceeded(m, plan)
saveBoundedManifest(t, cfg, m)
store := &prerequisiteChangingStore{local: &manifest.LocalStore{}}
runs := 0
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
_, err := executePlan(context.Background(), cfg, plan, RunOptions{
Env: &Env{ManifestStore: store},
})
if err == nil || !strings.Contains(err.Error(), `stage "prepare" has unusable status "running"`) {
t.Fatalf("executeStages() error = %v, want changed prerequisite rejection", err)
}
if store.loads != 2 {
t.Fatalf("manifest loads = %d, want preflight and locked reload", store.loads)
}
if runs != 0 || store.creates != 0 || store.saves != 0 {
t.Fatalf("runs=%d manifest creates=%d saves=%d, want no run or manifest mutation", runs, store.creates, store.saves)
}
}
func TestExecuteStagesBoundedCompositionUsesOnlySelectedCollaborators(t *testing.T) {
for _, test := range []struct {
name string
stageName string
configure func(*config.Config)
assertProbe func(*testing.T, *stage.Env)
}{
{
name: "render",
stageName: "render",
assertProbe: func(t *testing.T, env *stage.Env) {
if env.Seriatim == nil || env.Notarius != nil || env.Scriptorium != nil {
t.Fatalf("render collaborators: seriatim=%v notarius=%v scriptorium=%v", env.Seriatim, env.Notarius, env.Scriptorium)
}
},
},
{
name: "extract",
stageName: "extract",
configure: func(cfg *config.Config) {
cfg.Pipeline.Notarius = &config.NotariusConfig{Enabled: true}
},
assertProbe: func(t *testing.T, env *stage.Env) {
if env.Notarius == nil || env.Scriptorium != nil || env.Seriatim != nil {
t.Fatalf("extract collaborators: notarius=%v scriptorium=%v seriatim=%v", env.Notarius, env.Scriptorium, env.Seriatim)
}
},
},
{
name: "analyze",
stageName: "analyze",
assertProbe: func(t *testing.T, env *stage.Env) {
if env.Scriptorium == nil || env.Notarius != nil || env.Seriatim != nil || env.WhisperX != nil || env.Audita != nil {
t.Fatalf("analyze collaborators: scriptorium=%v notarius=%v seriatim=%v whisperx=%v audita=%v", env.Scriptorium, env.Notarius, env.Seriatim, env.WhisperX, env.Audita)
}
},
},
} {
t.Run(test.name, func(t *testing.T) {
cfg := testConfig(t)
if test.configure != nil {
test.configure(cfg)
}
plan := mustBoundedPlan(t, test.stageName, test.stageName)
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
m.Campaign = cfg.Session.Campaign
markPrefixSucceeded(m, plan)
saveBoundedManifest(t, cfg, m)
var captured *stage.Env
plan.stages = []stage.Stage{collaboratorProbeStage{name: test.stageName, captured: &captured}}
_, err := executePlan(context.Background(), cfg, plan, RunOptions{})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if captured == nil {
t.Fatal("selected stage did not run")
}
test.assertProbe(t, captured)
})
}
}
func TestExecuteStagesBoundedForceStalesButDoesNotRunDependentsOutsideRange(t *testing.T) {
cfg := testConfig(t)
plan := mustBoundedPlan(t, "render", "render")
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
m.Campaign = cfg.Session.Campaign
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
saveBoundedManifest(t, cfg, m)
runs := 0
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
_, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if runs != 1 {
t.Fatalf("selected render runs = %d, want 1", runs)
}
loaded, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatalf("load manifest: %v", err)
}
for _, name := range []string{"analyze", "publish", "notify"} {
if loaded.Stages[name].Status != manifest.StatusStale {
t.Fatalf("stage %q status = %q, want stale", name, loaded.Stages[name].Status)
}
}
if loaded.Stages["extract"].Status != manifest.StatusSucceeded {
t.Fatalf("extract status = %q, want succeeded", loaded.Stages["extract"].Status)
}
}
func TestExecuteStagesBoundedFailureStopsWithinSelectedRange(t *testing.T) {
cfg := testConfig(t)
plan := mustBoundedPlan(t, "render", "extract")
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
m.Campaign = cfg.Session.Campaign
markPrefixSucceeded(m, plan)
saveBoundedManifest(t, cfg, m)
extractRuns := 0
plan.stages = []stage.Stage{
failingStage{name: "render", err: errors.New("render failed")},
countingStage{name: "extract", runs: &extractRuns},
}
_, err := executePlan(context.Background(), cfg, plan, RunOptions{})
if err == nil || !strings.Contains(err.Error(), "render failed") {
t.Fatalf("executeStages() error = %v", err)
}
if extractRuns != 0 {
t.Fatalf("extract runs = %d, want 0", extractRuns)
}
}
type collaboratorProbeStage struct {
name string
captured **stage.Env
}
func (s collaboratorProbeStage) Name() string { return s.name }
func (s collaboratorProbeStage) Run(_ context.Context, env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
*s.captured = env
return &stage.StageResult{}, nil
}
type prerequisiteMutationSpy struct {
local *manifest.LocalStore
creates int
saves int
}
type prerequisiteChangingStore struct {
local *manifest.LocalStore
loads int
creates int
saves int
}
func (s *prerequisiteChangingStore) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
s.creates++
return s.local.Create(ctx, sessionID)
}
func (s *prerequisiteChangingStore) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
s.loads++
m, err := s.local.Load(ctx, path)
if err != nil {
return nil, err
}
if s.loads == 2 {
m.MarkStageRunning("prepare", time.Now().UTC())
}
return m, nil
}
func (s *prerequisiteChangingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
s.saves++
return s.local.Save(ctx, path, m)
}
func (s *prerequisiteMutationSpy) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
s.creates++
return s.local.Create(ctx, sessionID)
}
func (s *prerequisiteMutationSpy) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
return s.local.Load(ctx, path)
}
func (s *prerequisiteMutationSpy) Save(ctx context.Context, path string, m *manifest.Manifest) error {
s.saves++
return s.local.Save(ctx, path, m)
}
func mustBoundedPlan(t *testing.T, from, through string) BoundedPlan {
t.Helper()
plan, err := BuildBoundedPlan(from, through)
if err != nil {
t.Fatalf("BuildBoundedPlan(%q, %q) error = %v", from, through, err)
}
return plan
}
func markPrefixSucceeded(m *manifest.Manifest, plan BoundedPlan) {
for _, name := range plan.PrefixNames() {
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
}
func saveBoundedManifest(t *testing.T, cfg *config.Config, m *manifest.Manifest) string {
t.Helper()
path := manifestPathFor(cfg)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("create manifest directory: %v", err)
}
if err := (&manifest.LocalStore{}).Save(context.Background(), path, m); err != nil {
t.Fatalf("save manifest: %v", err)
}
return path
}

110
internal/app/bounded_run.go Normal file
View File

@@ -0,0 +1,110 @@
package app
import (
"flag"
"fmt"
"io"
"strconv"
)
type boundedRunRequest struct {
Config commonConfigFlags
Plan BoundedPlan
Force bool
SelectedArtifacts []string
}
type singletonStringFlag struct {
name string
value string
set bool
}
func (f *singletonStringFlag) String() string { return f.value }
func (f *singletonStringFlag) Set(value string) error {
if f.set {
return fmt.Errorf("--%s may be specified only once", f.name)
}
f.value = value
f.set = true
return nil
}
type singletonBoolFlag struct {
name string
value bool
set bool
}
func (f *singletonBoolFlag) String() string { return strconv.FormatBool(f.value) }
func (f *singletonBoolFlag) IsBoolFlag() bool { return true }
func (f *singletonBoolFlag) Set(raw string) error {
if f.set {
return fmt.Errorf("--%s may be specified only once", f.name)
}
value, err := strconv.ParseBool(raw)
if err != nil {
return fmt.Errorf("--%s requires a boolean value: %w", f.name, err)
}
f.value = value
f.set = true
return nil
}
func parseBoundedRunRequest(command string, args []string, help io.Writer) (boundedRunRequest, error) {
fs := flag.NewFlagSet(command, flag.ContinueOnError)
fs.SetOutput(help)
var configFlags commonConfigFlags
var from singletonStringFlag
var through singletonStringFlag
var force singletonBoolFlag
var selectedArtifacts artifactSelectionFlag
from.name = "from"
through.name = "through"
force.name = "force"
addCommonConfigFlags(fs, &configFlags)
fs.Var(&from, "from", "first canonical stage to select (inclusive)")
fs.Var(&through, "through", "last canonical stage to select (inclusive)")
fs.Var(&force, "force", "rerun selected stages even when already succeeded")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
fs.Usage = func() {
invocation := "narratio run"
if command == "plan" {
invocation = "narratio session plan"
}
_, _ = fmt.Fprintf(help, "Usage: %s <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [common config flags]\n\n", invocation)
_, _ = fmt.Fprintln(help, "Bounds are inclusive; omitted --from or --through selects the beginning or end of the canonical pipeline.")
_, _ = fmt.Fprintln(help)
_, _ = fmt.Fprintln(help, "Flags:")
fs.PrintDefaults()
}
if err := parseSessionAwareFlags(command, fs, args, &configFlags.sessionID); err != nil {
return boundedRunRequest{}, err
}
if configFlags.sessionID == "" {
return boundedRunRequest{}, fmt.Errorf("%s: session_id is required", command)
}
plan, err := BuildBoundedPlan(from.value, through.value)
if err != nil {
return boundedRunRequest{}, fmt.Errorf("%s: %w", command, err)
}
normalizedArtifacts, err := selectedArtifacts.Normalize()
if err != nil {
return boundedRunRequest{}, fmt.Errorf("%s: invalid --artifacts: %w", command, err)
}
if len(normalizedArtifacts) > 0 && !plan.Contains("analyze") && !plan.Contains("publish") {
return boundedRunRequest{}, fmt.Errorf("%s: --artifacts requires a selected range containing analyze or publish", command)
}
return boundedRunRequest{
Config: configFlags,
Plan: plan,
Force: force.value,
SelectedArtifacts: normalizedArtifacts,
}, nil
}

View File

@@ -0,0 +1,197 @@
package app
import (
"bytes"
"context"
"io"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestBoundedRunParsingIsSharedByRunAndPlan(t *testing.T) {
args := []string{
"2026-05-03",
"--from", "extract",
"--through=publish",
"--force",
"--artifacts", "session_recap,player_handout",
"--artifacts=session_recap",
"--config", "pipeline.yml",
}
runRequest, err := parseBoundedRunRequest("run", args, io.Discard)
if err != nil {
t.Fatalf("parse run request: %v", err)
}
planRequest, err := parseBoundedRunRequest("plan", args, io.Discard)
if err != nil {
t.Fatalf("parse plan request: %v", err)
}
if !reflect.DeepEqual(runRequest.Plan.Names(), planRequest.Plan.Names()) ||
runRequest.Plan.From() != planRequest.Plan.From() ||
runRequest.Plan.Through() != planRequest.Plan.Through() ||
runRequest.Force != planRequest.Force ||
!reflect.DeepEqual(runRequest.SelectedArtifacts, planRequest.SelectedArtifacts) ||
runRequest.Config != planRequest.Config {
t.Fatalf("run request = %#v, plan request = %#v", runRequest, planRequest)
}
wantArtifacts := []string{"player_handout", "session_recap"}
if !reflect.DeepEqual(runRequest.SelectedArtifacts, wantArtifacts) {
t.Fatalf("artifacts = %#v, want %#v", runRequest.SelectedArtifacts, wantArtifacts)
}
}
func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "from separate", args: []string{"session", "--from", "render", "--from", "extract"}, want: "--from may be specified only once"},
{name: "from equals", args: []string{"session", "--from=render", "--from=extract"}, want: "--from may be specified only once"},
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := parseBoundedRunRequest("run", test.args, io.Discard)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
}
func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) {
args := []string{"session", "--from", "publish", "--through", "render"}
runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard)
planRequest, planErr := parseBoundedRunRequest("plan", args, io.Discard)
if runErr == nil || planErr == nil {
t.Fatalf("run request=%#v error=%v; plan request=%#v error=%v", runRequest, runErr, planRequest, planErr)
}
runDetail := strings.TrimPrefix(runErr.Error(), "run: ")
planDetail := strings.TrimPrefix(planErr.Error(), "plan: ")
if runDetail != planDetail || !strings.Contains(runDetail, `from stage "publish" occurs after through stage "render"`) {
t.Fatalf("run error = %q, plan error = %q", runErr, planErr)
}
}
func TestBoundedRunParsingGatesArtifactSelectionByRange(t *testing.T) {
for _, test := range []struct {
name string
through string
wantErr bool
}{
{name: "render only", through: "render", wantErr: true},
{name: "analyze only", through: "analyze"},
{name: "publish only", through: "publish"},
} {
t.Run(test.name, func(t *testing.T) {
_, err := parseBoundedRunRequest("run", []string{
"session", "--from", test.through, "--through", test.through, "--artifacts", "session_recap",
}, io.Discard)
if test.wantErr && (err == nil || !strings.Contains(err.Error(), "range containing analyze or publish")) {
t.Fatalf("error = %v, want artifact/range error", err)
}
if !test.wantErr && err != nil {
t.Fatalf("error = %v", err)
}
})
}
}
func TestRunPassesBoundedPlanToRunner(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var capturedStages []string
var capturedPlan BoundedPlan
var capturedOptions RunOptions
original := executeStagesFn
t.Cleanup(func() { executeStagesFn = original })
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
capturedStages = plan.Names()
capturedPlan = plan
capturedOptions = options
return &RunSummary{SessionID: "2026-05-03", ManifestPath: filepath.Join(workspaceRoot, "manifest.json")}, nil
}
var out bytes.Buffer
err := Run(context.Background(), []string{
"2026-05-03",
"--from", "render",
"--through", "extract",
"--force",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &out)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
want := []string{"render", "extract"}
if !reflect.DeepEqual(capturedStages, want) || !reflect.DeepEqual(capturedPlan.Names(), want) || !capturedOptions.Force {
t.Fatalf("stages = %#v options = %#v", capturedStages, capturedOptions)
}
}
func TestPlanPrintsOnlyBoundedRange(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
m.Campaign = "sample-campaign"
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
}
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPath, m); err != nil {
t.Fatalf("save prerequisite manifest: %v", err)
}
var out bytes.Buffer
err := Plan(context.Background(), []string{
"2026-05-03",
"--from", "render",
"--through", "extract",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}, &out)
if err != nil {
t.Fatalf("Plan() error = %v", err)
}
got := out.String()
if !strings.Contains(got, "render: run\nextract: run\ntotals: run=2 skip=0") {
t.Fatalf("output = %q, want bounded decisions", got)
}
if strings.Contains(got, "trim: ") || strings.Contains(got, "analyze: ") {
t.Fatalf("output = %q, contains excluded stages", got)
}
}
func TestBoundedRunCommandHelp(t *testing.T) {
for _, test := range []struct {
name string
args []string
want string
}{
{name: "run", args: []string{"run", "--help"}, want: "Usage: narratio run <session_id> [--from <stage>] [--through <stage>]"},
{name: "plan", args: []string{"session", "plan", "--help"}, want: "Usage: narratio session plan <session_id> [--from <stage>] [--through <stage>]"},
} {
t.Run(test.name, func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := Execute(test.args, &stdout, &stderr); code != 0 {
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
}
if !strings.Contains(stdout.String(), test.want) || stderr.Len() != 0 {
t.Fatalf("stdout = %q stderr = %q, want %q", stdout.String(), stderr.String(), test.want)
}
})
}
}

View File

@@ -7,7 +7,9 @@ import (
"strings"
)
var supportedCommands = []string{"run", "run-stage", "analyze", "publish", "clean", "session"}
var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session"}
var runCommandFn = Run
// Execute dispatches CLI commands and returns a process exit code.
func Execute(args []string, stdout, stderr io.Writer) int {
@@ -22,8 +24,12 @@ func Execute(args []string, stdout, stderr io.Writer) int {
var err error
switch cmd {
case "version":
err = Version(cmdArgs, stdout)
case "run":
err = Run(ctx, cmdArgs, stdout)
err = runCommandFn(ctx, cmdArgs, stdout)
case "regenerate-artifacts":
err = RegenerateArtifacts(ctx, cmdArgs, stdout)
case "run-stage":
err = RunStage(ctx, cmdArgs, stdout)
case "analyze":

View File

@@ -32,7 +32,7 @@ func TestExecuteValidCommands(t *testing.T) {
wantOut string
}{
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=11 skipped=1; manifest="},
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nextract: run\nrender: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "analyze: skip\n targets: none\n prerequisites: none\n execute: none\n reuse: none\npublish: skip\nnotify: skip"},
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
}

View File

@@ -7,12 +7,14 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
@@ -25,6 +27,37 @@ type materializingNotariusRunner struct {
failuresRemaining int
}
type assertExtractionSourcesStage struct {
keys []string
runs *int
}
func (s assertExtractionSourcesStage) Name() string { return "analyze" }
func (s assertExtractionSourcesStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
definitions := artifacts.ExtractionDefinitionsFromConfig(env.Config.Pipeline.Notarius)
catalog, err := artifacts.BootstrapRuntimeCatalog(nil, env.EffectiveArtifacts, definitions)
if err != nil {
return nil, err
}
paths, err := env.ArtifactStore.EnsureLayoutFor(env.Config.Session.Campaign, env.Config.Session.SessionID)
if err != nil {
return nil, err
}
catalog.HydrateExtractionArtifacts(paths, m, definitions)
for _, key := range s.keys {
sourceID := artifacts.ExtractionArtifactSourceID(key)
entry, ok := catalog.Lookup(sourceID)
if !ok || !entry.Available || entry.SourceID != sourceID || entry.Path == "" {
return nil, fmt.Errorf("extraction source %q unavailable: %#v, present=%v", sourceID, entry, ok)
}
}
if s.runs != nil {
*s.runs = *s.runs + 1
}
return &stage.StageResult{}, nil
}
func (r *materializingNotariusRunner) Run(_ context.Context, req notarius.RunRequest) (notarius.RunResult, error) {
r.requests = append(r.requests, req)
if r.failuresRemaining > 0 {
@@ -38,32 +71,47 @@ func (r *materializingNotariusRunner) Run(_ context.Context, req notarius.RunReq
return notarius.RunResult{}, err
}
for path, content := range map[string]string{
filepath.Join(bundle, "index.json"): `{"manifest_file":"manifest.json"}`,
filepath.Join(bundle, "manifest.json"): `{}`,
filepath.Join(bundle, "rejected.json"): `{"rejected":[]}`,
filepath.Join(bundle, "warnings.json"): `{"warnings":[]}`,
filepath.Join(lanesDir, "npcs.json"): `{"npcs":[]}`,
filepath.Join(bundle, "index.json"): `{"manifest_file":"manifest.json"}`,
filepath.Join(bundle, "manifest.json"): `{}`,
filepath.Join(bundle, "rejected.json"): `{"rejected":[]}`,
filepath.Join(bundle, "warnings.json"): `{"schema_version":"notarius.warnings.v2","group_count":0,"occurrence_count":0,"groups":[]}`,
filepath.Join(bundle, "diagnostics.json"): `{"schema_version":"notarius.diagnostics.v1","group_count":0,"occurrence_count":0,"truncated":false,"unrepresented_occurrence_count":0,"groups":[]}`,
} {
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return notarius.RunResult{}, err
}
}
output := r.cfg.Outputs["npc_registry"]
keys := make([]string, 0, len(r.cfg.Outputs))
for key := range r.cfg.Outputs {
keys = append(keys, key)
}
sort.Strings(keys)
lanes := make([]notarius.LaneDescriptor, 0, len(keys))
for _, key := range keys {
output := r.cfg.Outputs[key]
filename := key + ".json"
path := filepath.Join(lanesDir, filename)
if err := os.WriteFile(path, []byte(`{"records":[]}`), 0o644); err != nil {
return notarius.RunResult{}, err
}
lanes = append(lanes, notarius.LaneDescriptor{
LaneID: output.LaneID, File: filepath.ToSlash(filepath.Join("lanes", filename)), Path: path,
MediaType: output.MediaType, SchemaID: output.SchemaID,
SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
})
}
return notarius.RunResult{
Receipt: notarius.Receipt{
SchemaVersion: notarius.ReceiptSchemaVersion, RunID: externalRunID,
PipelineID: req.PipelineID, OutputDirectory: bundle, IndexFile: "index.json",
NormalizedOutputCount: 1, ValidationStatus: "valid",
NormalizedOutputCount: len(lanes), ValidationStatus: "approved",
},
BundleRoot: bundle,
Index: notarius.Index{
Path: filepath.Join(bundle, "index.json"), RejectedPath: filepath.Join(bundle, "rejected.json"),
WarningsPath: filepath.Join(bundle, "warnings.json"),
Lanes: []notarius.LaneDescriptor{{
LaneID: output.LaneID, File: "lanes/npcs.json", Path: filepath.Join(lanesDir, "npcs.json"),
MediaType: output.MediaType, SchemaID: output.SchemaID,
SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
}},
WarningsPath: filepath.Join(bundle, "warnings.json"),
DiagnosticsPath: filepath.Join(bundle, "diagnostics.json"),
Lanes: lanes,
},
}, nil
}
@@ -100,6 +148,185 @@ func TestExtractLifecycleDisabledThenEnabled(t *testing.T) {
}
}
func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true)
originalPaths := configureLifecycleReferences(t, cfg)
summary, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if len(summary.Executed) != 2 || len(runner.requests) != 1 {
t.Fatalf("summary = %#v requests=%d", summary, len(runner.requests))
}
paths, err := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
if err != nil {
t.Fatalf("EnsureLayoutFor() error = %v", err)
}
want := []struct {
selector string
sourceID string
filename string
}{
{selector: "glossary", sourceID: artifactpolicy.SourceInputGlossary, filename: "glossary.yml"},
{selector: "party", sourceID: artifactpolicy.SourceInputParty, filename: "party.yml"},
{selector: "players", sourceID: artifactpolicy.SourceInputPlayers, filename: "players.yml"},
{selector: "spells", sourceID: artifactpolicy.SourceInputSpellCatalog, filename: "spell_catalog.json"},
}
request := runner.requests[0]
if len(request.References) != len(want) {
t.Fatalf("references = %#v", request.References)
}
loaded := loadLifecycleManifest(t, cfg)
for index, expected := range want {
binding := request.References[index]
canonical := filepath.Join(paths.InputsDir, expected.filename)
snapshot := filepath.Join(
artifacts.SessionRunNotariusReferencesDirForCampaign(
cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, loaded.RunID,
),
expected.filename,
)
if binding.Selector != expected.selector || binding.Path != snapshot || binding.Path == canonical || binding.Path == originalPaths[expected.sourceID] {
t.Fatalf("reference[%d] = %#v, want selector %q snapshot %q and not prepared/source paths", index, binding, expected.selector, snapshot)
}
}
extract := loaded.Stages["extract"]
if extract == nil || extract.Status != manifest.StatusSucceeded || extract.Metadata["reference_count"] != float64(len(want)) {
t.Fatalf("extract record = %#v", extract)
}
references, ok := extract.Metadata["references"].([]any)
if !ok || len(references) != len(want) || len(references) > config.MaxNotariusReferenceBindings {
t.Fatalf("reference metadata = %#v", extract.Metadata["references"])
}
for index, raw := range references {
entry, ok := raw.(map[string]any)
if !ok || len(entry) != 5 || entry["selector"] != want[index].selector || entry["source_id"] != want[index].sourceID {
t.Fatalf("reference metadata[%d] = %#v", index, raw)
}
}
}
func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDownstream(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true)
originalPaths := configureLifecycleReferences(t, cfg)
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
t.Fatalf("initial executeStages() error = %v", err)
}
before := loadLifecycleManifest(t, cfg)
beforeChecksum := lifecycleInputChecksum(t, before, "party")
for _, name := range []string{"render", "analyze", "publish"} {
before.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), before); err != nil {
t.Fatalf("Save(downstream success) error = %v", err)
}
if err := os.WriteFile(originalPaths[artifactpolicy.SourceInputParty], []byte("changed party bytes\n"), 0o644); err != nil {
t.Fatalf("WriteFile(party source) error = %v", err)
}
prepared := loadLifecycleManifest(t, cfg)
prepare, err := stage.Select("prepare")
if err != nil {
t.Fatalf("stage.Select(prepare) error = %v", err)
}
if _, err := prepare.Run(context.Background(), env, prepared); err != nil {
t.Fatalf("prepare.Run() error = %v", err)
}
if lifecycleInputChecksum(t, prepared, "party") == beforeChecksum {
t.Fatal("prepared party checksum did not change")
}
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), prepared); err != nil {
t.Fatalf("Save(reprepared manifest) error = %v", err)
}
plan, err := BuildSingleStagePlan("extract")
if err != nil {
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
}
run, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err != nil {
t.Fatalf("rerun executeStages() error = %v", err)
}
if len(run.Executed) != 1 || len(run.Skipped) != 0 || len(runner.requests) != 2 {
t.Fatalf("rerun summary = %#v requests=%d", run, len(runner.requests))
}
after := loadLifecycleManifest(t, cfg)
if after.Stages["extract"].Status != manifest.StatusSucceeded {
t.Fatalf("extract status = %#v", after.Stages["extract"])
}
if after.Stages["render"] == nil || after.Stages["render"].Status != manifest.StatusSucceeded {
t.Fatalf("render status = %#v, want succeeded sibling", after.Stages["render"])
}
for _, name := range []string{"analyze", "publish"} {
if after.Stages[name] == nil || after.Stages[name].Status != manifest.StatusStale {
t.Fatalf("%s status = %#v, want stale", name, after.Stages[name])
}
}
}
func TestExtractLifecycleSessionOverrideBytesReachCanonicalReference(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true)
configureLifecycleReferences(t, cfg)
overridePath := filepath.Join(filepath.Dir(cfg.SessionPath), "session-party.yml")
if err := os.WriteFile(overridePath, []byte("session override party\n"), 0o644); err != nil {
t.Fatalf("WriteFile(session override) error = %v", err)
}
cfg.StableInputs.PartyFile = config.ResolvedInputFile{
Path: "./session-party.yml", ConfigPath: cfg.SessionPath, Source: "session_config",
}
cfg.Session.Inputs.PartyFile = "./session-party.yml"
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if len(runner.requests) != 1 {
t.Fatalf("requests = %d", len(runner.requests))
}
var partyPath string
for _, binding := range runner.requests[0].References {
if binding.Selector == "party" {
partyPath = binding.Path
}
}
contents, err := os.ReadFile(partyPath)
if err != nil {
t.Fatalf("ReadFile(prepared party) error = %v", err)
}
if string(contents) != "session override party\n" || partyPath == overridePath {
t.Fatalf("prepared party path=%q contents=%q override=%q", partyPath, contents, overridePath)
}
}
func TestExtractLifecycleEmptyReferencesPreserveAllDndExtractionSources(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, true)
cfg.Pipeline.Notarius.Outputs = lifecycleDndOutputs()
keys := make([]string, 0, len(cfg.Pipeline.Notarius.Outputs))
for key := range cfg.Pipeline.Notarius.Outputs {
keys = append(keys, key)
}
sort.Strings(keys)
analyzeRuns := 0
extractPlan, err := BuildSingleStagePlan("extract")
if err != nil {
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
}
plan := append(extractPlan, assertExtractionSourcesStage{keys: keys, runs: &analyzeRuns})
summary, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if len(summary.Executed) != 2 || len(runner.requests) != 1 || len(runner.requests[0].References) != 0 || analyzeRuns != 1 {
t.Fatalf("summary=%#v requests=%#v analyze=%d", summary, runner.requests, analyzeRuns)
}
loaded := loadLifecycleManifest(t, cfg)
if got := len(loaded.Stages["extract"].Outputs); got != len(keys)+1 {
t.Fatalf("extract outputs = %d, want %d lanes plus index", got, len(keys))
}
}
func TestExtractLifecycleChangedOutcomeRerunsSucceededDownstream(t *testing.T) {
cfg, env, runner := extractionLifecycleFixture(t, false)
analyzeRuns := 0
@@ -394,6 +621,73 @@ func markLifecycleStageSucceeded(t *testing.T, cfg *config.Config, name string)
}
}
func prepareExtractLifecyclePlan(t *testing.T) []stage.Stage {
t.Helper()
prepare, err := BuildSingleStagePlan("prepare")
if err != nil {
t.Fatalf("BuildSingleStagePlan(prepare) error = %v", err)
}
extract, err := BuildSingleStagePlan("extract")
if err != nil {
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
}
return append(prepare, extract...)
}
func configureLifecycleReferences(t *testing.T, cfg *config.Config) map[string]string {
t.Helper()
cfg.Pipeline.Notarius.References = map[string]string{
"party": artifactpolicy.SourceInputParty,
"players": artifactpolicy.SourceInputPlayers,
"glossary": artifactpolicy.SourceInputGlossary,
"spells": artifactpolicy.SourceInputSpellCatalog,
}
spellPath := filepath.Join(filepath.Dir(cfg.CampaignPath), "spells.json")
if err := os.WriteFile(spellPath, []byte(`{"spells":[]}`+"\n"), 0o644); err != nil {
t.Fatalf("WriteFile(spell catalog) error = %v", err)
}
cfg.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
Path: "./spells.json", ConfigPath: cfg.CampaignPath, Source: "campaign_config",
}
cfg.Session.Inputs.SpellCatalogFile = "./spells.json"
return map[string]string{
artifactpolicy.SourceInputParty: filepath.Join(filepath.Dir(cfg.CampaignPath), "party.yml"),
artifactpolicy.SourceInputPlayers: filepath.Join(filepath.Dir(cfg.CampaignPath), "players.yml"),
artifactpolicy.SourceInputGlossary: filepath.Join(filepath.Dir(cfg.CampaignPath), "glossary.yml"),
artifactpolicy.SourceInputSpellCatalog: spellPath,
}
}
func lifecycleInputChecksum(t *testing.T, m *manifest.Manifest, kind string) string {
t.Helper()
for _, input := range m.Inputs {
if input.Kind == kind {
if strings.TrimSpace(input.Checksum) == "" {
t.Fatalf("input %q has no checksum: %#v", kind, input)
}
return input.Checksum
}
}
t.Fatalf("manifest input %q not found: %#v", kind, m.Inputs)
return ""
}
func lifecycleDndOutputs() map[string]config.NotariusOutputConfig {
return map[string]config.NotariusOutputConfig{
"item_registry": {LaneID: "item-registry", MediaType: "application/json", SchemaID: "notarius.dnd.item_registry", SchemaVersion: "v1", ModuleKey: "dnd/item-registry"},
"npc_registry": {LaneID: "npc-registry", MediaType: "application/json", SchemaID: "notarius.dnd.npc_registry", SchemaVersion: "v1", ModuleKey: "dnd/npc-registry"},
"location_registry": {LaneID: "location-registry", MediaType: "application/json", SchemaID: "notarius.dnd.location_registry", SchemaVersion: "v1", ModuleKey: "dnd/location-registry"},
"scene_descriptions": {LaneID: "scene-descriptions", MediaType: "application/json", SchemaID: "notarius.dnd.scene_descriptions", SchemaVersion: "v1", ModuleKey: "dnd/scene-descriptions"},
"item_occurrences": {LaneID: "item-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.item_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/item-occurrences"},
"spells": {LaneID: "spells", MediaType: "application/json", SchemaID: "notarius.dnd.spells", SchemaVersion: "v1", ModuleKey: "dnd/spells"},
"combat_turns": {LaneID: "combat-turns", MediaType: "application/json", SchemaID: "notarius.dnd.combat_turns", SchemaVersion: "v1", ModuleKey: "dnd/combat-turns"},
"npc_occurrences": {LaneID: "npc-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.npc_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/npc-occurrences"},
"location_occurrences": {LaneID: "location-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.location_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/location-occurrences"},
"enemy_events": {LaneID: "enemy-events", MediaType: "application/json", SchemaID: "notarius.dnd.enemy_events", SchemaVersion: "v1", ModuleKey: "dnd/enemy-events"},
}
}
func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *stage.Env, *materializingNotariusRunner) {
t.Helper()
cfg := testConfig(t)

View File

@@ -31,6 +31,7 @@ func buildHelperArtifactCatalog(cfg *config.Config, m *manifest.Manifest) (*arti
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
}
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
return catalog, nil
}
@@ -43,7 +44,11 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
fmt.Fprintln(out, "Configured:")
for _, entry := range catalog.ListConfigured() {
writeArtifactLine(out, entry.SourceID, lockSet)
state := "unavailable"
if entry.Available {
state = "available"
}
writeExtractionArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet)
}
fmt.Fprintln(out, "Extraction:")
for _, entry := range catalog.ListExtraction() {

View File

@@ -0,0 +1,54 @@
package app
import (
"os"
"path/filepath"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestBuildHelperArtifactCatalogUsesAnalyzeManifestEvidence(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{
Pipeline: &config.PipelineConfig{
Workspace: config.WorkspaceConfig{Root: root},
Scriptorium: &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
}},
},
Session: &config.SessionConfig{Campaign: "campaign", SessionID: "session"},
}
paths := artifacts.NewLocalStore(root).SessionPathsFor("campaign", "session")
outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md")
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
t.Fatal(err)
}
body := []byte("# recap\n")
if err := os.WriteFile(outputPath, body, 0o644); err != nil {
t.Fatal(err)
}
m := manifest.New("session", time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC))
incidental, err := buildHelperArtifactCatalog(cfg, m)
if err != nil {
t.Fatal(err)
}
entry, _ := incidental.Lookup(artifacts.ConfiguredArtifactSourceID("session_recap"))
if entry.Available {
t.Fatal("operator catalog advertised incidental configured artifact")
}
setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", body)
current, err := buildHelperArtifactCatalog(cfg, m)
if err != nil {
t.Fatal(err)
}
entry, _ = current.Lookup(artifacts.ConfiguredArtifactSourceID("session_recap"))
if !entry.Available || entry.Provenance != artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
t.Fatalf("operator catalog entry = %#v", entry)
}
}

View File

@@ -458,6 +458,63 @@ inputs:
}
}
func TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
campaignBytes, err := os.ReadFile(campaignPath)
if err != nil {
t.Fatalf("read campaign: %v", err)
}
campaignYAML := strings.Replace(string(campaignBytes), " party_file: ./party.yml\n", " party_file: ./party.yml\n spell_catalog_file: ./spells.json\n", 1)
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
t.Fatalf("write campaign: %v", err)
}
spellPath := filepath.Join(filepath.Dir(campaignPath), "spells.json")
mustWriteTestFile(t, spellPath, "{\"spells\":[]}\n")
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
commonArgs := []string{
"2026-05-03",
"--config", pipelinePath,
"--campaign-file", campaignPath,
"--session", sessionPath,
}
var stdout bytes.Buffer
var stderr bytes.Buffer
validateArgs := append([]string{"session", "validate"}, commonArgs...)
if code := Execute(validateArgs, &stdout, &stderr); code != 0 {
t.Fatalf("validate exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "OK inputs spell_catalog: "+spellPath) {
t.Fatalf("validate stdout = %q, want spell catalog finding", stdout.String())
}
stdout.Reset()
stderr.Reset()
statusArgs := append([]string{"session", "status"}, commonArgs...)
if code := Execute(statusArgs, &stdout, &stderr); code != 0 {
t.Fatalf("status exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
if !strings.Contains(stdout.String(), "Stable input spell_catalog: "+spellPath) {
t.Fatalf("status stdout = %q, want spell catalog inventory", stdout.String())
}
if err := os.Remove(spellPath); err != nil {
t.Fatalf("remove spell catalog: %v", err)
}
stdout.Reset()
stderr.Reset()
if code := Execute(validateArgs, &stdout, &stderr); code == 0 {
t.Fatalf("validate missing spell catalog exit code = 0; stdout=%q", stdout.String())
}
if !strings.Contains(stdout.String(), "ERROR inputs spell_catalog missing:") {
t.Fatalf("validate stdout = %q, want missing spell catalog finding", stdout.String())
}
}
func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)

View File

@@ -53,23 +53,28 @@ type effectiveLocksCheck struct {
func inspectStableInputs(cfg *config.Config) []stableInputCheck {
items := []struct {
name string
in config.ResolvedInputFile
name string
in config.ResolvedInputFile
optional bool
}{
{name: "speakers", in: cfg.StableInputs.SpeakersFile},
{name: "autocorrect", in: cfg.StableInputs.AutocorrectFile},
{name: "glossary", in: cfg.StableInputs.GlossaryFile},
{name: "players", in: cfg.StableInputs.PlayersFile},
{name: "party", in: cfg.StableInputs.PartyFile},
{name: "spell_catalog", in: cfg.StableInputs.SpellCatalogFile, optional: true},
}
out := make([]stableInputCheck, 0, len(items))
for _, item := range items {
if item.optional && strings.TrimSpace(item.in.Path) == "" {
continue
}
path, err := resolveHelperConfigRelativePath(item.in)
if err != nil {
out = append(out, stableInputCheck{Name: item.name, Err: err})
continue
}
if _, err := os.Stat(path); err != nil {
if err := requireInspectionFile(path, item.name); err != nil {
out = append(out, stableInputCheck{Name: item.name, Path: path, Err: err})
continue
}
@@ -271,8 +276,8 @@ func requireInspectionFile(path, label string) error {
}
return fmt.Errorf("stat %s %q: %w", label, path, err)
}
if info.IsDir() {
return fmt.Errorf("%s %q is a directory", label, path)
if !info.Mode().IsRegular() {
return fmt.Errorf("%s %q is not a regular file", label, path)
}
return nil
}

View File

@@ -0,0 +1,34 @@
//go:build unix
package app
import (
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"golang.org/x/sys/unix"
)
func TestInspectStableInputsRejectsSpellCatalogFIFO(t *testing.T) {
root := t.TempDir()
sourcePath := filepath.Join(root, "spells.fifo")
if err := unix.Mkfifo(sourcePath, 0o644); err != nil {
t.Fatalf("Mkfifo() error = %v", err)
}
cfg := &config.Config{StableInputs: config.ResolvedStableInputs{
SpellCatalogFile: config.ResolvedInputFile{Path: sourcePath, ConfigPath: filepath.Join(root, "campaign.yml")},
}}
for _, check := range inspectStableInputs(cfg) {
if check.Name != "spell_catalog" {
continue
}
if check.Err == nil || !strings.Contains(check.Err.Error(), "not a regular file") {
t.Fatalf("spell catalog check = %#v, want regular-file rejection", check)
}
return
}
t.Fatal("spell catalog inspection result was not reported")
}

View File

@@ -2,34 +2,30 @@ package app
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/logging"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
// Plan validates configuration, prepares the local workdir, and prints stage order.
// Plan validates configuration and prints a read-only execution preview.
func Plan(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var flags commonConfigFlags
var force bool
addCommonConfigFlags(fs, &flags)
fs.BoolVar(&force, "force", false, "show all stages as scheduled to rerun")
if err := parseSessionAwareFlags("plan", fs, args, &flags.sessionID); err != nil {
request, err := parseBoundedRunRequest("plan", args, out)
if err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if flags.sessionID == "" {
return fmt.Errorf("plan: session_id is required")
}
flags := request.Config
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil {
return fmt.Errorf("plan: %w", err)
@@ -39,38 +35,75 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("plan: %w", err)
}
if _, err := loadSecretsFromConfig(cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)); err != nil {
effective, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
if err != nil {
return fmt.Errorf("plan: %w", err)
}
m, err := loadManifestIfPresent(ctx, cfg)
if err != nil {
return fmt.Errorf("plan: %w", err)
}
if err := validateBoundedPrerequisites(request.Plan, m); err != nil {
return fmt.Errorf("plan: %w", err)
}
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
paths, err := store.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
paths := store.SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
model, err := cloneManifestForPlan(m, cfg)
if err != nil {
return fmt.Errorf("plan: prepare workdir: %w", err)
return fmt.Errorf("plan: clone session state: %w", err)
}
stages := BuildFullPlan()
var m *manifest.Manifest
m, err = loadManifestIfPresent(ctx, cfg)
if err != nil {
return fmt.Errorf("plan: %w", err)
stages := request.Plan.Stages()
stageEnv := &stage.Env{
Config: cfg, SelectedArtifactKeys: append([]string(nil), request.SelectedArtifacts...),
EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force,
}
decisions := decideStageActions(stages, m, force)
runCount := 0
skipCount := 0
if _, err := fmt.Fprintf(out, "narratio session plan: workdir prepared at %s\n", paths.Root); err != nil {
if _, err := fmt.Fprintf(out, "narratio session plan: read-only workdir at %s\n", paths.Root); err != nil {
return err
}
for _, d := range decisions {
if d.Action == stageActionRun {
for _, selectedStage := range stages {
action := decideStageAction(selectedStage, model, request.Force)
var validation *stage.ResumeValidation
if validator, ok := selectedStage.(stage.ResumeValidator); ok &&
(action == stageActionSkip || selectedStage.Name() == "analyze") {
checked, validationErr := validator.ValidateResume(ctx, stageEnv, model)
if validationErr != nil {
return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr)
}
checked = checked.Normalized()
validation = &checked
if action == stageActionSkip && !checked.Resumable {
at := time.Now().UTC()
model.MarkStageStale(selectedStage.Name(), at, checked.Reason)
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(
model, selectedStage.Name(), at, staleReasonNotResumable,
); invalidationErr != nil {
return fmt.Errorf("plan: model resume invalidation for stage %q: %w", selectedStage.Name(), invalidationErr)
}
action = stageActionRun
}
}
if action == stageActionRun {
runCount++
} else {
skipCount++
}
if _, err := fmt.Fprintf(out, "%s: %s\n", d.Stage.Name(), d.Action); err != nil {
if _, err := fmt.Fprintf(out, "%s: %s\n", selectedStage.Name(), action); err != nil {
return err
}
if validation != nil && validation.Analyze != nil {
if err := writeAnalyzePlanDetails(out, validation.Analyze); err != nil {
return err
}
}
if action == stageActionRun {
if err := modelPlannedStageRun(model, selectedStage, cfg, request.Force); err != nil {
return fmt.Errorf("plan: model stage %q: %w", selectedStage.Name(), err)
}
}
}
if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil {
return err
@@ -78,3 +111,102 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
return nil
}
func cloneManifestForPlan(source *manifest.Manifest, cfg *config.Config) (*manifest.Manifest, error) {
if source == nil {
created := manifest.New(cfg.Session.SessionID, time.Now().UTC())
created.Campaign = cfg.Session.Campaign
return created, nil
}
data, err := json.Marshal(source)
if err != nil {
return nil, err
}
var cloned manifest.Manifest
if err := json.Unmarshal(data, &cloned); err != nil {
return nil, err
}
return &cloned, nil
}
func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, cfg *config.Config, force bool) error {
prior := capturePriorStageOutcome(model, selectedStage.Name())
at := time.Now().UTC()
model.MarkStageRunning(selectedStage.Name(), at)
if force {
if _, err := invalidateDependentSucceededStagesWithReason(
model, selectedStage.Name(), at, staleReasonForcedReplacement,
); err != nil {
return err
}
}
if reason := plannedSelfSkipReason(selectedStage.Name(), cfg); reason != "" {
model.MarkStageSkipped(selectedStage.Name(), at, reason)
if !prior.isSameSelfSkip(reason) {
_, err := invalidateDependentSucceededStagesWithReason(
model, selectedStage.Name(), at, staleReasonSelfSkip,
)
return err
}
return nil
}
model.MarkStageSucceeded(selectedStage.Name(), at, nil)
if !prior.exists || prior.status != manifest.StatusSucceeded {
if _, err := invalidateDependentSucceededStagesWithReason(
model, selectedStage.Name(), at, staleReasonChangedResult,
); err != nil {
return err
}
}
return nil
}
func plannedSelfSkipReason(stageName string, cfg *config.Config) string {
if stageName == "extract" && cfg != nil && cfg.Pipeline != nil &&
(cfg.Pipeline.Notarius == nil || !cfg.Pipeline.Notarius.Enabled) {
return "notarius_disabled"
}
return ""
}
func writeAnalyzePlanDetails(out io.Writer, summary *stage.AnalyzeResumeSummary) error {
if summary == nil {
return nil
}
if _, err := fmt.Fprintf(out, " targets: %s\n", planStringList(summary.ExplicitTargets)); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " prerequisites: %s\n", planArtifactList(summary.PrerequisiteWork)); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " execute: %s\n", planArtifactList(summary.ExecutionOrder)); err != nil {
return err
}
_, err := fmt.Fprintf(out, " reuse: %s\n", planArtifactList(summary.ReusedCurrent))
return err
}
func planStringList(values []string) string {
if len(values) == 0 {
return "none"
}
return strings.Join(values, ", ")
}
func planArtifactList(values []stage.AnalyzeResumeArtifact) string {
if len(values) == 0 {
return "none"
}
parts := make([]string, 0, len(values))
for _, value := range values {
detail := value.Role
if value.Reason != "" {
detail += ":" + value.Reason
}
if value.Forced {
detail += ":forced"
}
parts = append(parts, fmt.Sprintf("%s(%s)", value.Key, detail))
}
return strings.Join(parts, ", ")
}

View File

@@ -3,17 +3,22 @@ package app
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"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 TestPlanCreatesAndReusesWorkdir(t *testing.T) {
func TestPlanDoesNotCreateWorkdir(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -24,10 +29,10 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
t.Fatalf("first Plan() error = %v", err)
}
got := out.String()
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
t.Fatalf("first output = %q, want workdir prepared", got)
if !strings.Contains(got, "narratio session plan: read-only workdir at") {
t.Fatalf("first output = %q, want read-only workdir", got)
}
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
if !strings.Contains(got, name+": run") {
t.Fatalf("first output = %q, missing stage %q", got, name)
}
@@ -37,26 +42,16 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
}
sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
expectedDirs := []string{
sessionWorkdir,
filepath.Join(sessionWorkdir, "inputs"),
filepath.Join(sessionWorkdir, "audio"),
filepath.Join(sessionWorkdir, "transcripts", "raw"),
filepath.Join(sessionWorkdir, "transcripts", "trimmed"),
filepath.Join(sessionWorkdir, "artifacts"),
filepath.Join(sessionWorkdir, "config"),
filepath.Join(sessionWorkdir, "logs"),
}
for _, dir := range expectedDirs {
assertDir(t, dir)
if _, err := os.Stat(sessionWorkdir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("workdir stat error = %v, want absent", err)
}
out.Reset()
if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("second Plan() error = %v", err)
}
if !strings.Contains(out.String(), "narratio session plan: workdir prepared at") {
t.Fatalf("second output = %q, want workdir prepared", out.String())
if !strings.Contains(out.String(), "narratio session plan: read-only workdir at") {
t.Fatalf("second output = %q, want read-only workdir", out.String())
}
}
@@ -89,7 +84,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
}
}
func TestPlanFailsWhenConfiguredSecretsDirMissing(t *testing.T) {
func TestPlanDoesNotLoadConfiguredSecrets(t *testing.T) {
workspaceRoot := t.TempDir()
configDir := t.TempDir()
pipelinePath := filepath.Join(configDir, "pipeline.yml")
@@ -130,21 +125,125 @@ inputs:
var out bytes.Buffer
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "validate secrets env_dir") {
t.Fatalf("error = %q, want secrets validation error context", err.Error())
if err != nil {
t.Fatalf("Plan() error = %v, want missing runtime secrets ignored", err)
}
}
func assertDir(t *testing.T, path string) {
t.Helper()
info, err := os.Stat(path)
func TestPlanAndRunShareAnalyzeArtifactDecisionsWithoutPlanSideEffects(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
t.Fatalf("load config: %v", err)
}
if !info.IsDir() {
t.Fatalf("%q is not a directory", path)
analyze, err := stage.Select("analyze")
if err != nil {
t.Fatal(err)
}
fake := &scriptorium.FakeRunner{}
first, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
Env: &Env{Scriptorium: fake},
})
if err != nil {
t.Fatalf("initial analyze: %v", err)
}
if len(fake.RunRequests) != 2 {
t.Fatalf("initial adapter requests = %d, want 2", len(fake.RunRequests))
}
store := &manifest.LocalStore{}
m, err := store.Load(context.Background(), first.ManifestPath)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
m.MarkStageStale("render", time.Now().UTC(), "upstream selection requires reconsideration")
m.MarkStageSkipped("extract", time.Now().UTC(), "notarius_disabled")
if err := store.Save(context.Background(), first.ManifestPath, m); err != nil {
t.Fatal(err)
}
manifestBefore, err := os.ReadFile(first.ManifestPath)
if err != nil {
t.Fatal(err)
}
filesBefore := planFixtureFiles(t, filepath.Dir(first.ManifestPath))
marker := filepath.Join(t.TempDir(), "adapter-invoked")
binaryDir := t.TempDir()
binary := filepath.Join(binaryDir, "scriptorium")
if err := os.WriteFile(binary, []byte("#!/bin/sh\ntouch \""+marker+"\"\nexit 99\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binaryDir+string(os.PathListSeparator)+os.Getenv("PATH"))
var out bytes.Buffer
err = Plan(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
"--from", "render", "--through", "analyze",
}, &out)
if err != nil {
t.Fatalf("Plan() error = %v", err)
}
got := out.String()
for _, want := range []string{
"render: run", "extract: run", "analyze: run",
" targets: player_handout, session_recap",
" prerequisites: none", " execute: none",
"player_handout(target:current)", "session_recap(target:current)",
} {
if !strings.Contains(got, want) {
t.Fatalf("plan output = %q, want %q", got, want)
}
}
manifestAfter, err := os.ReadFile(first.ManifestPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(manifestBefore, manifestAfter) {
t.Fatal("plan modified the session manifest")
}
if filesAfter := planFixtureFiles(t, filepath.Dir(first.ManifestPath)); !reflect.DeepEqual(filesAfter, filesBefore) {
t.Fatalf("plan files = %#v, want unchanged %#v", filesAfter, filesBefore)
}
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("adapter marker stat = %v, want absent", err)
}
fake.RunRequests = nil
actual, err := executeStages(context.Background(), cfg, []stage.Stage{
resultStage{name: "render", result: &stage.StageResult{}},
resultStage{name: "extract", result: &stage.StageResult{Disposition: stage.StageDispositionSkipped, SkipReason: "notarius_disabled"}},
analyze,
}, RunOptions{
Env: &Env{Scriptorium: fake},
})
if err != nil {
t.Fatalf("actual analyze: %v", err)
}
if !reflect.DeepEqual(actual.Executed, []string{"render", "extract", "analyze"}) ||
!reflect.DeepEqual(actual.Skipped, []string{"extract"}) || len(fake.RunRequests) != 0 {
t.Fatalf("actual decision: executed=%#v skipped=%#v adapter_requests=%d, want planned stage decisions with artifact reuse", actual.Executed, actual.Skipped, len(fake.RunRequests))
}
}
func planFixtureFiles(t *testing.T, root string) []string {
t.Helper()
var files []string
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
files = append(files, relative+":"+entry.Type().String())
return nil
})
if err != nil {
t.Fatal(err)
}
return files
}

View File

@@ -2,13 +2,134 @@ package app
import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
// BoundedPlan is one validated inclusive range of the canonical pipeline.
// It owns effective endpoints and membership so command, runner, and
// composition callers do not independently interpret range bounds.
type BoundedPlan struct {
stages []stage.Stage
canonicalNames []string
startIndex int
endIndex int
explicitFrom bool
explicitThrough bool
}
// BuildBoundedPlan selects an inclusive contiguous range of the canonical
// pipeline. Empty endpoints default to the beginning or end respectively.
func BuildBoundedPlan(from, through string) (BoundedPlan, error) {
registry := stage.All()
names := make([]string, len(registry))
indices := make(map[string]int, len(registry))
for index, candidate := range registry {
if candidate == nil {
return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage %d is nil", index)
}
name := candidate.Name()
if _, duplicate := indices[name]; duplicate {
return BoundedPlan{}, fmt.Errorf("build bounded plan: duplicate canonical stage %q", name)
}
names[index] = name
indices[name] = index
}
if len(registry) == 0 {
return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage registry is empty")
}
start := 0
if from != "" {
var ok bool
start, ok = indices[from]
if !ok {
return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown from stage %q; valid stages: %s", from, strings.Join(names, ", "))
}
}
end := len(registry) - 1
if through != "" {
var ok bool
end, ok = indices[through]
if !ok {
return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown through stage %q; valid stages: %s", through, strings.Join(names, ", "))
}
}
if start > end {
return BoundedPlan{}, fmt.Errorf("build bounded plan: from stage %q occurs after through stage %q; valid stages: %s", from, through, strings.Join(names, ", "))
}
return BoundedPlan{
stages: append([]stage.Stage(nil), registry[start:end+1]...),
canonicalNames: append([]string(nil), names...),
startIndex: start,
endIndex: end,
explicitFrom: from != "",
explicitThrough: through != "",
}, nil
}
// Stages returns a copy of the selected canonical stages.
func (p BoundedPlan) Stages() []stage.Stage {
return append([]stage.Stage(nil), p.stages...)
}
// Names returns selected stage names in canonical order.
func (p BoundedPlan) Names() []string {
out := make([]string, 0, len(p.stages))
for _, candidate := range p.stages {
out = append(out, candidate.Name())
}
return out
}
// From returns the effective inclusive start stage.
func (p BoundedPlan) From() string {
if len(p.canonicalNames) == 0 || p.startIndex < 0 || p.startIndex >= len(p.canonicalNames) {
return ""
}
return p.canonicalNames[p.startIndex]
}
// Through returns the effective inclusive end stage.
func (p BoundedPlan) Through() string {
if len(p.canonicalNames) == 0 || p.endIndex < 0 || p.endIndex >= len(p.canonicalNames) {
return ""
}
return p.canonicalNames[p.endIndex]
}
// Contains reports whether a canonical stage is selected by the range.
func (p BoundedPlan) Contains(name string) bool {
for _, candidate := range p.stages {
if candidate.Name() == name {
return true
}
}
return false
}
// PrefixNames returns canonical stages excluded before the selected start.
func (p BoundedPlan) PrefixNames() []string {
if p.startIndex <= 0 || p.startIndex > len(p.canonicalNames) {
return nil
}
return append([]string(nil), p.canonicalNames[:p.startIndex]...)
}
// HasExplicitBounds reports whether either endpoint was supplied by the caller.
func (p BoundedPlan) HasExplicitBounds() bool {
return p.explicitFrom || p.explicitThrough
}
// BuildFullPlan returns the canonical full stage list in deterministic order.
func BuildFullPlan() []stage.Stage {
return stage.All()
plan, err := BuildBoundedPlan("", "")
if err != nil {
panic(err)
}
return plan.Stages()
}
// BuildSingleStagePlan returns a one-stage plan for an exact stage name.
@@ -19,3 +140,22 @@ func BuildSingleStagePlan(name string) ([]stage.Stage, error) {
}
return []stage.Stage{s}, nil
}
// buildSingleStageExecutionPlan selects one canonical stage without applying
// bounded-run prefix prerequisites. The run-stage family validates the stage's
// concrete inputs and intentionally retains its established direct-execution
// semantics.
func buildSingleStageExecutionPlan(name string) (BoundedPlan, error) {
stages, err := BuildSingleStagePlan(name)
if err != nil {
return BoundedPlan{}, err
}
plan, err := BuildBoundedPlan(name, name)
if err != nil {
return BoundedPlan{}, fmt.Errorf("build stage plan: %w", err)
}
plan.stages = stages
plan.explicitFrom = false
plan.explicitThrough = false
return plan, nil
}

View File

@@ -1,10 +1,16 @@
package app
import "testing"
import (
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestBuildFullPlanOrder(t *testing.T) {
got := BuildFullPlan()
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"}
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}
if len(got) != len(want) {
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
}
@@ -34,3 +40,113 @@ func TestBuildSingleStagePlanUnknown(t *testing.T) {
t.Fatal("expected error for unknown stage, got nil")
}
}
func TestBuildBoundedPlanEndpoints(t *testing.T) {
canonical := stageNames(BuildFullPlan())
for index, name := range canonical {
t.Run(name, func(t *testing.T) {
one, err := BuildBoundedPlan(name, name)
if err != nil {
t.Fatalf("BuildBoundedPlan(%q, %q) error = %v", name, name, err)
}
if got := one.Names(); !reflect.DeepEqual(got, []string{name}) {
t.Fatalf("one-stage names = %#v, want %q", got, name)
}
if one.From() != name || one.Through() != name || !one.Contains(name) || !one.HasExplicitBounds() {
t.Fatalf("one-stage plan endpoints or membership = %#v", one)
}
from, err := BuildBoundedPlan(name, "")
if err != nil {
t.Fatalf("BuildBoundedPlan(%q, empty) error = %v", name, err)
}
if got := from.Names(); !reflect.DeepEqual(got, canonical[index:]) {
t.Fatalf("from names = %#v, want %#v", got, canonical[index:])
}
through, err := BuildBoundedPlan("", name)
if err != nil {
t.Fatalf("BuildBoundedPlan(empty, %q) error = %v", name, err)
}
if got := through.Names(); !reflect.DeepEqual(got, canonical[:index+1]) {
t.Fatalf("through names = %#v, want %#v", got, canonical[:index+1])
}
})
}
}
func TestBuildBoundedPlanDefaultsToFullCanonicalPlan(t *testing.T) {
plan, err := BuildBoundedPlan("", "")
if err != nil {
t.Fatalf("BuildBoundedPlan() error = %v", err)
}
want := stageNames(BuildFullPlan())
if got := plan.Names(); !reflect.DeepEqual(got, want) {
t.Fatalf("bounded names = %#v, want %#v", got, want)
}
if plan.From() != want[0] || plan.Through() != want[len(want)-1] || plan.HasExplicitBounds() {
t.Fatalf("default endpoints = %q through %q explicit=%t", plan.From(), plan.Through(), plan.HasExplicitBounds())
}
if got := plan.PrefixNames(); len(got) != 0 {
t.Fatalf("default prefix = %#v, want empty", got)
}
}
func TestBuildBoundedPlanRejectsInvalidBounds(t *testing.T) {
tests := []struct {
name string
from string
through string
want []string
}{
{name: "unknown from", from: "missing", through: "analyze", want: []string{"unknown from stage", "missing", "prepare", "notify"}},
{name: "unknown through", from: "extract", through: "missing", want: []string{"unknown through stage", "missing", "prepare", "notify"}},
{name: "reversed", from: "publish", through: "render", want: []string{"publish", "occurs after", "render", "prepare", "notify"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := BuildBoundedPlan(test.from, test.through)
if err == nil {
t.Fatal("BuildBoundedPlan() error = nil")
}
for _, fragment := range test.want {
if !strings.Contains(err.Error(), fragment) {
t.Fatalf("error = %q, want fragment %q", err, fragment)
}
}
})
}
}
func TestBoundedPlanIsContiguousAndCannotMutateRegistry(t *testing.T) {
before := stageNames(BuildFullPlan())
plan, err := BuildBoundedPlan("trim", "analyze")
if err != nil {
t.Fatalf("BuildBoundedPlan() error = %v", err)
}
want := []string{"trim", "render", "extract", "analyze"}
if got := plan.Names(); !reflect.DeepEqual(got, want) {
t.Fatalf("names = %#v, want contiguous %#v", got, want)
}
if got := plan.PrefixNames(); !reflect.DeepEqual(got, before[:5]) {
t.Fatalf("prefix = %#v, want %#v", got, before[:5])
}
stages := plan.Stages()
stages[0] = nil
names := plan.Names()
names[0] = "changed"
if got := plan.Names(); !reflect.DeepEqual(got, want) {
t.Fatalf("mutated plan names = %#v, want %#v", got, want)
}
if got := stageNames(BuildFullPlan()); !reflect.DeepEqual(got, before) {
t.Fatalf("canonical registry changed = %#v, want %#v", got, before)
}
}
func stageNames(stages []stage.Stage) []string {
names := make([]string, 0, len(stages))
for _, candidate := range stages {
names = append(names, candidate.Name())
}
return names
}

View File

@@ -146,7 +146,12 @@ func TestPostPublishCleanupRetriesWhenInitialObligationSaveFails(t *testing.T) {
store.fail = nil
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("retry executeStages() error = %v", err)
t.Fatalf("non-publish executeStages() error = %v", err)
}
assertExists(t, seed.spoolAudioDir)
assertCleanupPending(t, cfg)
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("publish retry executeStages() error = %v", err)
}
assertMissing(t, seed.spoolAudioDir)
assertCleanupComplete(t, cfg)
@@ -178,7 +183,12 @@ func TestPostPublishCleanupRetriesFailedDeletionWithoutTouchingOtherRuns(t *test
removeRunScopedDirFn = originalRemove
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("retry executeStages() error = %v", err)
t.Fatalf("non-publish executeStages() error = %v", err)
}
assertExists(t, seed.runWorkDir)
assertCleanupPending(t, cfg)
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("publish retry executeStages() error = %v", err)
}
assertMissing(t, seed.runWorkDir)
assertExists(t, seed.otherRunDir)
@@ -218,12 +228,16 @@ func TestPostPublishCleanupRetriesWhenCompletionEvidenceSaveFails(t *testing.T)
store.fail = nil
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("retry executeStages() error = %v", err)
t.Fatalf("non-publish executeStages() error = %v", err)
}
assertCleanupPending(t, cfg)
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("publish retry executeStages() error = %v", err)
}
assertCleanupComplete(t, cfg)
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("idempotent retry executeStages() error = %v", err)
t.Fatalf("idempotent non-publish executeStages() error = %v", err)
}
assertMissing(t, seed.spoolAudioDir)
}
@@ -328,7 +342,10 @@ func TestPostPublishCleanupFailsOnUnsafePath(t *testing.T) {
t.Fatalf("Save() error = %v", err)
}
_, err = executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
if _, err = executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
t.Fatalf("non-publish executeStages() error = %v", err)
}
_, err = executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ObjectStore: &storage.FakeBackend{}}})
if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") {
t.Fatalf("executeStages() error = %v, want safe-path failure", err)
}
@@ -525,6 +542,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
setAppAnalyzeEvidence(seedManifest, "session_recap", "artifacts/session_recap.md", []byte("# recap\n"))
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {

View File

@@ -0,0 +1,43 @@
package app
import (
"context"
"fmt"
"io"
)
// RegenerateArtifacts expands the convenience command into its canonical run
// invocation. The run command remains the sole owner of parsing and execution.
func RegenerateArtifacts(ctx context.Context, args []string, out io.Writer) error {
if containsHelpOption(args) {
printRegenerateArtifactsHelp(out)
return nil
}
expanded := make([]string, 0, len(args)+5)
if len(args) > 0 && !isCLIFlagToken(args[0]) {
expanded = append(expanded, args[0])
args = args[1:]
}
expanded = append(expanded, "--force", "--from", "extract", "--through", "analyze")
expanded = append(expanded, args...)
return runCommandFn(ctx, expanded, out)
}
func containsHelpOption(args []string) bool {
for _, arg := range args {
if arg == "-h" || arg == "--help" {
return true
}
}
return false
}
func printRegenerateArtifactsHelp(out io.Writer) {
_, _ = fmt.Fprintln(out, "Usage: narratio regenerate-artifacts <session_id> [--artifacts <name[,name...]>] [common config flags]")
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintln(out, "Exactly equivalent to:")
_, _ = fmt.Fprintln(out, " narratio run <session_id> --force --from extract --through analyze [caller options]")
_, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintln(out, "Extraction always runs; selected analysis artifacts and their required prerequisites are rebuilt. Publish and notify never run.")
}

View File

@@ -0,0 +1,135 @@
package app
import (
"bytes"
"context"
"io"
"reflect"
"strings"
"testing"
)
func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
original := runCommandFn
t.Cleanup(func() { runCommandFn = original })
var captured []string
runCommandFn = func(_ context.Context, args []string, _ io.Writer) error {
captured = append([]string(nil), args...)
return nil
}
code := Execute([]string{
"regenerate-artifacts", "2026-05-03",
"--artifacts", "session_recap,player_handout",
"--artifacts=player_handout",
"--config", "pipeline.yml",
"--campaign", "sample-campaign",
}, io.Discard, io.Discard)
if code != 0 {
t.Fatalf("Execute() code = %d, want 0", code)
}
want := []string{
"2026-05-03", "--force", "--from", "extract", "--through", "analyze",
"--artifacts", "session_recap,player_handout",
"--artifacts=player_handout",
"--config", "pipeline.yml",
"--campaign", "sample-campaign",
}
if !reflect.DeepEqual(captured, want) {
t.Fatalf("forwarded args = %#v, want %#v", captured, want)
}
}
func TestRegenerateArtifactsForwardsSessionIDCompatibilityFlag(t *testing.T) {
original := runCommandFn
t.Cleanup(func() { runCommandFn = original })
var captured []string
runCommandFn = func(_ context.Context, args []string, _ io.Writer) error {
captured = append([]string(nil), args...)
return nil
}
code := Execute([]string{
"regenerate-artifacts",
"--config", "pipeline.yml",
"--session-id", "2026-05-03",
"--artifacts", "session_recap",
}, io.Discard, io.Discard)
if code != 0 {
t.Fatalf("Execute() code = %d, want 0", code)
}
want := []string{
"--force", "--from", "extract", "--through", "analyze",
"--config", "pipeline.yml",
"--session-id", "2026-05-03",
"--artifacts", "session_recap",
}
if !reflect.DeepEqual(captured, want) {
t.Fatalf("forwarded args = %#v, want %#v", captured, want)
}
}
func TestRegenerateArtifactsHelpDoesNotInvokeRun(t *testing.T) {
original := runCommandFn
t.Cleanup(func() { runCommandFn = original })
called := false
runCommandFn = func(_ context.Context, _ []string, _ io.Writer) error {
called = true
return nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := Execute([]string{"regenerate-artifacts", "--help"}, &stdout, &stderr); code != 0 {
t.Fatalf("Execute() code = %d, stderr = %q", code, stderr.String())
}
if called {
t.Fatal("help invoked canonical run handler")
}
for _, detail := range []string{
"narratio run <session_id> --force --from extract --through analyze",
"Extraction always runs",
"Publish and notify never run",
} {
if !strings.Contains(stdout.String(), detail) {
t.Fatalf("help = %q, want %q", stdout.String(), detail)
}
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestRegenerateArtifactsOwnedOptionsFailThroughRunParser(t *testing.T) {
for _, test := range []struct {
name string
args []string
owned string
}{
{name: "force", args: []string{"--force"}, owned: "force"},
{name: "from", args: []string{"--from=render"}, owned: "from"},
{name: "through", args: []string{"--through", "publish"}, owned: "through"},
} {
t.Run(test.name, func(t *testing.T) {
args := []string{"regenerate-artifacts", "2026-05-03"}
args = append(args, test.args...)
var stderr bytes.Buffer
if code := Execute(args, io.Discard, &stderr); code == 0 {
t.Fatalf("Execute(%#v) code = 0", args)
}
if !strings.Contains(stderr.String(), "--"+test.owned+" may be specified only once") {
t.Fatalf("stderr = %q, want shared duplicate %s error", stderr.String(), test.owned)
}
})
}
}
func TestRegenerateArtifactsRejectsUnknownOptionsThroughRunParser(t *testing.T) {
var stderr bytes.Buffer
if code := Execute([]string{"regenerate-artifacts", "2026-05-03", "--regenerate-only"}, io.Discard, &stderr); code == 0 {
t.Fatal("Execute() code = 0")
}
if !strings.Contains(stderr.String(), "flag provided but not defined") || !strings.Contains(stderr.String(), "regenerate-only") {
t.Fatalf("stderr = %q, want canonical parser unknown-option error", stderr.String())
}
}

View File

@@ -13,7 +13,6 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
@@ -37,7 +36,7 @@ inputs:
if storeInitCalls != 1 {
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
}
if !strings.Contains(stdout.String(), "narratio session plan: workdir prepared") {
if !strings.Contains(stdout.String(), "narratio session plan: read-only workdir") {
t.Fatalf("stdout = %q, want plan output", stdout.String())
}
if _, ok := fake.Objects[remoteKey]; !ok {
@@ -85,7 +84,7 @@ inputs:
`,
command: []string{"run", "2026-05-03"},
configureRun: func() {
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
executeStagesFn = func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
return nil, errors.New("adapter failed")
}
},
@@ -99,7 +98,7 @@ inputs:
`,
command: []string{"run", "2026-05-03"},
configureRun: func() {
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
executeStagesFn = func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
return nil, context.Canceled
}
},

View File

@@ -14,7 +14,6 @@ import (
"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) {
@@ -34,12 +33,12 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
t.Cleanup(func() {
executeStagesFn = origExecuteStagesFn
})
executeStagesFn = func(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
executeStagesFn = func(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
if opts.Env == nil {
opts.Env = &Env{}
}
opts.Env.Scriptorium = &scriptorium.NoopRunner{}
return executeStages(ctx, cfg, stages, opts)
return executePlan(ctx, cfg, plan, opts)
}
var stdout bytes.Buffer
@@ -224,12 +223,12 @@ previous_session_id: 2026-04-26
executeStagesFn = origExecuteStagesFn
newObjectStoreFromConfigFn = origObjectStoreFn
})
executeStagesFn = func(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
executeStagesFn = func(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
if opts.Env == nil {
opts.Env = &Env{}
}
opts.Env.Scriptorium = scriptoriumFake
return executeStages(ctx, cfg, stages, opts)
return executePlan(ctx, cfg, plan, opts)
}
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
objectStoreConstructed = true
@@ -282,6 +281,7 @@ func restoreWorkflowManifestJSON(t *testing.T, sessionID, campaign string) []byt
for i, stageName := range stages {
m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil)
}
setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", []byte("# restored recap\n"))
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)

View File

@@ -2,6 +2,7 @@ package app
import (
"context"
"errors"
"flag"
"fmt"
"io"
@@ -11,22 +12,14 @@ import (
// Run executes the pipeline plan and persists manifest state.
func Run(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var flags commonConfigFlags
var force bool
var selectedArtifacts artifactSelectionFlag
addCommonConfigFlags(fs, &flags)
fs.BoolVar(&force, "force", false, "rerun stages even when already succeeded")
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
if err := parseSessionAwareFlags("run", fs, args, &flags.sessionID); err != nil {
request, err := parseBoundedRunRequest("run", args, out)
if err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if flags.sessionID == "" {
return fmt.Errorf("run: session_id is required")
}
flags := request.Config
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
if err != nil {
return fmt.Errorf("run: %w", err)
@@ -36,18 +29,13 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("run: %w", err)
}
normalizedArtifacts, err := selectedArtifacts.Normalize()
if err != nil {
return fmt.Errorf("run: invalid --artifacts: %w", err)
}
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, normalizedArtifacts)
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
if err != nil {
return fmt.Errorf("run: %w", err)
}
stages := BuildFullPlan()
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
Force: force,
SelectedArtifacts: normalizedArtifacts,
summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{
Force: request.Force,
SelectedArtifacts: request.SelectedArtifacts,
EffectiveArtifacts: effectiveArtifacts,
})
if err != nil {

View File

@@ -19,11 +19,6 @@ const (
stageActionSkip stageAction = "skip"
)
type stageDecision struct {
Stage stage.Stage
Action stageAction
}
const (
staleReasonForcedReplacement = "upstream stage was force-run"
staleReasonChangedResult = "upstream stage result changed"
@@ -39,19 +34,9 @@ type priorStageOutcome struct {
outputs int
}
func decideStageActions(stages []stage.Stage, m *manifest.Manifest, force bool) []stageDecision {
out := make([]stageDecision, 0, len(stages))
for _, s := range stages {
out = append(out, stageDecision{
Stage: s,
Action: decideStageAction(s, m, force),
})
}
return out
}
func decideStageAction(s stage.Stage, m *manifest.Manifest, force bool) stageAction {
// TODO: incorporate stale detection once checksum/input change tracking is implemented.
// A succeeded aggregate record is the initial skip candidate. The runner and
// planner then let stage-owned resume validation refine that decision.
if !force && stageSucceeded(m, s.Name()) {
return stageActionSkip
}
@@ -122,30 +107,153 @@ func canonicalStageNames() []string {
return out
}
func downstreamStageNames(stageName string) []string {
names := canonicalStageNames()
for i, name := range names {
if name != stageName {
continue
}
return append([]string(nil), names[i+1:]...)
}
return nil
type invalidationRelation struct {
canonical []string
direct map[string][]string
}
func invalidateDownstreamSucceededStagesWithReason(m *manifest.Manifest, upstreamStage string, at time.Time, reason string) []string {
if m == nil || m.Stages == nil {
var canonicalInvalidationEdges = map[string][]string{
"prepare": {"transcribe"},
"transcribe": {"merge"},
"merge": {"polish"},
"polish": {"normalize"},
"normalize": {"trim"},
"trim": {"render", "extract"},
"render": {"analyze"},
"extract": {"analyze"},
"analyze": {"publish"},
"publish": {"notify"},
"notify": {},
}
func newInvalidationRelation(registry []stage.Stage, direct map[string][]string) (*invalidationRelation, error) {
canonical := make([]string, 0, len(registry))
known := make(map[string]struct{}, len(registry))
for index, candidate := range registry {
if candidate == nil {
return nil, fmt.Errorf("canonical stage registry entry %d is nil", index)
}
name := strings.TrimSpace(candidate.Name())
if name == "" {
return nil, fmt.Errorf("canonical stage registry entry %d has an empty name", index)
}
if _, duplicate := known[name]; duplicate {
return nil, fmt.Errorf("canonical stage registry contains duplicate stage %q", name)
}
known[name] = struct{}{}
canonical = append(canonical, name)
}
cloned := make(map[string][]string, len(direct))
for source, targets := range direct {
if _, ok := known[source]; !ok {
return nil, fmt.Errorf("invalidation relation classifies unknown stage %q", source)
}
cloned[source] = []string{}
seenTargets := make(map[string]struct{}, len(targets))
for _, target := range targets {
if _, ok := known[target]; !ok {
return nil, fmt.Errorf("invalidation relation edge %q -> %q references an unknown stage", source, target)
}
if _, duplicate := seenTargets[target]; duplicate {
return nil, fmt.Errorf("invalidation relation contains duplicate edge %q -> %q", source, target)
}
seenTargets[target] = struct{}{}
cloned[source] = append(cloned[source], target)
}
}
for _, name := range canonical {
if _, classified := direct[name]; !classified {
return nil, fmt.Errorf("invalidation relation is missing classification for stage %q", name)
}
}
relation := &invalidationRelation{canonical: canonical, direct: cloned}
visiting := make(map[string]bool, len(canonical))
visited := make(map[string]bool, len(canonical))
var visit func(string) error
visit = func(name string) error {
if visiting[name] {
return fmt.Errorf("invalidation relation contains a cycle involving stage %q", name)
}
if visited[name] {
return nil
}
visiting[name] = true
for _, target := range relation.direct[name] {
if err := visit(target); err != nil {
return err
}
}
visiting[name] = false
visited[name] = true
return nil
}
for _, name := range canonical {
if err := visit(name); err != nil {
return nil, err
}
}
return relation, nil
}
func canonicalInvalidationRelation() (*invalidationRelation, error) {
return newInvalidationRelation(stage.All(), canonicalInvalidationEdges)
}
func (r *invalidationRelation) Dependents(stageName string) ([]string, error) {
if r == nil {
return nil, fmt.Errorf("invalidation relation is nil")
}
if _, ok := r.direct[stageName]; !ok {
return nil, fmt.Errorf("unknown stage %q in invalidation relation", stageName)
}
reachable := make(map[string]bool, len(r.canonical))
var collect func(string)
collect = func(name string) {
for _, target := range r.direct[name] {
if reachable[target] {
continue
}
reachable[target] = true
collect(target)
}
}
collect(stageName)
out := make([]string, 0, len(reachable))
for _, name := range r.canonical {
if reachable[name] {
out = append(out, name)
}
}
return out, nil
}
func dependentStageNames(stageName string) ([]string, error) {
relation, err := canonicalInvalidationRelation()
if err != nil {
return nil, err
}
return relation.Dependents(stageName)
}
func invalidateDependentSucceededStagesWithReason(m *manifest.Manifest, upstreamStage string, at time.Time, reason string) ([]string, error) {
dependents, err := dependentStageNames(upstreamStage)
if err != nil {
return nil, err
}
if m == nil || m.Stages == nil {
return nil, nil
}
invalidated := make([]string, 0)
for _, downstream := range downstreamStageNames(upstreamStage) {
sr := m.Stages[downstream]
for _, dependent := range dependents {
sr := m.Stages[dependent]
if sr == nil || sr.Status != manifest.StatusSucceeded {
continue
}
m.MarkStageStale(downstream, at, reason)
invalidated = append(invalidated, downstream)
m.MarkStageStale(dependent, at, reason)
invalidated = append(invalidated, dependent)
}
return invalidated
return invalidated, nil
}

View File

@@ -1,69 +1,109 @@
package app
import (
"context"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestDecideStageActions(t *testing.T) {
func TestDecideStageAction(t *testing.T) {
stages := BuildFullPlan()[:2]
m := manifest.New("2026-05-03", time.Now().UTC())
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
got := decideStageActions(stages, m, false)
if len(got) != 2 {
t.Fatalf("len(decisions) = %d, want 2", len(got))
if got := decideStageAction(stages[0], m, false); got != stageActionSkip {
t.Fatalf("prepare action = %q, want %q", got, stageActionSkip)
}
if got[0].Action != stageActionSkip {
t.Fatalf("prepare action = %q, want %q", got[0].Action, stageActionSkip)
}
if got[1].Action != stageActionRun {
t.Fatalf("transcribe action = %q, want %q", got[1].Action, stageActionRun)
if got := decideStageAction(stages[1], m, false); got != stageActionRun {
t.Fatalf("transcribe action = %q, want %q", got, stageActionRun)
}
forced := decideStageActions(stages, m, true)
if forced[0].Action != stageActionRun {
t.Fatalf("forced prepare action = %q, want %q", forced[0].Action, stageActionRun)
if got := decideStageAction(stages[0], m, true); got != stageActionRun {
t.Fatalf("forced prepare action = %q, want %q", got, stageActionRun)
}
}
func TestDownstreamStageNames(t *testing.T) {
got := downstreamStageNames("polish")
want := []string{"normalize", "trim", "extract", "render", "analyze", "publish", "notify"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
func TestInvalidationDependents(t *testing.T) {
tests := []struct {
stage string
want []string
}{
{"prepare", []string{"transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
{"transcribe", []string{"merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
{"merge", []string{"polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
{"polish", []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
{"normalize", []string{"trim", "render", "extract", "analyze", "publish", "notify"}},
{"trim", []string{"render", "extract", "analyze", "publish", "notify"}},
{"render", []string{"analyze", "publish", "notify"}},
{"extract", []string{"analyze", "publish", "notify"}},
{"analyze", []string{"publish", "notify"}},
{"publish", []string{"notify"}},
{"notify", []string{}},
}
missing := downstreamStageNames("unknown")
if len(missing) != 0 {
t.Fatalf("downstreamStageNames(unknown) = %#v, want empty", missing)
for _, test := range tests {
t.Run(test.stage, func(t *testing.T) {
got, err := dependentStageNames(test.stage)
if err != nil {
t.Fatalf("dependentStageNames(%q) error = %v", test.stage, err)
}
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("dependentStageNames(%q) = %#v, want %#v", test.stage, got, test.want)
}
})
}
if _, err := dependentStageNames("unknown"); err == nil || !strings.Contains(err.Error(), "unknown stage") {
t.Fatalf("dependentStageNames(unknown) error = %v, want unknown-stage error", err)
}
}
func TestInvalidateDownstreamSucceededStagesWithReason(t *testing.T) {
func TestInvalidationRelationRejectsInvalidInventory(t *testing.T) {
canonical := []stage.Stage{
invalidationTestStage("one"),
invalidationTestStage("two"),
}
tests := []struct {
name string
registry []stage.Stage
edges map[string][]string
want string
}{
{name: "duplicate registry name", registry: append(canonical, invalidationTestStage("one")), edges: map[string][]string{"one": {"two"}, "two": {}}, want: "duplicate stage"},
{name: "unknown source", registry: canonical, edges: map[string][]string{"one": {"two"}, "two": {}, "three": {}}, want: "unknown stage"},
{name: "unknown target", registry: canonical, edges: map[string][]string{"one": {"three"}, "two": {}}, want: "unknown stage"},
{name: "missing classification", registry: canonical, edges: map[string][]string{"one": {"two"}}, want: "missing classification"},
{name: "cycle", registry: canonical, edges: map[string][]string{"one": {"two"}, "two": {"one"}}, want: "cycle"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := newInvalidationRelation(test.registry, test.edges)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("newInvalidationRelation() error = %v, want %q", err, test.want)
}
})
}
}
func TestInvalidateDependentSucceededStagesWithReason(t *testing.T) {
now := time.Now().UTC()
m := manifest.New("2026-05-03", now)
m.MarkStageSucceeded("prepare", now, nil)
m.MarkStageSucceeded("transcribe", now, nil)
m.MarkStageSucceeded("merge", now, nil)
m.MarkStageSucceeded("polish", now, nil)
m.MarkStageSucceeded("normalize", now, nil)
m.MarkStageSucceeded("trim", now, nil)
m.MarkStageSucceeded("extract", now, nil)
m.MarkStageSucceeded("render", now, nil)
m.MarkStageFailed("analyze", now, "analysis failed")
m.MarkStageSucceeded("publish", now, nil)
m.MarkStageSucceeded("notify", now, nil)
got := invalidateDownstreamSucceededStagesWithReason(m, "polish", now.Add(1*time.Second), staleReasonChangedResult)
want := []string{"normalize", "trim", "extract", "render", "publish", "notify"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("invalidateDownstreamSucceededStagesWithReason() = %#v, want %#v", got, want)
for _, name := range canonicalStageNames() {
m.MarkStageSucceeded(name, now, nil)
}
m.MarkStageFailed("analyze", now, "analysis failed")
got, err := invalidateDependentSucceededStagesWithReason(m, "polish", now.Add(time.Second), staleReasonChangedResult)
if err != nil {
t.Fatalf("invalidateDependentSucceededStagesWithReason() error = %v", err)
}
want := []string{"normalize", "trim", "render", "extract", "publish", "notify"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("invalidateDependentSucceededStagesWithReason() = %#v, want %#v", got, want)
}
for _, stageName := range want {
if m.Stages[stageName].Status != manifest.StatusStale {
t.Fatalf("%s status = %q, want stale", stageName, m.Stages[stageName].Status)
@@ -72,34 +112,38 @@ func TestInvalidateDownstreamSucceededStagesWithReason(t *testing.T) {
if m.Stages["analyze"].Status != manifest.StatusFailed {
t.Fatalf("analyze status = %q, want failed", m.Stages["analyze"].Status)
}
if m.Stages["prepare"].Status != manifest.StatusSucceeded {
t.Fatalf("prepare status = %q, want succeeded", m.Stages["prepare"].Status)
}
}
func TestExtractionPositionControlsForceInvalidation(t *testing.T) {
func TestRenderAndExtractInvalidationAreIndependent(t *testing.T) {
now := time.Now().UTC()
tests := []struct {
upstream string
want []string
}{
{upstream: "trim", want: []string{"extract", "render", "analyze", "publish", "notify"}},
{upstream: "extract", want: []string{"render", "analyze", "publish", "notify"}},
{upstream: "render", want: []string{"analyze", "publish", "notify"}},
}
for _, test := range tests {
t.Run(test.upstream, func(t *testing.T) {
for _, upstream := range []string{"render", "extract"} {
t.Run(upstream, func(t *testing.T) {
m := manifest.New("2026-05-03", now)
for _, name := range canonicalStageNames() {
m.MarkStageSucceeded(name, now, nil)
}
got := invalidateDownstreamSucceededStagesWithReason(m, test.upstream, now.Add(time.Second), staleReasonForcedReplacement)
if !reflect.DeepEqual(got, test.want) {
t.Fatalf("invalidated = %#v, want %#v", got, test.want)
got, err := invalidateDependentSucceededStagesWithReason(m, upstream, now.Add(time.Second), staleReasonForcedReplacement)
if err != nil {
t.Fatalf("invalidate dependents: %v", err)
}
if test.upstream == "render" && m.Stages["extract"].Status != manifest.StatusSucceeded {
t.Fatalf("forcing render changed extract: %#v", m.Stages["extract"])
want := []string{"analyze", "publish", "notify"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("invalidated = %#v, want %#v", got, want)
}
sibling := "render"
if upstream == "render" {
sibling = "extract"
}
if m.Stages[sibling].Status != manifest.StatusSucceeded {
t.Fatalf("%s invalidated sibling %s: %#v", upstream, sibling, m.Stages[sibling])
}
})
}
}
type invalidationTestStage string
func (s invalidationTestStage) Name() string { return string(s) }
func (s invalidationTestStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
return &stage.StageResult{}, nil
}

View File

@@ -199,7 +199,7 @@ type singleStageCommand struct {
}
func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSummary, error) {
stages, err := BuildSingleStagePlan(req.StageName)
plan, err := buildSingleStageExecutionPlan(req.StageName)
if err != nil {
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
}
@@ -220,7 +220,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
if err != nil {
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
}
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
summary, err := executeStagesFn(ctx, cfg, plan, RunOptions{
Force: req.Force,
SelectedArtifacts: req.SelectedArtifacts,
EffectiveArtifacts: effectiveArtifacts,

View File

@@ -225,7 +225,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
if err != nil {
t.Fatalf("load manifest after force: %v", err)
}
for _, name := range []string{"normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
for _, name := range []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
}

View File

@@ -40,9 +40,17 @@ type RunSummary struct {
Skipped []string
}
var executeStagesFn = executeStages
var executeStagesFn = executePlan
func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (summary *RunSummary, resultErr error) {
func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (summary *RunSummary, resultErr error) {
stages := plan.Stages()
var prerequisiteStore manifest.Store
if opts.Env != nil {
prerequisiteStore = opts.Env.ManifestStore
}
if err := inspectBoundedPrerequisites(ctx, cfg, plan, prerequisiteStore); err != nil {
return nil, fmt.Errorf("validate bounded run prerequisites: %w", err)
}
effectiveArtifacts := opts.EffectiveArtifacts
if !effectiveArtifacts.Resolved() && cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
var err error
@@ -131,6 +139,13 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
return nil, fmt.Errorf("create manifest: %w", err)
}
}
// The preflight prerequisite inspection avoids creating run state for an
// already-invalid request. Recheck the manifest protected by the session
// lock because another invocation may have changed prerequisite state while
// this invocation waited to acquire the lock.
if err := validateBoundedPrerequisites(plan, m); err != nil {
return nil, fmt.Errorf("validate bounded run prerequisites under session lock: %w", err)
}
identity.applyToSessionManifest(m)
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
@@ -169,7 +184,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
fmt.Errorf("load secrets from files: %w", err),
)
}
if env.WhisperX == nil {
if env.WhisperX == nil && stagesContainAny(stages, "transcribe") {
client, err := buildDefaultWhisperXClient(env.Config)
if err != nil {
return nil, persistTerminalFailure(
@@ -179,7 +194,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
}
env.WhisperX = client
}
if env.Seriatim == nil {
if env.Seriatim == nil && stagesContainAny(stages, "merge", "normalize", "trim", "render") {
runner, err := buildDefaultSeriatimRunner(env.Config)
if err != nil {
return nil, persistTerminalFailure(
@@ -189,7 +204,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
}
env.Seriatim = runner
}
if env.Audita == nil {
if env.Audita == nil && stagesContainAny(stages, "polish") {
runner, err := buildDefaultAuditaRunner(env.Config)
if err != nil {
return nil, persistTerminalFailure(
@@ -202,7 +217,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
if env.Notarius == nil && needsNotariusForRun(env.Config, stages) {
env.Notarius = notarius.NewSubprocessRunner()
}
if env.Scriptorium == nil {
if env.Scriptorium == nil && stagesContainAny(stages, "trim", "analyze") {
env.Scriptorium = scriptorium.NewSubprocessRunner()
}
if env.ObjectStore == nil && needsObjectStoreForRun(env.Config, stages, effectiveArtifacts) {
@@ -232,23 +247,21 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
return config.MergePublishLockRules(staticLocks, remote.Locks), nil
}
}
if env.Notifier == nil {
if env.Notifier == nil && stagesContainAny(stages, "notify") {
env.Notifier = &notify.NoopSender{}
}
stageEnv := env
decisions := decideStageActions(stages, m, opts.Force)
runNames := make([]string, 0, len(decisions))
executed := make([]string, 0, len(decisions))
skipped := make([]string, 0, len(decisions))
for _, d := range decisions {
s := d.Stage
runNames := make([]string, 0, len(stages))
executed := make([]string, 0, len(stages))
skipped := make([]string, 0, len(stages))
for _, s := range stages {
stageEnv.Force = opts.Force
runNames = append(runNames, s.Name())
d.Action = decideStageAction(s, m, opts.Force)
action := decideStageAction(s, m, opts.Force)
if d.Action == stageActionSkip {
if action == stageActionSkip {
if validator, ok := s.(stage.ResumeValidator); ok {
validation, err := validator.ValidateResume(ctx, stageEnv, m)
if err != nil {
@@ -261,9 +274,14 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
if !validation.Resumable {
staleAt := nowUTC()
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
invalidateDownstreamSucceededStagesWithReason(
if _, err := invalidateDependentSucceededStagesWithReason(
m, s.Name(), staleAt, staleReasonNotResumable,
)
); err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("invalidate dependents after resume validation for stage %q: %w", s.Name(), err),
)
}
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
@@ -271,12 +289,12 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
)
}
env.Logger.Info("stage result is not resumable", "stage", s.Name(), "reason", validation.Reason)
d.Action = stageActionRun
action = stageActionRun
}
}
}
if d.Action == stageActionSkip {
if action == stageActionSkip {
skipped = append(skipped, s.Name())
skipAt := nowUTC()
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
@@ -292,6 +310,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
}
executed = append(executed, s.Name())
priorOutcome := capturePriorStageOutcome(m, s.Name())
priorAnalyzeState := captureAnalyzeState(m, s.Name())
now := nowUTC()
runManifest.SetStageAction(s.Name(), manifest.RunStageActionRun, now)
@@ -305,13 +324,20 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
}
m.MarkStageRunning(s.Name(), now)
if opts.Force {
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), now, staleReasonForcedReplacement)
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), now, staleReasonForcedReplacement); err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("invalidate dependents before forced stage %q: %w", s.Name(), err),
)
}
}
env.Logger.Info("starting stage", "stage", s.Name())
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
operationErr := fmt.Errorf("save manifest before stage %q: %w", s.Name(), err)
m.MarkStageFailed(s.Name(), nowUTC(), operationErr.Error())
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), nowUTC(), staleReasonFailure)
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), nowUTC(), staleReasonFailure); invalidationErr != nil {
operationErr = errors.Join(operationErr, fmt.Errorf("invalidate dependents after stage %q persistence failure: %w", s.Name(), invalidationErr))
}
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest, operationErr,
)
@@ -319,13 +345,28 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "running", "path", manifestPath)
result, err := s.Run(ctx, stageEnv, m)
var analyzeProjection *validatedAnalyzeProjection
if err == nil {
err = validateStageResult(result)
if err == nil {
analyzeProjection, err = validateSuccessfulAnalyzeProjection(s.Name(), result)
}
} else if result != nil && result.AnalyzeState != nil {
var projectionErr error
analyzeProjection, projectionErr = validateFailedAnalyzeProjection(s.Name(), result)
if projectionErr != nil {
err = errors.Join(err, projectionErr)
}
}
if err != nil {
if analyzeProjection != nil {
applyAnalyzeProjection(m, runManifest, analyzeProjection)
}
failedAt := nowUTC()
m.MarkStageFailed(s.Name(), failedAt, err.Error())
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure)
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure); invalidationErr != nil {
err = errors.Join(err, fmt.Errorf("invalidate dependents after stage %q failure: %w", s.Name(), invalidationErr))
}
runManifest.MarkStageFailed(s.Name(), failedAt, err.Error())
identity.applyToRunManifest(runManifest, manifestPath)
env.Logger.Info("stage failed", "stage", s.Name(), "error", err)
@@ -340,7 +381,12 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
applyStageResultToManifest(m, s.Name(), result)
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip)
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip); err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("invalidate dependents after stage %q self-skip: %w", s.Name(), err),
)
}
}
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
return nil, persistTerminalFailure(
@@ -362,21 +408,42 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
continue
}
outputs := mapResultOutputs(s.Name(), result, runID)
sessionOutputs := mapResultOutputs(s.Name(), result, runID)
runOutputs := sessionOutputs
if analyzeProjection != nil {
sessionOutputs = analyzeProjectionOutputs(analyzeProjection.session, "")
runOutputs = analyzeProjectionOutputs(analyzeProjection.invocation, runID)
}
succeededAt := nowUTC()
m.MarkStageSucceeded(s.Name(), succeededAt, outputs)
m.MarkStageSucceeded(s.Name(), succeededAt, sessionOutputs)
applyAnalyzeProjection(m, runManifest, analyzeProjection)
applyStageResultToManifest(m, s.Name(), result)
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult)
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult); err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("invalidate dependents after changed stage %q result: %w", s.Name(), err),
)
}
}
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
operationErr := fmt.Errorf("save manifest after stage %q: %w", s.Name(), err)
if analyzeProjection != nil {
restoreAnalyzeState(m, priorAnalyzeState)
failedAt := nowUTC()
m.MarkStageFailed(s.Name(), failedAt, operationErr.Error())
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure); invalidationErr != nil {
operationErr = errors.Join(operationErr, fmt.Errorf("invalidate dependents after analyze projection persistence failure: %w", invalidationErr))
}
runManifest.MarkStageFailed(s.Name(), failedAt, operationErr.Error())
}
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("save manifest after stage %q: %w", s.Name(), err),
operationErr,
)
}
runManifest.MarkStageSucceeded(s.Name(), succeededAt, outputs)
runManifest.MarkStageSucceeded(s.Name(), succeededAt, runOutputs)
applyStageResultToRunManifest(runManifest, s.Name(), result)
identity.applyToRunManifest(runManifest, manifestPath)
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
@@ -401,11 +468,13 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
// The run record lives inside the run work directory, which cleanup may
// remove. Persist its completed publishing result before cleanup starts so a
// successful deletion cannot be undone by a later diagnostic write.
if err := runPostPublishCleanup(ctx, env, manifestPath, m, executed); err != nil {
return nil, persistPostPublishCleanupFailure(
ctx, env.ManifestStore, manifestPath, m,
fmt.Errorf("post-publish cleanup incomplete: %w", err),
)
if containsStage(executed, "publish") {
if err := runPostPublishCleanup(ctx, env, manifestPath, m, executed); err != nil {
return nil, persistPostPublishCleanupFailure(
ctx, env.ManifestStore, manifestPath, m,
fmt.Errorf("post-publish cleanup incomplete: %w", err),
)
}
}
return &RunSummary{
@@ -419,6 +488,22 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
}, nil
}
func stagesContainAny(stages []stage.Stage, names ...string) bool {
wanted := make(map[string]struct{}, len(names))
for _, name := range names {
wanted[name] = struct{}{}
}
for _, candidate := range stages {
if candidate == nil {
continue
}
if _, ok := wanted[candidate.Name()]; ok {
return true
}
}
return false
}
func persistPostPublishCleanupFailure(
ctx context.Context,
sessionStore manifest.Store,

View File

@@ -564,12 +564,12 @@ func TestExecuteStagesUsesOptionalResumeValidation(t *testing.T) {
cfg := testConfig(t)
store := &manifest.LocalStore{}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
seed.MarkStageSucceeded("checked", time.Now().UTC(), nil)
seed.MarkStageSucceeded("extract", time.Now().UTC(), nil)
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatalf("Save() error = %v", err)
}
runs := 0
candidate := resumeCheckingStage{name: "checked", validation: test.validation, runs: &runs}
candidate := resumeCheckingStage{name: "extract", validation: test.validation, runs: &runs}
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
@@ -581,7 +581,7 @@ func TestExecuteStagesUsesOptionalResumeValidation(t *testing.T) {
}
}
func TestExecuteStagesNonResumableResultRerunsSucceededDownstream(t *testing.T) {
func TestExecuteStagesNonResumableResultPreservesSucceededSibling(t *testing.T) {
cfg := testConfig(t)
store := &manifest.LocalStore{}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
@@ -599,7 +599,7 @@ func TestExecuteStagesNonResumableResultRerunsSucceededDownstream(t *testing.T)
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if extractRuns != 1 || renderRuns != 1 || len(summary.Executed) != 2 || len(summary.Skipped) != 0 {
if extractRuns != 1 || renderRuns != 0 || len(summary.Executed) != 1 || len(summary.Skipped) != 1 {
t.Fatalf("extract runs=%d render runs=%d summary=%#v", extractRuns, renderRuns, summary)
}
}
@@ -1019,14 +1019,14 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
store := &manifest.LocalStore{}
manifestPath := manifestPathFor(cfg)
seed := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
seed.MarkStageSucceeded("optional", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{
seed.MarkStageSucceeded("extract", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{
Kind: "old_output",
SourceID: "narratio.example.old",
LocalPath: "artifacts/old.json",
}})
seed.Stages["optional"].Logs = []string{"old.log"}
seed.Stages["optional"].GeneratedConfigs = []string{"old.yml"}
seed.Stages["optional"].Metadata = map[string]any{"old": true}
seed.Stages["extract"].Logs = []string{"old.log"}
seed.Stages["extract"].GeneratedConfigs = []string{"old.yml"}
seed.Stages["extract"].Metadata = map[string]any{"old": true}
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
@@ -1038,7 +1038,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
optionalRuns := 0
stages := []stage.Stage{
resultStage{
name: "optional",
name: "extract",
runs: &optionalRuns,
order: &order,
result: &stage.StageResult{
@@ -1049,16 +1049,16 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
Metadata: map[string]any{"enabled": false},
},
},
resultStage{name: "later", order: &order, result: &stage.StageResult{}},
resultStage{name: "analyze", order: &order, result: &stage.StageResult{}},
}
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{Force: true})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if strings.Join(order, ",") != "optional,later" {
t.Fatalf("execution order = %v, want optional then later", order)
if strings.Join(order, ",") != "extract,analyze" {
t.Fatalf("execution order = %v, want extract then analyze", order)
}
if len(summary.Executed) != 2 || len(summary.Skipped) != 1 || summary.Skipped[0] != "optional" {
if len(summary.Executed) != 2 || len(summary.Skipped) != 1 || summary.Skipped[0] != "extract" {
t.Fatalf("summary = %#v, want optional executed and self-skipped before later", summary)
}
@@ -1066,7 +1066,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
if err != nil {
t.Fatalf("Load() session manifest error = %v", err)
}
selfSkipped := sessionManifest.Stages["optional"]
selfSkipped := sessionManifest.Stages["extract"]
if selfSkipped == nil || selfSkipped.Status != manifest.StatusSkipped {
t.Fatalf("optional stage = %#v, want skipped", selfSkipped)
}
@@ -1081,7 +1081,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
selfSkipped.Metadata["enabled"] != false || selfSkipped.Metadata["old"] != nil {
t.Fatalf("optional result details = %#v, want current bounded diagnostics and metadata", selfSkipped)
}
if later := sessionManifest.Stages["later"]; later == nil || later.Status != manifest.StatusSucceeded {
if later := sessionManifest.Stages["analyze"]; later == nil || later.Status != manifest.StatusSucceeded {
t.Fatalf("later stage = %#v, want succeeded", later)
}
@@ -1089,7 +1089,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
if err != nil {
t.Fatalf("LoadRun() error = %v", err)
}
runStage := runManifest.Stages["optional"]
runStage := runManifest.Stages["extract"]
if runStage == nil || runStage.Action != manifest.RunStageActionRun || runStage.Status != manifest.StatusSkipped {
t.Fatalf("run optional stage = %#v, want run action with skipped status", runStage)
}
@@ -1108,7 +1108,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
cfg := testConfig(t)
invalid := resultStage{name: "optional", result: &stage.StageResult{
invalid := resultStage{name: "extract", result: &stage.StageResult{
Disposition: stage.StageDispositionSkipped,
SkipReason: "integration_disabled",
Outputs: []artifacts.Ref{{Kind: "unexpected"}},
@@ -1129,7 +1129,7 @@ func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
if loadErr != nil {
t.Fatalf("Load() session manifest error = %v", loadErr)
}
if got := loaded.Stages["optional"]; got == nil || got.Status != manifest.StatusFailed {
if got := loaded.Stages["extract"]; got == nil || got.Status != manifest.StatusFailed {
t.Fatalf("optional stage = %#v, want failed", got)
}
}

View File

@@ -0,0 +1,15 @@
package app
import (
"context"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
// executeStages keeps runner tests focused on controlled stage doubles. The
// production command path always supplies one validated BoundedPlan directly.
func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
plan := BoundedPlan{stages: append([]stage.Stage(nil), stages...)}
return executePlan(ctx, cfg, plan, opts)
}

View File

@@ -11,7 +11,6 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
@@ -21,7 +20,7 @@ func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
var capturedSessionID string
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
capturedSessionID = cfg.Session.SessionID
return &RunSummary{
SessionID: cfg.Session.SessionID,
@@ -117,7 +116,7 @@ inputs:
`)
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
return &RunSummary{
SessionID: cfg.Session.SessionID,
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
@@ -194,8 +193,8 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
var capturedArtifacts []string
origExecuteStagesFn := executeStagesFn
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
for _, s := range stages {
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
for _, s := range plan.Stages() {
capturedStages = append(capturedStages, s.Name())
}
capturedForce = opts.Force
@@ -257,7 +256,7 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
{
name: "plan",
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
want: "narratio session plan: workdir prepared",
want: "narratio session plan: read-only workdir",
},
{
name: "artifacts",

17
internal/app/version.go Normal file
View File

@@ -0,0 +1,17 @@
package app
import (
"fmt"
"io"
"gitea.maximumdirect.net/eric/narratio/internal/buildinfo"
)
// Version prints the version embedded in the current Narratio binary.
func Version(args []string, out io.Writer) error {
if len(args) != 0 {
return fmt.Errorf("version: unexpected arguments")
}
_, err := fmt.Fprintf(out, "narratio %s\n", buildinfo.Version)
return err
}

View File

@@ -0,0 +1,38 @@
package app
import (
"bytes"
"io"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/buildinfo"
)
func TestExecuteVersionReportsEmbeddedBuildVersion(t *testing.T) {
original := buildinfo.Version
buildinfo.Version = "v1.5.0-test"
t.Cleanup(func() { buildinfo.Version = original })
var stdout bytes.Buffer
var stderr bytes.Buffer
if code := Execute([]string{"version"}, &stdout, &stderr); code != 0 {
t.Fatalf("Execute() code = %d, stderr = %q", code, stderr.String())
}
if got, want := stdout.String(), "narratio v1.5.0-test\n"; got != want {
t.Fatalf("stdout = %q, want %q", got, want)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestExecuteVersionRejectsArguments(t *testing.T) {
var stderr bytes.Buffer
if code := Execute([]string{"version", "extra"}, io.Discard, &stderr); code == 0 {
t.Fatal("Execute() code = 0, want failure")
}
if !strings.Contains(stderr.String(), "version: unexpected arguments") {
t.Fatalf("stderr = %q, want argument error", stderr.String())
}
}

View File

@@ -13,9 +13,10 @@ import (
const (
SourceBoundsSession = "narratio.bounds.session"
SourceInputPlayers = "narratio.input.players"
SourceInputParty = "narratio.input.party"
SourceInputGlossary = "narratio.input.glossary"
SourceInputPlayers = "narratio.input.players"
SourceInputParty = "narratio.input.party"
SourceInputGlossary = "narratio.input.glossary"
SourceInputSpellCatalog = "narratio.input.spell_catalog"
configuredSourcePrefix = "narratio.artifact."
extractionSourcePrefix = "narratio.extraction."
@@ -55,6 +56,14 @@ type ScriptoriumInputSourceDescriptor struct {
PreviousSession *PreviousSessionSourceDescriptor
}
// PreparedInputSourceDescriptor describes a prepared stable input's source,
// manifest kind, and canonical staged filename.
type PreparedInputSourceDescriptor struct {
SourceID string
ManifestKind string
Filename string
}
// PreviousSessionSourceDescriptor describes one canonical previous-session input source.
type PreviousSessionSourceDescriptor struct {
SourceID string
@@ -158,9 +167,9 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
if trimmed == "" {
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
}
if IsStableInputSource(trimmed) {
if prepared, ok := DescribePreparedInputSource(trimmed); ok {
return ScriptoriumInputSourceDescriptor{
Source: Source{ID: trimmed, Kind: SourceKindStableInput},
Source: Source{ID: prepared.SourceID, Kind: SourceKindStableInput},
}, nil
}
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
@@ -185,15 +194,34 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
return ScriptoriumInputSourceDescriptor{Source: classified}, nil
}
// IsStableInputSource reports whether source is a prepared stable input source
// available only to Scriptorium input resolution.
func IsStableInputSource(source string) bool {
switch strings.TrimSpace(source) {
case SourceInputPlayers, SourceInputParty, SourceInputGlossary:
return true
default:
return false
}
var preparedInputSources = map[string]PreparedInputSourceDescriptor{
SourceInputPlayers: {
SourceID: SourceInputPlayers,
ManifestKind: "players",
Filename: "players.yml",
},
SourceInputParty: {
SourceID: SourceInputParty,
ManifestKind: "party",
Filename: "party.yml",
},
SourceInputGlossary: {
SourceID: SourceInputGlossary,
ManifestKind: "glossary",
Filename: "glossary.yml",
},
SourceInputSpellCatalog: {
SourceID: SourceInputSpellCatalog,
ManifestKind: "spell_catalog",
Filename: "spell_catalog.json",
},
}
// DescribePreparedInputSource returns the canonical descriptor for a prepared
// stable input source.
func DescribePreparedInputSource(source string) (PreparedInputSourceDescriptor, bool) {
descriptor, ok := preparedInputSources[strings.TrimSpace(source)]
return descriptor, ok
}
// DescribePreviousSessionSource validates a canonical previous-session source id

View File

@@ -170,6 +170,7 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
{name: "prepared players input", source: "narratio.input.players", wantKind: SourceKindStableInput},
{name: "prepared party input", source: "narratio.input.party", wantKind: SourceKindStableInput},
{name: "prepared glossary input", source: "narratio.input.glossary", wantKind: SourceKindStableInput},
{name: "prepared spell catalog input", source: "narratio.input.spell_catalog", wantKind: SourceKindStableInput},
{name: "configured", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"},
{name: "previous", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap", wantPrev: true},
{name: "invalid previous", source: "narratio.previous_session.artifact.", wantErr: ErrInvalidPreviousSessionSource},
@@ -211,6 +212,35 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
}
}
func TestDescribePreparedInputSource(t *testing.T) {
tests := []struct {
source string
manifestKind string
filename string
}{
{source: SourceInputPlayers, manifestKind: "players", filename: "players.yml"},
{source: SourceInputParty, manifestKind: "party", filename: "party.yml"},
{source: SourceInputGlossary, manifestKind: "glossary", filename: "glossary.yml"},
{source: SourceInputSpellCatalog, manifestKind: "spell_catalog", filename: "spell_catalog.json"},
}
for _, tt := range tests {
t.Run(tt.manifestKind, func(t *testing.T) {
descriptor, ok := DescribePreparedInputSource(" " + tt.source + " ")
if !ok {
t.Fatalf("DescribePreparedInputSource(%q) ok = false", tt.source)
}
if descriptor.SourceID != tt.source || descriptor.ManifestKind != tt.manifestKind || descriptor.Filename != tt.filename {
t.Fatalf("DescribePreparedInputSource(%q) = %#v", tt.source, descriptor)
}
})
}
if _, ok := DescribePreparedInputSource("narratio.input.unknown"); ok {
t.Fatal("DescribePreparedInputSource(unknown) ok = true")
}
}
func TestValidateInputConfiguredReference(t *testing.T) {
configured := map[string]struct{}{"session_recap": {}}

View File

@@ -0,0 +1,144 @@
package artifacts
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
// AnalyzeEvidenceState distinguishes a verified current configured artifact
// from every form of unavailable evidence.
type AnalyzeEvidenceState string
const (
AnalyzeEvidenceCurrent AnalyzeEvidenceState = "current"
AnalyzeEvidenceNonCurrent AnalyzeEvidenceState = "non_current"
)
// AnalyzeEvidence is the read-only result of inspecting one configured
// artifact's manifest record and durable output.
type AnalyzeEvidence struct {
State AnalyzeEvidenceState
Reason string
SourceID string
Path string
ProducerRunID string
Contract *artifactmodel.ContractMetadata
Checksum string
Size int64
}
// InspectAnalyzeEvidence verifies that one configured artifact has supported,
// current manifest evidence for the exact canonical bytes on disk.
func InspectAnalyzeEvidence(
paths SessionPaths,
m *manifest.Manifest,
key string,
configured ConfiguredArtifactDefinition,
) AnalyzeEvidence {
normalizedKey := strings.TrimSpace(key)
sourceID := ConfiguredArtifactSourceID(normalizedKey)
nonCurrent := func(reason string) AnalyzeEvidence {
return AnalyzeEvidence{State: AnalyzeEvidenceNonCurrent, Reason: reason, SourceID: sourceID}
}
if m == nil {
return nonCurrent("analyze manifest evidence is absent; regenerate the artifact")
}
stageRecord := m.Stages["analyze"]
if stageRecord == nil || stageRecord.Name != "analyze" {
return nonCurrent("analyze manifest evidence is absent; regenerate the artifact")
}
if !stageRecord.HasVersionedAnalyzeState() {
return nonCurrent("analyze manifest evidence is legacy or unsupported; regenerate the artifact")
}
record, ok := stageRecord.AnalyzeArtifacts[normalizedKey]
if !ok {
return nonCurrent("configured artifact has no analyze manifest record; regenerate the artifact")
}
if record.Status != manifest.AnalyzeArtifactCurrent {
return nonCurrent(fmt.Sprintf("configured artifact manifest status is %q; regenerate the artifact", record.Status))
}
if err := manifest.ValidateAnalyzeArtifactCollection(
stageRecord.AnalyzeStateVersion,
map[string]manifest.AnalyzeArtifactRecord{normalizedKey: record},
); err != nil {
return nonCurrent("configured artifact manifest evidence is malformed; regenerate the artifact")
}
configuredPath, err := pathsafe.NormalizeRelativeDestination(strings.TrimSpace(configured.OutputPath))
if err != nil {
return nonCurrent("configured artifact output path is unsafe; correct the configuration")
}
if record.Output.LocalPath != configuredPath {
return nonCurrent("configured artifact manifest path differs from current configuration; regenerate the artifact")
}
if record.Output.SourceID != sourceID || record.Output.Kind != "scriptorium_artifact" {
return nonCurrent("configured artifact manifest identity is incompatible; regenerate the artifact")
}
file, err := fileops.OpenConfinedRegularFile(paths.Root, configuredPath)
if err != nil {
return nonCurrent("configured artifact output is missing or unsafe; regenerate the artifact")
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
return nonCurrent("configured artifact output is not a safe regular file; regenerate the artifact")
}
hash := sha256.New()
size, err := io.Copy(hash, file)
if err != nil {
return nonCurrent("configured artifact output could not be verified; regenerate the artifact")
}
if size != info.Size() || size != record.OutputSize {
return nonCurrent("configured artifact output size differs from manifest evidence; regenerate the artifact")
}
if hex.EncodeToString(hash.Sum(nil)) != record.Output.Checksum {
return nonCurrent("configured artifact output checksum differs from manifest evidence; regenerate the artifact")
}
return AnalyzeEvidence{
State: AnalyzeEvidenceCurrent,
SourceID: sourceID,
Path: filepath.Join(paths.Root, filepath.FromSlash(configuredPath)),
ProducerRunID: record.ProducerRunID,
Contract: cloneArtifactContract(record.Output.Contract),
Checksum: record.Output.Checksum,
Size: record.OutputSize,
}
}
// HydrateAnalyzeArtifacts makes configured sources available only from
// validated current manifest evidence. It never mutates manifest state.
func (c *ArtifactCatalog) HydrateAnalyzeArtifacts(
paths SessionPaths,
m *manifest.Manifest,
configured map[string]ConfiguredArtifactDefinition,
) {
if c == nil || len(configured) == 0 {
return
}
for _, entry := range c.ListConfigured() {
definition, ok := configured[entry.ConfiguredKey]
if !ok {
continue
}
evidence := InspectAnalyzeEvidence(paths, m, entry.ConfiguredKey, definition)
if evidence.State != AnalyzeEvidenceCurrent {
continue
}
_ = c.markAvailableFromAnalyzeManifest(
evidence.SourceID, evidence.Path, evidence.ProducerRunID,
evidence.Checksum, evidence.Size, evidence.Contract,
)
}
}

View File

@@ -0,0 +1,234 @@
package artifacts
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestInspectAnalyzeEvidenceAcceptsCurrentCanonicalOutput(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
body := []byte("recap\n")
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", body)
got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"})
if got.State != AnalyzeEvidenceCurrent {
t.Fatalf("State = %q, reason = %q", got.State, got.Reason)
}
if got.SourceID != ConfiguredArtifactSourceID("session_recap") || got.Path != filepath.Join(paths.ArtifactsDir, "session_recap.md") || got.ProducerRunID != "run-1" {
t.Fatalf("evidence = %#v", got)
}
}
func TestInspectAnalyzeEvidenceRejectsNonCurrentAndInvalidEvidence(t *testing.T) {
tests := []struct {
name string
mutate func(*manifest.Manifest)
config ConfiguredArtifactDefinition
reason string
}{
{name: "absent manifest", mutate: func(m *manifest.Manifest) { *m = manifest.Manifest{} }, reason: "absent"},
{name: "legacy", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeStateVersion = 0
m.Stages["analyze"].AnalyzeArtifacts = nil
}, reason: "legacy"},
{name: "unsupported version", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeStateVersion++ }, reason: "legacy or unsupported"},
{name: "missing record", mutate: func(m *manifest.Manifest) { delete(m.Stages["analyze"].AnalyzeArtifacts, "session_recap") }, reason: "no analyze manifest record"},
{name: "stale", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactStale), reason: `status is "stale"`},
{name: "missing", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactMissing), reason: `status is "missing"`},
{name: "failed", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactFailed), reason: `status is "failed"`},
{name: "unselected", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactUnselected), reason: `status is "unselected"`},
{name: "fingerprint version", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.FingerprintVersion++ })
}, reason: "malformed"},
{name: "key mismatch", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Key = "other" })
}, reason: "malformed"},
{name: "source mismatch", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.SourceID = ConfiguredArtifactSourceID("other") })
}, reason: "malformed"},
{name: "missing contract", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Contract = nil })
}, reason: "malformed"},
{name: "configured path mismatch", config: ConfiguredArtifactDefinition{OutputPath: "artifacts/renamed.md"}, reason: "differs from current configuration"},
{name: "record path mismatch", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.LocalPath = "artifacts/other.md" })
}, reason: "differs from current configuration"},
{name: "size mismatch", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.OutputSize++ })
}, reason: "size differs"},
{name: "checksum mismatch", mutate: func(m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("0", 64) })
}, reason: "checksum differs"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
if test.mutate != nil {
test.mutate(m)
}
definition := test.config
if definition.OutputPath == "" {
definition.OutputPath = "artifacts/session_recap.md"
}
got := InspectAnalyzeEvidence(paths, m, "session_recap", definition)
if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, test.reason) {
t.Fatalf("evidence = %#v, want non-current reason containing %q", got, test.reason)
}
})
}
}
func TestInspectAnalyzeEvidenceRejectsMissingAndUnsafeFiles(t *testing.T) {
tests := []struct {
name string
alter func(t *testing.T, paths SessionPaths, outputPath string)
}{
{name: "missing", alter: func(t *testing.T, _ SessionPaths, outputPath string) {
if err := os.Remove(outputPath); err != nil {
t.Fatal(err)
}
}},
{name: "directory", alter: func(t *testing.T, _ SessionPaths, outputPath string) {
if err := os.Remove(outputPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(outputPath, 0o755); err != nil {
t.Fatal(err)
}
}},
{name: "symlink leaf", alter: func(t *testing.T, paths SessionPaths, outputPath string) {
if err := os.Remove(outputPath); err != nil {
t.Fatal(err)
}
target := filepath.Join(paths.Root, "target.md")
if err := os.WriteFile(target, []byte("recap\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, outputPath); err != nil {
t.Fatal(err)
}
}},
{name: "symlink ancestor", alter: func(t *testing.T, paths SessionPaths, outputPath string) {
if err := os.RemoveAll(paths.ArtifactsDir); err != nil {
t.Fatal(err)
}
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "session_recap.md"), []byte("recap\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, paths.ArtifactsDir); err != nil {
t.Fatal(err)
}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md")
test.alter(t, paths, outputPath)
got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"})
if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, "missing or unsafe") {
t.Fatalf("evidence = %#v", got)
}
})
}
}
func TestHydrateAnalyzeArtifactsUsesOnlyCurrentConfiguredKeys(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
m.Stages["analyze"].AnalyzeArtifacts["removed"] = analyzeEvidenceRecord(t, paths, "removed", "artifacts/removed.md", []byte("old\n"))
configured := map[string]ConfiguredArtifactDefinition{"session_recap": {OutputPath: "artifacts/session_recap.md"}}
catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(configured, nil); err != nil {
t.Fatal(err)
}
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
entry, _ := catalog.Lookup(ConfiguredArtifactSourceID("session_recap"))
if !entry.Available || entry.Provenance != ArtifactProvenanceCurrentAnalyzeManifest || entry.ProducerRunID != "run-1" {
t.Fatalf("entry = %#v", entry)
}
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
if entry.Checksum != record.Output.Checksum || entry.Size != record.OutputSize {
t.Fatalf("entry content identity = (%q, %d), want (%q, %d)", entry.Checksum, entry.Size, record.Output.Checksum, record.OutputSize)
}
if entry.Contract == nil || *entry.Contract != *record.Output.Contract {
t.Fatalf("entry contract = %#v, want %#v", entry.Contract, record.Output.Contract)
}
entry.Contract.SchemaVersion = "mutated"
again, _ := catalog.Lookup(ConfiguredArtifactSourceID("session_recap"))
if again.Contract == nil || again.Contract.SchemaVersion != "1" {
t.Fatalf("catalog contract was mutated through lookup: %#v", again.Contract)
}
if _, ok := catalog.Lookup(ConfiguredArtifactSourceID("removed")); ok {
t.Fatal("removed manifest record was advertised in current catalog")
}
}
func analyzeEvidenceFixture(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) *manifest.Manifest {
t.Helper()
record := analyzeEvidenceRecord(t, paths, key, relativePath, body)
now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
m := manifest.New(paths.SessionID, now)
m.Stages["analyze"] = &manifest.StageRecord{
Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now,
AnalyzeStateVersion: manifest.AnalyzeStateContractVersion,
AnalyzeArtifacts: map[string]manifest.AnalyzeArtifactRecord{key: record},
}
return m
}
func analyzeEvidenceRecord(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) manifest.AnalyzeArtifactRecord {
t.Helper()
outputPath := filepath.Join(paths.Root, filepath.FromSlash(relativePath))
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(outputPath, body, 0o644); err != nil {
t.Fatal(err)
}
checksum, err := SHA256File(outputPath)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
return manifest.AnalyzeArtifactRecord{
Key: key, Status: manifest.AnalyzeArtifactCurrent,
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("1", 64),
Output: &manifest.ArtifactRecord{
Kind: "scriptorium_artifact", SourceID: ConfiguredArtifactSourceID(key), LocalPath: relativePath,
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
ProducerRunID: "run-1", Checksum: checksum,
},
OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now,
}
}
func analyzeEvidenceStatus(status manifest.AnalyzeArtifactStatus) func(*manifest.Manifest) {
return func(m *manifest.Manifest) {
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
record.Status = status
record.Output = nil
record.OutputSize = 0
if status == manifest.AnalyzeArtifactFailed {
record.Error = "generation failed"
}
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = record
}
}
func mutateAnalyzeEvidenceRecord(m *manifest.Manifest, mutate func(*manifest.AnalyzeArtifactRecord)) manifest.AnalyzeArtifactRecord {
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
mutate(&record)
return record
}

View File

@@ -107,6 +107,9 @@ type ResolvedSessionArtifact struct {
OutputKind string
ProducerRunID string
Provenance string
Contract *artifactmodel.ContractMetadata
Checksum string
Size int64
}
// SessionArtifactNotFoundError includes context when a known artifact cannot be read.
@@ -259,6 +262,9 @@ func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest,
OutputKind: entry.OutputKind,
ProducerRunID: entry.ProducerRunID,
Provenance: entry.Provenance,
Contract: cloneArtifactContract(entry.Contract),
Checksum: entry.Checksum,
Size: entry.Size,
}, nil
}

View File

@@ -364,7 +364,7 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testin
}
}
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing.T) {
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromManifest(t *testing.T) {
workspace := t.TempDir()
paths := buildSessionPaths(workspace, "campaign", "session")
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
@@ -385,16 +385,17 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
}
sourceID := ConfiguredArtifactSourceID("player_handout")
if err := catalog.MarkAvailableFromDisk(sourceID, outputPath); err != nil {
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
}
m := analyzeEvidenceFixture(t, paths, "player_handout", "artifacts/player_handout.md", []byte("handout\n"))
catalog.HydrateAnalyzeArtifacts(paths, m, map[string]ConfiguredArtifactDefinition{
"player_handout": {Enabled: false, OutputPath: "artifacts/player_handout.md"},
})
resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, sourceID, catalog)
resolved, err := ResolveSessionArtifactWithCatalog(paths, m, sourceID, catalog)
if err != nil {
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
}
if resolved.Provenance != ArtifactProvenanceDisabledFromDisk {
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceDisabledFromDisk)
if resolved.Provenance != ArtifactProvenanceCurrentAnalyzeManifest {
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceCurrentAnalyzeManifest)
}
}

View File

@@ -5,13 +5,14 @@ import (
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
const (
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
ArtifactProvenanceDisabledFromDisk = "filesystem.disabled_artifact_output"
ArtifactProvenanceCurrentAnalyzeManifest = "manifest.current_analyze_artifact"
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
)
@@ -64,6 +65,9 @@ type CatalogEntry struct {
Path string
Provenance string
ProducerRunID string
Contract *artifactmodel.ContractMetadata
Checksum string
Size int64
}
// ArtifactCatalog tracks built-in, configured, and extraction artifact definitions and runtime state.
@@ -98,6 +102,7 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
if _, exists := c.extractionIndex[trimmed]; exists {
return fmt.Errorf("duplicate extraction artifact key %q", trimmed)
}
def := configured[key]
sourceID := ExtractionArtifactSourceID(trimmed)
if err := c.addEntry(CatalogEntry{
SourceID: sourceID,
@@ -105,6 +110,10 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
ProducerStage: "extract",
OutputKind: "notarius_lane",
Planned: true,
Contract: &artifactmodel.ContractMetadata{
MediaType: def.MediaType, SchemaID: def.SchemaID,
SchemaVersion: def.SchemaVersion, ModuleKey: def.ModuleKey,
},
}); err != nil {
return fmt.Errorf("register extraction artifact %q: %w", trimmed, err)
}
@@ -221,7 +230,7 @@ func (c *ArtifactCatalog) Lookup(sourceID string) (CatalogEntry, bool) {
return CatalogEntry{}, false
}
entry, ok := c.entries[strings.TrimSpace(sourceID)]
return entry, ok
return cloneCatalogEntry(entry), ok
}
// SourceIDForConfiguredKey returns canonical source ID for one configured key.
@@ -255,7 +264,7 @@ func (c *ArtifactCatalog) ListConfigured() []CatalogEntry {
out := make([]CatalogEntry, 0, len(keys))
for _, key := range keys {
sourceID := c.configuredIndex[key]
out = append(out, c.entries[sourceID])
out = append(out, cloneCatalogEntry(c.entries[sourceID]))
}
return out
}
@@ -272,7 +281,7 @@ func (c *ArtifactCatalog) ListExtraction() []CatalogEntry {
sort.Strings(keys)
out := make([]CatalogEntry, 0, len(keys))
for _, key := range keys {
out = append(out, c.entries[c.extractionIndex[key]])
out = append(out, cloneCatalogEntry(c.entries[c.extractionIndex[key]]))
}
return out
}
@@ -282,17 +291,71 @@ func (c *ArtifactCatalog) MarkAvailableGenerated(sourceID, path string) error {
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
}
// MarkAvailableFromDisk marks one source as available from disabled artifact on disk.
func (c *ArtifactCatalog) MarkAvailableFromDisk(sourceID, path string) error {
return c.markAvailable(sourceID, path, ArtifactProvenanceDisabledFromDisk)
// MarkAvailableGeneratedEvidence marks one source as available from the
// current analyze invocation and retains the semantic output identity needed
// by later scheduled dependents.
func (c *ArtifactCatalog) MarkAvailableGeneratedEvidence(
sourceID, path, producerRunID, checksum string,
size int64,
contract *artifactmodel.ContractMetadata,
) error {
producerRunID = strings.TrimSpace(producerRunID)
checksum = strings.TrimSpace(checksum)
if err := ValidateRunIdentity(producerRunID); err != nil {
return fmt.Errorf("generated artifact producer run id: %w", err)
}
if err := validateSHA256(checksum); err != nil {
return fmt.Errorf("generated artifact checksum: %w", err)
}
if size <= 0 {
return fmt.Errorf("generated artifact size must be positive")
}
if contract == nil || strings.TrimSpace(contract.MediaType) == "" ||
strings.TrimSpace(contract.SchemaID) == "" || strings.TrimSpace(contract.SchemaVersion) == "" {
return fmt.Errorf("generated artifact contract is incomplete")
}
if err := c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun); err != nil {
return err
}
entry := c.entries[strings.TrimSpace(sourceID)]
entry.ProducerRunID = producerRunID
entry.Checksum = checksum
entry.Size = size
entry.Contract = cloneArtifactContract(contract)
c.entries[entry.SourceID] = entry
return nil
}
func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, producerRunID string) error {
func (c *ArtifactCatalog) markAvailableFromExtractManifest(
sourceID, path, producerRunID, checksum string,
size int64,
contract *artifactmodel.ContractMetadata,
) error {
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
return err
}
entry := c.entries[strings.TrimSpace(sourceID)]
entry.ProducerRunID = strings.TrimSpace(producerRunID)
entry.Checksum = strings.TrimSpace(checksum)
entry.Size = size
entry.Contract = cloneArtifactContract(contract)
c.entries[entry.SourceID] = entry
return nil
}
func (c *ArtifactCatalog) markAvailableFromAnalyzeManifest(
sourceID, path, producerRunID, checksum string,
size int64,
contract *artifactmodel.ContractMetadata,
) error {
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentAnalyzeManifest); err != nil {
return err
}
entry := c.entries[strings.TrimSpace(sourceID)]
entry.ProducerRunID = strings.TrimSpace(producerRunID)
entry.Checksum = strings.TrimSpace(checksum)
entry.Size = size
entry.Contract = cloneArtifactContract(contract)
c.entries[entry.SourceID] = entry
return nil
}
@@ -313,10 +376,29 @@ func (c *ArtifactCatalog) markAvailable(sourceID, path, provenance string) error
entry.Available = true
entry.Path = trimmedPath
entry.Provenance = provenance
entry.ProducerRunID = ""
entry.Checksum = ""
entry.Size = 0
if provenance == ArtifactProvenanceGeneratedCurrentAnalyzeRun {
entry.Contract = nil
}
c.entries[normalizedID] = entry
return nil
}
func cloneCatalogEntry(entry CatalogEntry) CatalogEntry {
entry.Contract = cloneArtifactContract(entry.Contract)
return entry
}
func cloneArtifactContract(contract *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
if contract == nil {
return nil
}
clone := *contract
return &clone
}
func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
if c == nil {
return fmt.Errorf("artifact catalog is nil")

View File

@@ -1,6 +1,11 @@
package artifacts
import "testing"
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
)
func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
catalog := NewArtifactCatalog()
@@ -179,27 +184,27 @@ func TestArtifactCatalogMarkAvailableGenerated(t *testing.T) {
}
}
func TestArtifactCatalogMarkAvailableFromDisk(t *testing.T) {
func TestArtifactCatalogMarkAvailableGeneratedEvidence(t *testing.T) {
catalog := NewArtifactCatalog()
if err := catalog.RegisterConfiguredArtifacts(
map[string]ConfiguredArtifactDefinition{
"session_recap": {Enabled: false, OutputPath: "artifacts/session_recap.md"},
},
nil,
); err != nil {
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
}); err != nil {
t.Fatal(err)
}
sourceID, _ := catalog.SourceIDForConfiguredKey("session_recap")
if err := catalog.MarkAvailableFromDisk(sourceID, "/tmp/session_recap.md"); err != nil {
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
contract := &artifactmodel.ContractMetadata{
MediaType: "text/markdown", SchemaID: "narratio.session_recap", SchemaVersion: "1",
}
checksum := strings.Repeat("a", 64)
if err := catalog.MarkAvailableGeneratedEvidence(
sourceID, "/tmp/session_recap.md", "run-1", checksum, 42, contract,
); err != nil {
t.Fatal(err)
}
entry, _ := catalog.Lookup(sourceID)
if !entry.Available {
t.Fatalf("entry.Available = false, want true")
}
if entry.Provenance != ArtifactProvenanceDisabledFromDisk {
t.Fatalf("entry.Provenance = %q, want %q", entry.Provenance, ArtifactProvenanceDisabledFromDisk)
if !entry.Available || entry.ProducerRunID != "run-1" || entry.Checksum != checksum || entry.Size != 42 ||
entry.Contract == nil || *entry.Contract != *contract {
t.Fatalf("generated evidence entry = %#v", entry)
}
}

View File

@@ -39,8 +39,11 @@ func (c *ArtifactCatalog) HydrateExtractionArtifacts(
if proof.State != ExtractionEvidenceValid {
return
}
for sourceID, path := range proof.Outputs {
_ = c.markAvailableFromExtractManifest(sourceID, path, proof.ProducerRunID)
for sourceID, output := range proof.Outputs {
_ = c.markAvailableFromExtractManifest(
sourceID, output.Path, proof.ProducerRunID,
output.Checksum, output.Size, output.Contract,
)
}
}

View File

@@ -68,6 +68,17 @@ func TestHydrateExtractionArtifactsAcceptsOnlyCompleteCurrentBundle(t *testing.T
if entry.Provenance != ArtifactProvenanceCurrentExtractManifest || entry.ProducerRunID != "extract-run-1" {
t.Fatalf("hydrated provenance = %#v", entry)
}
wantOutput := currentManifest.Stages["extract"].Outputs[0]
info, err := os.Stat(wantOutput.LocalPath)
if err != nil {
t.Fatal(err)
}
if entry.Checksum != wantOutput.Checksum || entry.Size != info.Size() {
t.Fatalf("hydrated content identity = (%q, %d), want (%q, %d)", entry.Checksum, entry.Size, wantOutput.Checksum, info.Size())
}
if entry.Contract == nil || *entry.Contract != *wantOutput.Contract {
t.Fatalf("hydrated contract = %#v, want %#v", entry.Contract, wantOutput.Contract)
}
resolved, err := ResolveSessionArtifactWithCatalog(paths, currentManifest, entry.SourceID, catalog)
if err != nil {
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)

View File

@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
@@ -23,7 +24,15 @@ const (
type ExtractionEvidence struct {
State ExtractionEvidenceState
Reason, ProducerRunID string
Outputs map[string]string
Outputs map[string]ExtractionEvidenceOutput
}
// ExtractionEvidenceOutput is the verified semantic identity of one lane.
type ExtractionEvidenceOutput struct {
Path string
Checksum string
Size int64
Contract *artifactmodel.ContractMetadata
}
// InspectExtractionEvidence verifies structure, confinement, identities, contracts, and payload bytes.
@@ -72,7 +81,7 @@ func InspectExtractionEvidence(
for key, d := range configured {
expected[ExtractionArtifactSourceID(key)] = d
}
seen, outputs := map[string]struct{}{}, map[string]string{}
seen, outputs := map[string]struct{}{}, map[string]ExtractionEvidenceOutput{}
indexSeen := false
for _, out := range r.Outputs {
if strings.TrimSpace(out.ProducerRunID) != runID {
@@ -82,7 +91,7 @@ func InspectExtractionEvidence(
if indexSeen || out.Kind != extractionIndexKind || filepath.Clean(out.LocalPath) != filepath.Join(root, "index.json") {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract index path is not canonical"}
}
if state, reason := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
if state, reason, _ := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
return ExtractionEvidence{State: state, Reason: reason}
}
indexSeen = true
@@ -98,11 +107,15 @@ func InspectExtractionEvidence(
if !compatibleCatalogExtractionContract(out.Contract, d) || !compatibleCatalogExtractionProvenance(out.ExternalProvenance, receiptRunID, receiptPipelineID, d) {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract output contract or provenance is incompatible"}
}
if state, reason := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
state, reason, size := inspectExtractionPayload(root, out.LocalPath, out.Checksum)
if state != ExtractionEvidenceValid {
return ExtractionEvidence{State: state, Reason: reason}
}
seen[out.SourceID] = struct{}{}
outputs[out.SourceID] = out.LocalPath
outputs[out.SourceID] = ExtractionEvidenceOutput{
Path: out.LocalPath, Checksum: out.Checksum, Size: size,
Contract: cloneArtifactContract(out.Contract),
}
}
if !indexSeen || len(seen) != len(expected) || len(r.Outputs) != len(expected)+1 {
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result is incomplete"}
@@ -110,30 +123,30 @@ func InspectExtractionEvidence(
return ExtractionEvidence{State: ExtractionEvidenceValid, ProducerRunID: runID, Outputs: outputs}
}
func inspectExtractionPayload(root, path, checksum string) (ExtractionEvidenceState, string) {
func inspectExtractionPayload(root, path, checksum string) (ExtractionEvidenceState, string, int64) {
if !filepath.IsAbs(path) || !pathWithinExtractionRoot(root, path) || strings.TrimSpace(checksum) == "" {
return ExtractionEvidenceUnsafe, "extract output path or checksum is unsafe"
return ExtractionEvidenceUnsafe, "extract output path or checksum is unsafe", 0
}
info, err := os.Lstat(path)
if os.IsNotExist(err) {
return ExtractionEvidenceObsolete, "extract output is missing"
return ExtractionEvidenceObsolete, "extract output is missing", 0
}
if !safeExtractionComponents(root, path) {
return ExtractionEvidenceUnsafe, "extract output path contains unsafe components"
return ExtractionEvidenceUnsafe, "extract output path contains unsafe components", 0
}
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return ExtractionEvidenceUnsafe, "extract output is not a regular file"
return ExtractionEvidenceUnsafe, "extract output is not a regular file", 0
}
actual, err := SHA256File(path)
if err != nil {
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read"
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read", 0
}
if actual != checksum {
return ExtractionEvidenceObsolete, "extract output checksum does not match durable bytes"
return ExtractionEvidenceObsolete, "extract output checksum does not match durable bytes", 0
}
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
if err != nil || !json.Valid(body) {
return ExtractionEvidenceObsolete, "extract output is not valid JSON"
return ExtractionEvidenceObsolete, "extract output is not valid JSON", 0
}
return ExtractionEvidenceValid, ""
return ExtractionEvidenceValid, "", info.Size()
}

View File

@@ -113,6 +113,12 @@ func SessionRunNotariusLogPathForCampaign(rootDir, campaign, sessionID, runID st
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius.stderr.log")
}
// SessionRunNotariusReferencesDirForCampaign returns the invocation-local
// directory containing verified reference snapshots supplied to Notarius.
func SessionRunNotariusReferencesDirForCampaign(rootDir, campaign, sessionID, runID string) string {
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "references")
}
// SessionRunNotariusOutputRootForCampaign returns the invocation-local Notarius output root.
func SessionRunNotariusOutputRootForCampaign(rootDir, campaign, sessionID, runID string) string {
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius-output")

View File

@@ -64,6 +64,7 @@ func TestSessionNotariusPathsForCampaign(t *testing.T) {
{name: "extract directory", got: SessionRunExtractDirForCampaign(root, campaign, sessionID, runID), want: extractDir},
{name: "receipt", got: SessionRunNotariusReceiptPathForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius.receipt.json")},
{name: "stderr", got: SessionRunNotariusLogPathForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius.stderr.log")},
{name: "references", got: SessionRunNotariusReferencesDirForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "references")},
{name: "output root", got: SessionRunNotariusOutputRootForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius-output")},
{name: "durable bundle", got: SessionNotariusBundleDirForCampaign(root, campaign, sessionID, runID), want: filepath.Join(root, "work", campaign, sessionID, "artifacts", "notarius", runID)},
}

View File

@@ -0,0 +1,191 @@
package artifacts
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// ErrPreparedInputAbsent reports that the current manifest has no record for a
// supported prepared stable input.
var ErrPreparedInputAbsent = errors.New("prepared input absent")
// PreparedInputAbsentError identifies the prepared source absent from the
// current manifest.
type PreparedInputAbsentError struct {
SourceID string
}
func (e *PreparedInputAbsentError) Error() string {
return fmt.Sprintf("%s: %q", ErrPreparedInputAbsent, e.SourceID)
}
func (e *PreparedInputAbsentError) Unwrap() error {
return ErrPreparedInputAbsent
}
// PreparedInputIdentity is the verified identity of one canonical prepared
// session input.
type PreparedInputIdentity struct {
SourceID string
ManifestKind string
Path string
RelativePath string
Checksum string
Size int64
}
// ResolvePreparedInput resolves a prepared stable source exclusively from its
// current manifest record and verifies the canonical file's identity.
func ResolvePreparedInput(paths SessionPaths, m *manifest.Manifest, sourceID string) (PreparedInputIdentity, error) {
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
if !ok {
return PreparedInputIdentity{}, fmt.Errorf("unsupported prepared input source %q", sourceID)
}
rootPath, canonicalPath, relativePath, err := preparedInputCanonicalPaths(paths, descriptor)
if err != nil {
return PreparedInputIdentity{}, err
}
matching := make([]manifest.InputRecord, 0, 1)
if m != nil {
for _, record := range m.Inputs {
if strings.TrimSpace(record.Kind) == descriptor.ManifestKind {
matching = append(matching, record)
}
}
}
if len(matching) == 0 {
if m != nil {
for _, record := range m.Inputs {
recordedPath, pathErr := resolvePreparedManifestPath(paths, record.Path, rootPath)
if pathErr == nil && recordedPath == canonicalPath {
return PreparedInputIdentity{}, fmt.Errorf(
"prepared input source %q canonical path is recorded with manifest kind %q, want %q",
descriptor.SourceID,
strings.TrimSpace(record.Kind),
descriptor.ManifestKind,
)
}
}
}
return PreparedInputIdentity{}, &PreparedInputAbsentError{SourceID: descriptor.SourceID}
}
if len(matching) != 1 {
return PreparedInputIdentity{}, fmt.Errorf(
"prepared input source %q has %d manifest records for kind %q; want exactly one",
descriptor.SourceID,
len(matching),
descriptor.ManifestKind,
)
}
record := matching[0]
recordedPath, err := resolvePreparedManifestPath(paths, record.Path, rootPath)
if err != nil {
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest path: %w", descriptor.SourceID, err)
}
if recordedPath != canonicalPath {
return PreparedInputIdentity{}, fmt.Errorf(
"prepared input source %q manifest path %q does not match canonical path %q",
descriptor.SourceID,
recordedPath,
canonicalPath,
)
}
declaredChecksum := strings.TrimSpace(record.Checksum)
if declaredChecksum == "" {
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest checksum is required", descriptor.SourceID)
}
file, err := fileops.OpenConfinedRegularFile(rootPath, relativePath)
if err != nil {
return PreparedInputIdentity{}, fmt.Errorf("open prepared input source %q: %w", descriptor.SourceID, err)
}
digest := sha256.New()
size, readErr := io.Copy(digest, file)
closeErr := file.Close()
if readErr != nil {
return PreparedInputIdentity{}, fmt.Errorf("checksum prepared input source %q: %w", descriptor.SourceID, readErr)
}
if closeErr != nil {
return PreparedInputIdentity{}, fmt.Errorf("close prepared input source %q: %w", descriptor.SourceID, closeErr)
}
if size == 0 {
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q is empty", descriptor.SourceID)
}
checksum := hex.EncodeToString(digest.Sum(nil))
if !strings.EqualFold(checksum, declaredChecksum) {
return PreparedInputIdentity{}, fmt.Errorf(
"prepared input source %q checksum mismatch: manifest=%q actual=%q",
descriptor.SourceID,
declaredChecksum,
checksum,
)
}
return PreparedInputIdentity{
SourceID: descriptor.SourceID,
ManifestKind: descriptor.ManifestKind,
Path: canonicalPath,
RelativePath: filepath.ToSlash(relativePath),
Checksum: checksum,
Size: size,
}, nil
}
func preparedInputCanonicalPaths(
paths SessionPaths,
descriptor artifactpolicy.PreparedInputSourceDescriptor,
) (rootPath, canonicalPath, relativePath string, err error) {
rootPath, err = filepath.Abs(strings.TrimSpace(paths.Root))
if err != nil || strings.TrimSpace(paths.Root) == "" {
if err == nil {
err = fmt.Errorf("session root is required")
}
return "", "", "", err
}
canonicalPath, err = filepath.Abs(filepath.Join(paths.InputsDir, descriptor.Filename))
if err != nil {
return "", "", "", fmt.Errorf("resolve prepared input canonical path: %w", err)
}
relativePath, err = filepath.Rel(rootPath, canonicalPath)
if err != nil || relativePath == "." || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) {
if err != nil {
return "", "", "", fmt.Errorf("resolve prepared input below session root: %w", err)
}
return "", "", "", fmt.Errorf("prepared input canonical path %q is outside session root %q", canonicalPath, rootPath)
}
return filepath.Clean(rootPath), filepath.Clean(canonicalPath), filepath.Clean(relativePath), nil
}
func resolvePreparedManifestPath(paths SessionPaths, recordedPath, rootPath string) (string, error) {
if strings.TrimSpace(recordedPath) == "" {
return "", fmt.Errorf("recorded path is required")
}
resolved := ResolveSessionLocalPathForRead(paths, recordedPath)
if strings.TrimSpace(resolved) == "" {
return "", fmt.Errorf("recorded path is required")
}
absolute, err := filepath.Abs(resolved)
if err != nil {
return "", fmt.Errorf("resolve recorded path: %w", err)
}
relative, err := filepath.Rel(rootPath, absolute)
if err != nil {
return "", fmt.Errorf("resolve recorded path below session root: %w", err)
}
if relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("recorded path %q is outside session root %q", absolute, rootPath)
}
return filepath.Clean(absolute), nil
}

View File

@@ -0,0 +1,222 @@
package artifacts
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestResolvePreparedInputReturnsVerifiedIdentity(t *testing.T) {
paths, m, canonicalPath, checksum := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n"))
m.Inputs[0].Path = filepath.ToSlash(filepath.Join("inputs", "spell_catalog.json"))
identity, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog)
if err != nil {
t.Fatalf("ResolvePreparedInput() error = %v", err)
}
wantAbsolute, err := filepath.Abs(canonicalPath)
if err != nil {
t.Fatalf("filepath.Abs() error = %v", err)
}
if identity.SourceID != artifactpolicy.SourceInputSpellCatalog ||
identity.ManifestKind != "spell_catalog" ||
identity.Path != wantAbsolute ||
identity.RelativePath != "inputs/spell_catalog.json" ||
identity.Checksum != checksum ||
identity.Size != int64(len("{\"spells\":[]}\n")) {
t.Fatalf("ResolvePreparedInput() = %#v", identity)
}
}
func TestResolvePreparedInputRequiresCurrentManifestRecord(t *testing.T) {
paths, _, _, _ := preparedInputFixture(t, artifactpolicy.SourceInputPlayers, []byte("- Alice\n"))
for _, m := range []*manifest.Manifest{nil, manifest.New("session", time.Now().UTC())} {
_, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputPlayers)
if !errors.Is(err, ErrPreparedInputAbsent) {
t.Fatalf("ResolvePreparedInput() error = %v, want ErrPreparedInputAbsent", err)
}
var absent *PreparedInputAbsentError
if !errors.As(err, &absent) || absent.SourceID != artifactpolicy.SourceInputPlayers {
t.Fatalf("ResolvePreparedInput() error = %#v, want typed players absence", err)
}
}
}
func TestResolvePreparedInputRejectsInvalidManifestEvidence(t *testing.T) {
tests := []struct {
name string
mutate func(*testing.T, SessionPaths, *manifest.Manifest, string)
wantErr string
}{
{
name: "duplicate record",
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
m.Inputs = append(m.Inputs, m.Inputs[0])
},
wantErr: "2 manifest records",
},
{
name: "wrong kind",
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
m.Inputs[0].Kind = "players"
},
wantErr: "recorded with manifest kind",
},
{
name: "wrong canonical path",
mutate: func(t *testing.T, paths SessionPaths, m *manifest.Manifest, _ string) {
wrong := filepath.Join(paths.InputsDir, "other.json")
if err := os.WriteFile(wrong, []byte("other\n"), 0o644); err != nil {
t.Fatalf("WriteFile(wrong) error = %v", err)
}
m.Inputs[0].Path = wrong
},
wantErr: "does not match canonical path",
},
{
name: "traversal path",
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
m.Inputs[0].Path = filepath.Join("..", "..", "outside.json")
},
wantErr: "outside session root",
},
{
name: "missing checksum",
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
m.Inputs[0].Checksum = " "
},
wantErr: "manifest checksum is required",
},
{
name: "checksum mismatch",
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
m.Inputs[0].Checksum = strings.Repeat("0", 64)
},
wantErr: "checksum mismatch",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths, m, canonicalPath, _ := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n"))
tt.mutate(t, paths, m, canonicalPath)
_, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ResolvePreparedInput() error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
func TestResolvePreparedInputRejectsInvalidCanonicalFile(t *testing.T) {
tests := []struct {
name string
mutate func(*testing.T, string)
wantErr string
}{
{
name: "missing",
mutate: func(t *testing.T, path string) {
if err := os.Remove(path); err != nil {
t.Fatalf("Remove() error = %v", err)
}
},
wantErr: "open prepared input source",
},
{
name: "symlink",
mutate: func(t *testing.T, path string) {
outside := filepath.Join(t.TempDir(), "outside.json")
if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil {
t.Fatalf("WriteFile(outside) error = %v", err)
}
if err := os.Remove(path); err != nil {
t.Fatalf("Remove() error = %v", err)
}
if err := os.Symlink(outside, path); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
},
wantErr: "not a regular file",
},
{
name: "directory",
mutate: func(t *testing.T, path string) {
if err := os.Remove(path); err != nil {
t.Fatalf("Remove() error = %v", err)
}
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatalf("Mkdir() error = %v", err)
}
},
wantErr: "not a regular file",
},
{
name: "empty",
mutate: func(t *testing.T, path string) {
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatalf("WriteFile(empty) error = %v", err)
}
},
wantErr: "is empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
paths, m, canonicalPath, _ := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n"))
tt.mutate(t, canonicalPath)
_, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ResolvePreparedInput() error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
func TestResolvePreparedInputRejectsUnsupportedSource(t *testing.T) {
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
_, err := ResolvePreparedInput(paths, nil, "narratio.input.unknown")
if err == nil || errors.Is(err, ErrPreparedInputAbsent) || !strings.Contains(err.Error(), "unsupported prepared input source") {
t.Fatalf("ResolvePreparedInput() error = %v", err)
}
}
func preparedInputFixture(
t *testing.T,
sourceID string,
payload []byte,
) (SessionPaths, *manifest.Manifest, string, string) {
t.Helper()
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
if !ok {
t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID)
}
canonicalPath := filepath.Join(paths.InputsDir, descriptor.Filename)
if err := os.MkdirAll(paths.InputsDir, 0o755); err != nil {
t.Fatalf("MkdirAll(inputs) error = %v", err)
}
if err := os.WriteFile(canonicalPath, payload, 0o644); err != nil {
t.Fatalf("WriteFile(canonical) error = %v", err)
}
checksum, err := SHA256File(canonicalPath)
if err != nil {
t.Fatalf("SHA256File() error = %v", err)
}
m := manifest.New("session", time.Now().UTC())
m.Inputs = []manifest.InputRecord{{
Kind: descriptor.ManifestKind,
Path: canonicalPath,
Checksum: checksum,
Source: "campaign_config",
}}
return paths, m, canonicalPath, checksum
}

View File

@@ -0,0 +1,6 @@
// Package buildinfo exposes metadata supplied by the release build.
package buildinfo
// Version is the Narratio release identifier. Source builds report "dev";
// release automation replaces it with the exact Git tag through -ldflags -X.
var Version = "dev"

View File

@@ -138,6 +138,74 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config")
}
func TestCampaignSessionMergeSpellCatalog(t *testing.T) {
tests := []struct {
name string
campaignValue string
sessionValue string
wantPath string
wantSource string
}{
{name: "omitted", wantPath: "", wantSource: "campaign_config"},
{name: "campaign inherited", campaignValue: "./campaign-spells.json", wantPath: "./campaign-spells.json", wantSource: "campaign_config"},
{name: "session override", campaignValue: "./campaign-spells.json", sessionValue: "./session-spells.json", wantPath: "./session-spells.json", wantSource: "session_config"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
campaignSpell := ""
if tt.campaignValue != "" {
campaignSpell = " spell_catalog_file: " + tt.campaignValue + "\n"
}
sessionSpell := ""
if tt.sessionValue != "" {
sessionSpell = " spell_catalog_file: " + tt.sessionValue + "\n"
}
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n"+campaignSpell,
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+sessionSpell,
)
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err != nil {
t.Fatalf("LoadWithSessionOptions() error = %v", err)
}
wantConfigPath := campaignPath
if tt.wantSource == "session_config" {
wantConfigPath = sessionPath
}
assertResolvedStableInput(t, cfg.StableInputs.SpellCatalogFile, tt.wantPath, wantConfigPath, tt.wantSource)
if cfg.Session.Inputs.SpellCatalogFile != tt.wantPath {
t.Fatalf("session spell_catalog_file = %q, want %q", cfg.Session.Inputs.SpellCatalogFile, tt.wantPath)
}
})
}
}
func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) {
tests := []struct {
name string
campaignLine string
sessionLine string
wantErr string
}{
{name: "campaign", campaignLine: " spell_catalog_file: ' '\n", wantErr: "campaign.inputs.spell_catalog_file"},
{name: "session", sessionLine: " spell_catalog_file: ' '\n", wantErr: "session.inputs.spell_catalog_file"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n"+tt.campaignLine,
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+tt.sessionLine,
)
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("LoadWithSessionOptions() error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) {
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",

View File

@@ -49,11 +49,12 @@ type CampaignConfig struct {
// CampaignInputsConfig contains stable campaign-level input file references.
type CampaignInputsConfig struct {
SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
SpellCatalogFile string `yaml:"spell_catalog_file"`
}
// SessionConfig contains per-session inputs and metadata.
@@ -266,6 +267,7 @@ type NotariusConfig struct {
PipelineID string `yaml:"pipeline_id"`
Timeout string `yaml:"timeout"`
WorkingDirectory string `yaml:"working_directory"`
References map[string]string `yaml:"references"`
Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
}
@@ -285,14 +287,15 @@ type NotificationConfig struct {
// SessionInputsConfig contains per-session input references.
type SessionInputsConfig struct {
AudioDir string `yaml:"audio_dir"`
AudioFiles []string `yaml:"audio_files"`
AudioS3 *SessionAudioS3Input `yaml:"audio_s3"`
SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
AudioDir string `yaml:"audio_dir"`
AudioFiles []string `yaml:"audio_files"`
AudioS3 *SessionAudioS3Input `yaml:"audio_s3"`
SpeakersFile string `yaml:"speakers_file"`
AutocorrectFile string `yaml:"autocorrect_file"`
GlossaryFile string `yaml:"glossary_file"`
PlayersFile string `yaml:"players_file"`
PartyFile string `yaml:"party_file"`
SpellCatalogFile string `yaml:"spell_catalog_file"`
}
// SessionAudioS3Input configures S3 session-audio input discovery.
@@ -303,11 +306,12 @@ type SessionAudioS3Input struct {
// ResolvedStableInputs records where stable input file paths came from after
// campaign/session merge.
type ResolvedStableInputs struct {
SpeakersFile ResolvedInputFile
AutocorrectFile ResolvedInputFile
GlossaryFile ResolvedInputFile
PlayersFile ResolvedInputFile
PartyFile ResolvedInputFile
SpeakersFile ResolvedInputFile
AutocorrectFile ResolvedInputFile
GlossaryFile ResolvedInputFile
PlayersFile ResolvedInputFile
PartyFile ResolvedInputFile
SpellCatalogFile ResolvedInputFile
}
// ResolvedInputFile records one merged config path and its source config file.

View File

@@ -35,6 +35,9 @@ const (
DefaultAuditaReport = true
DefaultNotariusBinary = "notarius"
DefaultNotariusTimeout = "3h"
// MaxNotariusReferenceBindings is the maximum number of CLI reference
// bindings accepted for one Notarius invocation.
MaxNotariusReferenceBindings = 256
DefaultScriptoriumBinary = "scriptorium"
DefaultScriptoriumTimeout = "10m"

View File

@@ -209,6 +209,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
if sessionCfg == nil {
return ResolvedStableInputs{}, fmt.Errorf("session config is required")
}
if campaignCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(campaignCfg.Inputs.SpellCatalogFile) == "" {
return ResolvedStableInputs{}, fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided")
}
if sessionCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(sessionCfg.Inputs.SpellCatalogFile) == "" {
return ResolvedStableInputs{}, fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
}
campaignName := CampaignID(campaignCfg)
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
@@ -254,6 +260,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
campaignPath,
sessionPath,
),
SpellCatalogFile: selectStableInput(
campaignCfg.Inputs.SpellCatalogFile,
sessionCfg.Inputs.SpellCatalogFile,
campaignPath,
sessionPath,
),
}
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
@@ -261,6 +273,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path
sessionCfg.Inputs.PartyFile = stable.PartyFile.Path
sessionCfg.Inputs.SpellCatalogFile = stable.SpellCatalogFile.Path
return stable, nil
}

View File

@@ -1,6 +1,7 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
@@ -108,6 +109,7 @@ func TestNotariusStrictYAML(t *testing.T) {
{name: "unknown output field", yaml: "notarius:\n outputs:\n npc_registry:\n lane_id: npc-registry\n unknown: true\n"},
{name: "unsupported session id", yaml: "notarius:\n session_id: forbidden\n"},
{name: "unsupported model", yaml: "notarius:\n model: forbidden\n"},
{name: "duplicate reference selector", yaml: "notarius:\n references:\n party: narratio.input.party\n party: narratio.input.players\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -122,6 +124,134 @@ func TestNotariusStrictYAML(t *testing.T) {
}
}
func TestNotariusReferenceValidationAndNormalization(t *testing.T) {
cfg := validNotariusConfig()
cfg.References = map[string]string{
" party ": " narratio.input.party ",
" chunk . players ": "narratio.input.players",
" npc-registry . extract . glossary ": "narratio.input.glossary",
"spells": "narratio.input.spell_catalog",
}
if err := validateNotarius(cfg, nil); err != nil {
t.Fatalf("validateNotarius() error = %v", err)
}
want := map[string]string{
"party": "narratio.input.party",
"chunk.players": "narratio.input.players",
"npc-registry.extract.glossary": "narratio.input.glossary",
"spells": "narratio.input.spell_catalog",
}
if len(cfg.References) != len(want) {
t.Fatalf("normalized references = %#v, want %#v", cfg.References, want)
}
for selector, source := range want {
if cfg.References[selector] != source {
t.Fatalf("references[%q] = %q, want %q", selector, cfg.References[selector], source)
}
}
}
func TestNotariusReferenceValidationRejectsInvalidBindings(t *testing.T) {
tests := []struct {
name string
references map[string]string
wantErr string
}{
{name: "empty selector", references: map[string]string{" ": "narratio.input.party"}, wantErr: "selector is required"},
{name: "equals in selector", references: map[string]string{"party=x": "narratio.input.party"}, wantErr: "must not contain"},
{name: "invalid stage", references: map[string]string{"lane.prepare.party": "narratio.input.party"}, wantErr: "middle component"},
{name: "empty source", references: map[string]string{"party": " "}, wantErr: "source is required"},
{name: "unsupported source", references: map[string]string{"party": "narratio.input.unknown"}, wantErr: "not a supported prepared input source"},
{
name: "normalized collision",
references: map[string]string{
"chunk.party": "narratio.input.party",
" chunk . party ": "narratio.input.players",
},
wantErr: "normalize to",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := validNotariusConfig()
cfg.References = tt.references
err := validateNotarius(cfg, nil)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("validateNotarius() error = %v, want containing %q", err, tt.wantErr)
}
})
}
}
func TestNotariusReferenceLimit(t *testing.T) {
for _, count := range []int{MaxNotariusReferenceBindings, MaxNotariusReferenceBindings + 1} {
t.Run(fmt.Sprintf("count_%d", count), func(t *testing.T) {
cfg := validNotariusConfig()
cfg.References = make(map[string]string, count)
for i := 0; i < count; i++ {
cfg.References[fmt.Sprintf("lane-%03d.party", i)] = "narratio.input.party"
}
err := validateNotarius(cfg, nil)
if count == MaxNotariusReferenceBindings {
if err != nil {
t.Fatalf("validateNotarius() at limit error = %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), "at most 256 bindings") {
t.Fatalf("validateNotarius() above limit error = %v", err)
}
})
}
}
func TestNotariusNilAndEmptyReferencesAreValid(t *testing.T) {
for _, references := range []map[string]string{nil, {}} {
cfg := validNotariusConfig()
cfg.References = references
if err := validateNotarius(cfg, nil); err != nil {
t.Fatalf("validateNotarius(%#v) error = %v", references, err)
}
}
}
func TestNotariusSpellCatalogReferenceRequiresEffectiveInput(t *testing.T) {
cfg := loadedValidConfig(t)
cfg.Pipeline.Notarius = validNotariusConfig()
cfg.Pipeline.Notarius.References = map[string]string{"spells": "narratio.input.spell_catalog"}
err := Validate(cfg)
if err == nil || !strings.Contains(err.Error(), "requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file") {
t.Fatalf("Validate() error = %v, want missing spell catalog input", err)
}
cfg.StableInputs.SpellCatalogFile = ResolvedInputFile{Path: "./spells.json", Source: "campaign_config"}
if err := Validate(cfg); err != nil {
t.Fatalf("Validate() with spell catalog error = %v", err)
}
}
func validNotariusConfig() *NotariusConfig {
return &NotariusConfig{
Enabled: true,
Binary: "notarius",
ConfigPath: "./notarius.yml",
PipelineID: "dnd-session",
Timeout: "45m",
WorkingDirectory: ".",
Outputs: map[string]NotariusOutputConfig{
"npc_registry": {
LaneID: "npc-registry",
MediaType: "application/json",
SchemaID: "notarius.dnd.npc_registry",
SchemaVersion: "v1",
},
},
}
}
func TestNotariusEnabledValidation(t *testing.T) {
valid := `notarius:
enabled: true

View File

@@ -451,7 +451,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.depends_on must not include itself",
},
{
name: "enabled dependency cycle fails validation",
name: "configured dependency cycle fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
@@ -476,7 +476,25 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
source: narratio.artifact.artifact_a
required: true
`,
wantValidateErr: "pipeline.scriptorium.artifacts enabled dependencies must not contain cycles",
wantValidateErr: "pipeline.scriptorium.artifacts dependencies must not contain cycles",
},
{
name: "disabled dependency cycle fails validation",
scriptoriumYAML: `scriptorium:
binary: scriptorium
artifacts:
artifact_a:
enabled: false
depends_on:
- artifact_b
output_path: artifacts/a.md
artifact_b:
enabled: false
depends_on:
- artifact_a
output_path: artifacts/b.md
`,
wantValidateErr: "pipeline.scriptorium.artifacts dependencies must not contain cycles",
},
{
name: "artifact source typo fails validation",

View File

@@ -12,6 +12,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
@@ -39,7 +40,7 @@ func Validate(cfg *Config) error {
if err := validateSession(cfg.Session); err != nil {
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
}
if err := validateCrossConfig(cfg.Pipeline, cfg.Session); err != nil {
if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
return fmt.Errorf("pipeline/session config invalid: %w", err)
}
@@ -71,6 +72,9 @@ func validateCampaign(cfg *CampaignConfig) error {
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("campaign.inputs.party_file is required")
}
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
return fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided")
}
return nil
}
@@ -547,6 +551,39 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
if strings.TrimSpace(cfg.WorkingDirectory) == "" {
return fmt.Errorf("pipeline.notarius.working_directory is required when pipeline.notarius.enabled is true")
}
if len(cfg.References) > MaxNotariusReferenceBindings {
return fmt.Errorf("pipeline.notarius.references must contain at most %d bindings", MaxNotariusReferenceBindings)
}
referenceKeys := make([]string, 0, len(cfg.References))
for selector := range cfg.References {
referenceKeys = append(referenceKeys, selector)
}
sort.Strings(referenceKeys)
var normalizedReferences map[string]string
if cfg.References != nil {
normalizedReferences = make(map[string]string, len(cfg.References))
}
referenceOwners := make(map[string]string, len(cfg.References))
for _, rawSelector := range referenceKeys {
selector, err := notariusref.NormalizeSelector(rawSelector)
if err != nil {
return fmt.Errorf("pipeline.notarius.references selector %q is invalid: %w", rawSelector, err)
}
if previous, ok := referenceOwners[selector]; ok {
return fmt.Errorf("pipeline.notarius.references selectors %q and %q normalize to %q", previous, rawSelector, selector)
}
referenceOwners[selector] = rawSelector
source := strings.TrimSpace(cfg.References[rawSelector])
if source == "" {
return fmt.Errorf("pipeline.notarius.references.%s source is required", selector)
}
if _, ok := artifactpolicy.DescribePreparedInputSource(source); !ok {
return fmt.Errorf("pipeline.notarius.references.%s source %q is not a supported prepared input source", selector, source)
}
normalizedReferences[selector] = source
}
reservedSources := map[string]string{}
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {
@@ -613,6 +650,7 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
cfg.PipelineID = strings.TrimSpace(cfg.PipelineID)
cfg.Timeout = strings.TrimSpace(cfg.Timeout)
cfg.WorkingDirectory = filepath.Clean(cfg.WorkingDirectory)
cfg.References = normalizedReferences
cfg.Outputs = normalizedOutputs
return nil
}
@@ -723,7 +761,7 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error
}
}
if err := validateEnabledArtifactDependencyCycles(cfg.Artifacts); err != nil {
if err := ValidateScriptoriumArtifactDependencies(cfg.Artifacts); err != nil {
return err
}
@@ -762,6 +800,9 @@ func validateSession(cfg *SessionConfig) error {
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
return fmt.Errorf("session.inputs.party_file is required")
}
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
}
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
@@ -797,10 +838,17 @@ func validateSessionIdentifier(fieldName, value string, required bool) error {
return nil
}
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error {
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig, stableInputs ResolvedStableInputs) error {
if pipeline == nil || session == nil {
return nil
}
if pipeline.Notarius != nil && pipeline.Notarius.Enabled {
for selector, source := range pipeline.Notarius.References {
if source == artifactpolicy.SourceInputSpellCatalog && strings.TrimSpace(stableInputs.SpellCatalogFile.Path) == "" {
return fmt.Errorf("pipeline.notarius.references.%s requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file", selector)
}
}
}
audioS3Enabled := session.Inputs.AudioS3 != nil
publishUploadEnabled := publishUploadConfiguredForS3(pipeline)
@@ -935,38 +983,47 @@ func validatePathWithinRoot(fieldName, value, root string) error {
return fmt.Errorf("%s must be under %s/", fieldName, normalizedRoot)
}
func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArtifactConfig) error {
// ValidateScriptoriumArtifactDependencies validates the configured dependency
// graph independently of execution selection. Disabled artifacts remain valid
// prerequisites for explicit selections and therefore participate in cycles.
func ValidateScriptoriumArtifactDependencies(artifacts map[string]ScriptoriumArtifactConfig) error {
if len(artifacts) == 0 {
return nil
}
enabled := make(map[string]struct{}, len(artifacts))
graph := make(map[string][]string, len(artifacts))
for name, cfg := range artifacts {
if !cfg.Enabled {
continue
}
enabled[name] = struct{}{}
}
for name, cfg := range artifacts {
if !cfg.Enabled {
continue
if !artifactpolicy.IsConfiguredKey(name) {
return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$")
}
seen := make(map[string]struct{}, len(cfg.DependsOn))
for _, dep := range cfg.DependsOn {
trimmedDep := strings.TrimSpace(dep)
if _, ok := enabled[trimmedDep]; ok {
graph[name] = append(graph[name], trimmedDep)
if trimmedDep == "" {
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on entries must be non-empty", name)
}
if _, ok := artifacts[trimmedDep]; !ok {
return fmt.Errorf("pipeline.scriptorium.artifacts.%s dependency %q is not configured", name, dep)
}
if trimmedDep == name {
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on must not include itself", name)
}
if _, duplicate := seen[trimmedDep]; duplicate {
continue
}
seen[trimmedDep] = struct{}{}
graph[name] = append(graph[name], trimmedDep)
}
sort.Strings(graph[name])
}
visiting := make(map[string]bool, len(enabled))
visited := make(map[string]bool, len(enabled))
visiting := make(map[string]bool, len(artifacts))
visited := make(map[string]bool, len(artifacts))
var visit func(node string) error
visit = func(node string) error {
if visiting[node] {
return fmt.Errorf("pipeline.scriptorium.artifacts enabled dependencies must not contain cycles")
return fmt.Errorf("pipeline.scriptorium.artifacts dependencies must not contain cycles")
}
if visited[node] {
return nil
@@ -982,7 +1039,12 @@ func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArt
return nil
}
for node := range enabled {
nodes := make([]string, 0, len(artifacts))
for node := range artifacts {
nodes = append(nodes, node)
}
sort.Strings(nodes)
for _, node := range nodes {
if err := visit(node); err != nil {
return err
}

View File

@@ -22,6 +22,32 @@ func RemoveAllUnderRoot(rootPath, target string) error {
return removeConfinedEntry(root, targetName)
}
// RemoveFileUnderRoot removes an exact regular-file target below root without
// following symlinked ancestors or the leaf. A missing target is successful;
// directories, symlinks, and other non-regular entries are rejected.
func RemoveFileUnderRoot(rootPath, target string) error {
root, targetName, err := openConfinedCleanupTarget(rootPath, target)
if err != nil {
return err
}
defer func() { _ = root.Close() }()
info, err := root.Lstat(targetName)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("inspect cleanup file %q: %w", targetName, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("refusing to delete non-regular file path %q", targetName)
}
if err := root.Remove(targetName); err != nil {
return fmt.Errorf("remove cleanup file %q: %w", targetName, err)
}
return nil
}
func openConfinedCleanupTarget(rootPath, target string) (*os.Root, string, error) {
if strings.TrimSpace(rootPath) == "" {
return nil, "", fmt.Errorf("cleanup root is required")

View File

@@ -1,8 +1,10 @@
package fileops
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -77,3 +79,47 @@ func TestRemoveAllUnderRootRejectsSymlinkInTree(t *testing.T) {
t.Fatalf("outside sentinel was changed: %v", err)
}
}
func TestRemoveFileUnderRootRemovesOnlyRegularFile(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "inputs", "spell_catalog.json")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(target, []byte("{}\n"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := RemoveFileUnderRoot(root, target); err != nil {
t.Fatalf("RemoveFileUnderRoot() error = %v", err)
}
if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("Lstat() error = %v, want not exist", err)
}
if err := RemoveFileUnderRoot(root, target); err != nil {
t.Fatalf("RemoveFileUnderRoot(missing) error = %v", err)
}
for _, tt := range []struct {
name string
setup func(string) error
}{
{name: "directory", setup: func(path string) error { return os.Mkdir(path, 0o755) }},
{name: "symlink", setup: func(path string) error { return os.Symlink(filepath.Join(root, "outside"), path) }},
} {
t.Run(tt.name, func(t *testing.T) {
if err := tt.setup(target); err != nil {
t.Fatalf("setup target: %v", err)
}
err := RemoveFileUnderRoot(root, target)
if err == nil || !strings.Contains(err.Error(), "non-regular") {
t.Fatalf("RemoveFileUnderRoot() error = %v, want non-regular rejection", err)
}
if _, err := os.Lstat(target); err != nil {
t.Fatalf("ambiguous target was removed: %v", err)
}
if err := os.Remove(target); err != nil {
t.Fatalf("cleanup target: %v", err)
}
})
}
}

View File

@@ -67,17 +67,29 @@ func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, erro
}
defer func() { _ = in.Close() }()
return WriteReaderAtomicWithChecksum(dst, in, perm)
}
// WriteReaderAtomicWithChecksum streams src through the durable replacement
// sequence and returns the SHA-256 checksum of the installed bytes. The caller
// retains ownership of src.
func WriteReaderAtomicWithChecksum(dst string, src io.Reader, perm os.FileMode) (string, error) {
if strings.TrimSpace(dst) == "" {
return "", fmt.Errorf("destination path is required")
}
if src == nil {
return "", fmt.Errorf("source reader is required")
}
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
digest := sha256.New()
err = replaceFileFromReaderConfined(
if err := replaceFileFromReaderConfined(
dst,
io.TeeReader(in, digest),
io.TeeReader(src, digest),
ReplaceFileOptions{Mode: perm},
)
if err != nil {
); err != nil {
return "", err
}
return hex.EncodeToString(digest.Sum(nil)), nil

View File

@@ -1,6 +1,8 @@
package fileops
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
@@ -74,6 +76,28 @@ func TestCopyFileAtomicWithChecksumMatchesDestination(t *testing.T) {
assertNoMatchingTempFiles(t, filepath.Dir(dst), ".copied.txt.tmp-")
}
func TestWriteReaderAtomicWithChecksumMatchesDestination(t *testing.T) {
root := t.TempDir()
dst := filepath.Join(root, "nested", "snapshot.yml")
payload := "verified reference bytes\n"
checksum, err := WriteReaderAtomicWithChecksum(dst, strings.NewReader(payload), WorkspaceFileMode)
if err != nil {
t.Fatalf("WriteReaderAtomicWithChecksum() error = %v", err)
}
wantChecksum := sha256.Sum256([]byte(payload))
if checksum != hex.EncodeToString(wantChecksum[:]) {
t.Fatalf("checksum = %q, want %q", checksum, hex.EncodeToString(wantChecksum[:]))
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != payload {
t.Fatalf("destination = %q, want %q", data, payload)
}
}
func TestCopyFileAtomicCleansTempFileOnInstallFailure(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "source.txt")

View File

@@ -0,0 +1,374 @@
package manifest
import (
"encoding/hex"
"fmt"
"sort"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
const (
// AnalyzeStateContractVersion identifies the supported per-artifact state
// representation owned by the analyze stage.
AnalyzeStateContractVersion = 1
// AnalyzeFingerprintContractVersion identifies the fingerprint representation
// stored by the supported analyze state contract.
AnalyzeFingerprintContractVersion = 1
)
const (
maxAnalyzeArtifactErrorLength = 512
maxAnalyzeArtifactTextLength = 4096
maxAnalyzeArtifactListEntries = 128
)
// AnalyzeArtifactStatus describes whether one configured analysis artifact is
// currently available or why it is not.
type AnalyzeArtifactStatus string
const (
AnalyzeArtifactCurrent AnalyzeArtifactStatus = "current"
AnalyzeArtifactStale AnalyzeArtifactStatus = "stale"
AnalyzeArtifactMissing AnalyzeArtifactStatus = "missing"
AnalyzeArtifactFailed AnalyzeArtifactStatus = "failed"
AnalyzeArtifactUnselected AnalyzeArtifactStatus = "unselected"
)
// AnalyzeArtifactProvenance records useful non-secret Scriptorium invocation
// identity without making adapter diagnostics part of the generic artifact schema.
type AnalyzeArtifactProvenance struct {
PromptID string `json:"prompt_id,omitempty"`
ProfileID string `json:"profile_id,omitempty"`
CommandMode string `json:"command_mode,omitempty"`
}
// AnalyzeArtifactRecord is analyze-owned state for one configured artifact.
// Output is present only while the record is current.
type AnalyzeArtifactRecord struct {
Key string `json:"key"`
Status AnalyzeArtifactStatus `json:"status"`
FingerprintVersion int `json:"fingerprint_version,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
Output *ArtifactRecord `json:"output,omitempty"`
OutputSize int64 `json:"output_size,omitempty"`
ProducerRunID string `json:"producer_run_id"`
UpdatedAt time.Time `json:"updated_at"`
Error string `json:"error,omitempty"`
Scriptorium *AnalyzeArtifactProvenance `json:"scriptorium,omitempty"`
Logs []string `json:"logs,omitempty"`
GeneratedConfigs []string `json:"generated_configs,omitempty"`
}
// HasVersionedAnalyzeState reports whether an analyze stage record carries the
// supported per-artifact authority. A legacy aggregate-only record returns false.
func (s *StageRecord) HasVersionedAnalyzeState() bool {
return s != nil && s.Name == "analyze" && s.AnalyzeStateVersion == AnalyzeStateContractVersion
}
// ValidateAnalyzeArtifactCollection validates one complete session or
// invocation collection independently of its containing manifest.
func ValidateAnalyzeArtifactCollection(version int, records map[string]AnalyzeArtifactRecord) error {
if version != AnalyzeStateContractVersion {
return fmt.Errorf("unsupported analyze state version %d", version)
}
for key, record := range records {
if err := validateAnalyzeArtifactRecord(key, record); err != nil {
return fmt.Errorf("analyze artifact %q: %w", key, err)
}
}
return nil
}
// CloneAnalyzeArtifactCollection returns a deep copy in canonical dependency
// order so projections cannot be mutated after application.
func CloneAnalyzeArtifactCollection(records map[string]AnalyzeArtifactRecord) map[string]AnalyzeArtifactRecord {
if records == nil {
return nil
}
cloned := make(map[string]AnalyzeArtifactRecord, len(records))
for key, record := range records {
record.Dependencies = cloneStrings(record.Dependencies)
record.Logs = cloneStrings(record.Logs)
record.GeneratedConfigs = cloneStrings(record.GeneratedConfigs)
record.Output = cloneArtifactRecord(record.Output)
record.Scriptorium = cloneAnalyzeProvenance(record.Scriptorium)
cloned[key] = record
}
normalizeAnalyzeArtifactCollection(cloned)
return cloned
}
func validateAnalyzeStageState(stageName string, version int, records map[string]AnalyzeArtifactRecord) error {
if stageName != "analyze" {
if version != 0 || records != nil {
return fmt.Errorf("stage %q cannot contain analyze-owned state", stageName)
}
return nil
}
if version == 0 {
if records != nil {
return fmt.Errorf("legacy analyze stage without a state version cannot contain analyze_artifacts")
}
return nil
}
return ValidateAnalyzeArtifactCollection(version, records)
}
func validateAnalyzeArtifactRecord(mapKey string, record AnalyzeArtifactRecord) error {
if !isNormalizedAnalyzeArtifactKey(mapKey) {
return fmt.Errorf("map key must match ^[a-z][a-z0-9_]*$ without normalization")
}
if record.Key != mapKey {
return fmt.Errorf("record key %q does not match map key", record.Key)
}
switch record.Status {
case AnalyzeArtifactCurrent, AnalyzeArtifactStale, AnalyzeArtifactMissing, AnalyzeArtifactFailed, AnalyzeArtifactUnselected:
default:
return fmt.Errorf("unsupported status %q", record.Status)
}
if err := validateAnalyzeDependencies(record.Dependencies); err != nil {
return err
}
if err := validateAnalyzeFingerprint(record.FingerprintVersion, record.Fingerprint, record.Status == AnalyzeArtifactCurrent); err != nil {
return err
}
if err := pathsafe.ValidateOpaqueSegment(record.ProducerRunID); err != nil {
return fmt.Errorf("producer_run_id is invalid: %w", err)
}
if record.UpdatedAt.IsZero() {
return fmt.Errorf("updated_at is required")
}
if len(record.Error) > maxAnalyzeArtifactErrorLength {
return fmt.Errorf("error exceeds %d bytes", maxAnalyzeArtifactErrorLength)
}
if strings.TrimSpace(record.Error) != record.Error {
return fmt.Errorf("error must be trimmed")
}
if record.Status == AnalyzeArtifactFailed {
if record.Error == "" {
return fmt.Errorf("failed status requires error")
}
} else if record.Error != "" {
return fmt.Errorf("status %q forbids error", record.Status)
}
if record.Status == AnalyzeArtifactCurrent {
if err := validateCurrentAnalyzeOutput(record); err != nil {
return err
}
} else if record.Output != nil || record.OutputSize != 0 {
return fmt.Errorf("status %q forbids output and output_size", record.Status)
}
if err := validateAnalyzeProvenance(record.Scriptorium); err != nil {
return err
}
if err := validateAnalyzeTextList("logs", record.Logs); err != nil {
return err
}
if err := validateAnalyzeTextList("generated_configs", record.GeneratedConfigs); err != nil {
return err
}
return nil
}
func validateCurrentAnalyzeOutput(record AnalyzeArtifactRecord) error {
if record.Output == nil {
return fmt.Errorf("current status requires output")
}
if record.OutputSize <= 0 {
return fmt.Errorf("current status requires positive output_size")
}
output := record.Output
if strings.TrimSpace(output.Kind) == "" {
return fmt.Errorf("current output kind is required")
}
if strings.TrimSpace(output.Kind) != output.Kind {
return fmt.Errorf("current output kind must be trimmed")
}
wantSource := artifactpolicy.ConfiguredSourceID(record.Key)
if output.SourceID != wantSource {
return fmt.Errorf("current output source_id %q must equal %q", output.SourceID, wantSource)
}
normalizedPath, err := pathsafe.NormalizeRelativeDestination(output.LocalPath)
if err != nil {
return fmt.Errorf("current output local_path is unsafe: %w", err)
}
if normalizedPath != output.LocalPath {
return fmt.Errorf("current output local_path %q is not canonical %q", output.LocalPath, normalizedPath)
}
if output.Contract == nil || strings.TrimSpace(output.Contract.MediaType) == "" || strings.TrimSpace(output.Contract.SchemaID) == "" || strings.TrimSpace(output.Contract.SchemaVersion) == "" {
return fmt.Errorf("current output contract media_type, schema_id, and schema_version are required")
}
for field, value := range map[string]string{
"media_type": output.Contract.MediaType, "schema_id": output.Contract.SchemaID,
"schema_version": output.Contract.SchemaVersion, "module_key": output.Contract.ModuleKey,
} {
if strings.TrimSpace(value) != value {
return fmt.Errorf("current output contract %s must be trimmed", field)
}
}
if err := validateSHA256("current output checksum", output.Checksum); err != nil {
return err
}
if output.ProducerRunID != "" && output.ProducerRunID != record.ProducerRunID {
return fmt.Errorf("current output producer_run_id %q does not match record", output.ProducerRunID)
}
for field, value := range map[string]string{
"output kind": output.Kind,
"output source_id": output.SourceID,
"output local_path": output.LocalPath,
"output contract media_type": output.Contract.MediaType,
"output contract schema_id": output.Contract.SchemaID,
"output contract schema_version": output.Contract.SchemaVersion,
"output contract module_key": output.Contract.ModuleKey,
} {
if err := validateAnalyzeText(field, value); err != nil {
return err
}
}
if output.ExternalProvenance != nil {
if strings.TrimSpace(output.ExternalProvenance.System) == "" {
return fmt.Errorf("output provenance system is required when provenance is present")
}
for field, value := range map[string]string{
"output provenance system": output.ExternalProvenance.System,
"output provenance run_id": output.ExternalProvenance.RunID,
"output provenance pipeline_id": output.ExternalProvenance.PipelineID,
"output provenance artifact_id": output.ExternalProvenance.ArtifactID,
} {
if err := validateAnalyzeText(field, value); err != nil {
return err
}
}
}
return nil
}
func validateAnalyzeDependencies(dependencies []string) error {
seen := make(map[string]struct{}, len(dependencies))
for index, dependency := range dependencies {
if !isNormalizedAnalyzeArtifactKey(dependency) {
return fmt.Errorf("dependencies[%d] must match ^[a-z][a-z0-9_]*$ without normalization", index)
}
if _, duplicate := seen[dependency]; duplicate {
return fmt.Errorf("duplicate dependency %q", dependency)
}
seen[dependency] = struct{}{}
}
return nil
}
func validateAnalyzeFingerprint(version int, fingerprint string, required bool) error {
if version == 0 && fingerprint == "" {
if required {
return fmt.Errorf("current status requires fingerprint version and fingerprint")
}
return nil
}
if version != AnalyzeFingerprintContractVersion {
return fmt.Errorf("unsupported fingerprint version %d", version)
}
return validateSHA256("fingerprint", fingerprint)
}
func validateSHA256(field, value string) error {
if len(value) != 64 || strings.ToLower(value) != value {
return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field)
}
decoded, err := hex.DecodeString(value)
if err != nil || len(decoded) != 32 {
return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field)
}
return nil
}
func validateAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) error {
if provenance == nil {
return nil
}
if provenance.PromptID == "" && provenance.ProfileID == "" && provenance.CommandMode == "" {
return fmt.Errorf("scriptorium provenance must contain at least one identifier")
}
for field, value := range map[string]string{
"scriptorium prompt_id": provenance.PromptID,
"scriptorium profile_id": provenance.ProfileID,
"scriptorium command_mode": provenance.CommandMode,
} {
if err := validateAnalyzeText(field, value); err != nil {
return err
}
}
return nil
}
func validateAnalyzeTextList(field string, values []string) error {
if len(values) > maxAnalyzeArtifactListEntries {
return fmt.Errorf("%s exceeds %d entries", field, maxAnalyzeArtifactListEntries)
}
for index, value := range values {
if err := validateAnalyzeText(fmt.Sprintf("%s[%d]", field, index), value); err != nil {
return err
}
}
return nil
}
func validateAnalyzeText(field, value string) error {
if len(value) > maxAnalyzeArtifactTextLength {
return fmt.Errorf("%s exceeds %d bytes", field, maxAnalyzeArtifactTextLength)
}
if strings.ContainsRune(value, '\x00') {
return fmt.Errorf("%s contains a NUL byte", field)
}
return nil
}
func isNormalizedAnalyzeArtifactKey(key string) bool {
return strings.TrimSpace(key) == key && artifactpolicy.IsConfiguredKey(key)
}
func normalizeAnalyzeArtifactCollection(records map[string]AnalyzeArtifactRecord) {
for key, record := range records {
if len(record.Dependencies) > 1 {
record.Dependencies = append([]string(nil), record.Dependencies...)
sort.Strings(record.Dependencies)
}
record.Logs = cloneStrings(record.Logs)
record.GeneratedConfigs = cloneStrings(record.GeneratedConfigs)
record.Output = cloneArtifactRecord(record.Output)
record.Scriptorium = cloneAnalyzeProvenance(record.Scriptorium)
records[key] = record
}
}
func cloneStrings(values []string) []string {
return append([]string(nil), values...)
}
func cloneArtifactRecord(record *ArtifactRecord) *ArtifactRecord {
if record == nil {
return nil
}
clone := *record
if record.Contract != nil {
contract := *record.Contract
clone.Contract = &contract
}
if record.ExternalProvenance != nil {
provenance := *record.ExternalProvenance
clone.ExternalProvenance = &provenance
}
return &clone
}
func cloneAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) *AnalyzeArtifactProvenance {
if provenance == nil {
return nil
}
clone := *provenance
return &clone
}

View File

@@ -0,0 +1,309 @@
package manifest
import (
"bytes"
"context"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
)
func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
records := map[string]AnalyzeArtifactRecord{
"session_recap": currentAnalyzeArtifactRecord("session_recap", now),
"quest_log": {
Key: "quest_log", Status: AnalyzeArtifactStale,
FingerprintVersion: AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("2", 64),
Dependencies: []string{"session_recap"}, ProducerRunID: "run-stale", UpdatedAt: now,
},
"player_handout": {
Key: "player_handout", Status: AnalyzeArtifactMissing,
Dependencies: []string{"session_recap"}, ProducerRunID: "run-missing", UpdatedAt: now,
},
"npc_digest": {
Key: "npc_digest", Status: AnalyzeArtifactFailed,
ProducerRunID: "run-failed", UpdatedAt: now, Error: "scriptorium validation failed",
Logs: []string{"runs/run-failed/logs/npc-digest.stderr.log"},
},
"gm_notes": {
Key: "gm_notes", Status: AnalyzeArtifactUnselected,
ProducerRunID: "run-unselected", UpdatedAt: now,
},
}
records["session_recap"] = func() AnalyzeArtifactRecord {
record := records["session_recap"]
record.Dependencies = []string{"quest_log", "gm_notes"}
return record
}()
m := New("session", now)
m.Stages["analyze"] = &StageRecord{
Name: "analyze",
Status: StatusSucceeded,
CreatedAt: now,
UpdatedAt: now,
AnalyzeStateVersion: AnalyzeStateContractVersion,
AnalyzeArtifacts: records,
}
path := filepath.Join(t.TempDir(), "manifest.json")
store := &LocalStore{}
if err := store.Save(context.Background(), path, m); err != nil {
t.Fatalf("Save() error = %v", err)
}
loaded, err := store.Load(context.Background(), path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
analyze := loaded.Stages["analyze"]
if !analyze.HasVersionedAnalyzeState() {
t.Fatal("round-tripped analyze record lacks versioned state")
}
for key, want := range records {
got, ok := analyze.AnalyzeArtifacts[key]
if !ok || got.Status != want.Status {
t.Fatalf("artifact %q = %#v, want status %q", key, got, want.Status)
}
}
if got := analyze.AnalyzeArtifacts["session_recap"].Dependencies; !reflect.DeepEqual(got, []string{"gm_notes", "quest_log"}) {
t.Fatalf("canonical dependencies = %#v", got)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
orderedKeys := []string{"gm_notes", "npc_digest", "player_handout", "quest_log", "session_recap"}
previous := -1
for _, key := range orderedKeys {
position := bytes.Index(data, []byte(`"`+key+`": {`))
if position <= previous {
t.Fatalf("map key %q position = %d after %d; JSON is not canonical:\n%s", key, position, previous, data)
}
previous = position
}
}
func TestRunManifestAnalyzeArtifactStateRoundTrip(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
run := NewRun("session", "campaign", "run-123", true, []string{"analyze"}, now)
run.Stages["analyze"] = &RunStageRecord{
Name: "analyze",
Action: RunStageActionRun,
Status: StatusSucceeded,
CreatedAt: now,
UpdatedAt: now,
AnalyzeStateVersion: AnalyzeStateContractVersion,
AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{
"session_recap": currentAnalyzeArtifactRecord("session_recap", now),
},
}
path := filepath.Join(t.TempDir(), "run.json")
store := &LocalStore{}
if err := store.SaveRun(context.Background(), path, run); err != nil {
t.Fatalf("SaveRun() error = %v", err)
}
loaded, err := store.LoadRun(context.Background(), path)
if err != nil {
t.Fatalf("LoadRun() error = %v", err)
}
got := loaded.Stages["analyze"]
if got.AnalyzeStateVersion != AnalyzeStateContractVersion || got.AnalyzeArtifacts["session_recap"].Status != AnalyzeArtifactCurrent {
t.Fatalf("run analyze state = %#v", got)
}
}
func TestValidateAnalyzeArtifactCollectionRejectsMalformedState(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
valid := currentAnalyzeArtifactRecord("session_recap", now)
tests := []struct {
name string
key string
mutate func(*AnalyzeArtifactRecord)
want string
}{
{name: "malformed map key", key: "Session Recap", want: "map key must match"},
{name: "record key mismatch", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Key = "quest_log" }, want: "does not match map key"},
{name: "unsupported status", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = "ready" }, want: "unsupported status"},
{name: "unsupported fingerprint version", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.FingerprintVersion = 99 }, want: "unsupported fingerprint version"},
{name: "bad fingerprint", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Fingerprint = "not-a-digest" }, want: "fingerprint must be"},
{name: "duplicate dependency", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Dependencies = []string{"quest_log", "quest_log"} }, want: "duplicate dependency"},
{name: "malformed dependency", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Dependencies = []string{"Quest Log"} }, want: "dependencies[0]"},
{name: "missing current output", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output = nil }, want: "requires output"},
{name: "bad current size", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.OutputSize = 0 }, want: "positive output_size"},
{name: "bad current source", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.SourceID = "narratio.artifact.other" }, want: "source_id"},
{name: "unsafe current path", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.LocalPath = "../recap.md" }, want: "local_path is unsafe"},
{name: "missing current contract", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.Contract = nil }, want: "output contract"},
{name: "bad current checksum", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("G", 64) }, want: "checksum must be"},
{name: "producer mismatch", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.ProducerRunID = "other-run" }, want: "does not match record"},
{name: "non-current output", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactStale }, want: "forbids output"},
{name: "failed without error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactFailed; r.Output = nil; r.OutputSize = 0 }, want: "requires error"},
{name: "current with error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Error = "unexpected" }, want: "forbids error"},
{name: "oversized error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) {
r.Status = AnalyzeArtifactFailed
r.Output = nil
r.OutputSize = 0
r.Error = strings.Repeat("x", maxAnalyzeArtifactErrorLength+1)
}, want: "error exceeds"},
{name: "bad producer run", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.ProducerRunID = "bad/run" }, want: "producer_run_id"},
{name: "missing update time", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.UpdatedAt = time.Time{} }, want: "updated_at"},
{name: "oversized log collection", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Logs = make([]string, maxAnalyzeArtifactListEntries+1) }, want: "logs exceeds"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
record := valid
record.Output = cloneArtifactRecord(valid.Output)
if test.mutate != nil {
test.mutate(&record)
}
err := ValidateAnalyzeArtifactCollection(AnalyzeStateContractVersion, map[string]AnalyzeArtifactRecord{test.key: record})
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
if err := ValidateAnalyzeArtifactCollection(99, nil); err == nil || !strings.Contains(err.Error(), "unsupported analyze state version") {
t.Fatalf("unsupported version error = %v", err)
}
}
func TestAnalyzeStateOwnershipAndLegacyCompatibility(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
store := &LocalStore{}
legacy := `{
"session_id": "session",
"created_at": "2026-05-03T10:00:00Z",
"updated_at": "2026-05-03T10:01:00Z",
"stages": {
"analyze": {
"name": "analyze",
"status": "succeeded",
"created_at": "2026-05-03T10:00:00Z",
"updated_at": "2026-05-03T10:01:00Z",
"outputs": [{"kind":"session_recap","local_path":"artifacts/session_recap.md"}]
}
}
}`
loaded, err := store.LoadReader(context.Background(), strings.NewReader(legacy))
if err != nil {
t.Fatalf("LoadReader(legacy) error = %v", err)
}
if loaded.Stages["analyze"].HasVersionedAnalyzeState() {
t.Fatal("legacy aggregate outputs became current per-artifact evidence")
}
path := filepath.Join(t.TempDir(), "legacy.json")
if err := store.Save(context.Background(), path, loaded); err != nil {
t.Fatalf("Save(legacy) error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(legacy) error = %v", err)
}
if bytes.Contains(data, []byte("analyze_state_version")) || bytes.Contains(data, []byte("analyze_artifacts")) || bytes.Contains(data, []byte("fingerprint")) {
t.Fatalf("legacy state gained fabricated evidence:\n%s", data)
}
empty := New("session", now)
empty.Stages["analyze"] = &StageRecord{
Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
AnalyzeStateVersion: AnalyzeStateContractVersion,
}
emptyPath := filepath.Join(t.TempDir(), "empty.json")
if err := store.Save(context.Background(), emptyPath, empty); err != nil {
t.Fatalf("Save(versioned empty state) error = %v", err)
}
emptyLoaded, err := store.Load(context.Background(), emptyPath)
if err != nil {
t.Fatalf("Load(versioned empty state) error = %v", err)
}
if !emptyLoaded.Stages["analyze"].HasVersionedAnalyzeState() || len(emptyLoaded.Stages["analyze"].AnalyzeArtifacts) != 0 {
t.Fatalf("versioned empty state = %#v", emptyLoaded.Stages["analyze"])
}
m := New("session", now)
m.Stages["render"] = &StageRecord{
Name: "render", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
AnalyzeStateVersion: AnalyzeStateContractVersion,
}
if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "bad-owner.json"), m); err == nil || !strings.Contains(err.Error(), "cannot contain analyze-owned state") {
t.Fatalf("non-analyze ownership error = %v", err)
}
m.Stages = map[string]*StageRecord{"analyze": {
Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{},
}}
if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "missing-version.json"), m); err == nil || !strings.Contains(err.Error(), "without a state version") {
t.Fatalf("missing version error = %v", err)
}
}
func TestAnalyzeArtifactStateSurvivesAggregateLifecycleClearing(t *testing.T) {
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
for _, transition := range []struct {
name string
apply func(*Manifest)
}{
{name: "running", apply: func(m *Manifest) { m.MarkStageRunning("analyze", now.Add(time.Minute)) }},
{name: "failed", apply: func(m *Manifest) { m.MarkStageFailed("analyze", now.Add(time.Minute), "aggregate failed") }},
{name: "skipped", apply: func(m *Manifest) { m.MarkStageSkipped("analyze", now.Add(time.Minute), "disabled") }},
} {
t.Run(transition.name, func(t *testing.T) {
m := New("session", now)
m.Stages["analyze"] = &StageRecord{
Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
Outputs: []ArtifactRecord{{Kind: "legacy", LocalPath: "artifacts/legacy.md"}},
Logs: []string{"aggregate.log"}, GeneratedConfigs: []string{"aggregate.yml"}, Metadata: map[string]any{"aggregate": true},
AnalyzeStateVersion: AnalyzeStateContractVersion,
AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{
"session_recap": currentAnalyzeArtifactRecord("session_recap", now),
},
}
transition.apply(m)
stage := m.Stages["analyze"]
if len(stage.Outputs) != 0 || len(stage.Logs) != 0 || len(stage.GeneratedConfigs) != 0 || len(stage.Metadata) != 0 {
t.Fatalf("aggregate details survived %s: %#v", transition.name, stage)
}
if !stage.HasVersionedAnalyzeState() || stage.AnalyzeArtifacts["session_recap"].Status != AnalyzeArtifactCurrent {
t.Fatalf("per-artifact state was cleared by %s: %#v", transition.name, stage)
}
})
}
}
func currentAnalyzeArtifactRecord(key string, now time.Time) AnalyzeArtifactRecord {
runID := "run-" + strings.ReplaceAll(key, "_", "-")
return AnalyzeArtifactRecord{
Key: key,
Status: AnalyzeArtifactCurrent,
FingerprintVersion: AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("1", 64),
Output: &ArtifactRecord{
Kind: key,
SourceID: "narratio.artifact." + key,
LocalPath: "artifacts/" + strings.ReplaceAll(key, "_", "-") + ".md",
ProducerRunID: runID,
Checksum: strings.Repeat("a", 64),
Contract: &artifactmodel.ContractMetadata{
MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1",
},
ExternalProvenance: &artifactmodel.ExternalProvenance{
System: "scriptorium", PipelineID: "campaign", ArtifactID: key,
},
},
OutputSize: 42,
ProducerRunID: runID,
UpdatedAt: now,
Scriptorium: &AnalyzeArtifactProvenance{
PromptID: key, ProfileID: "default", CommandMode: "artifact",
},
Logs: []string{"runs/" + runID + "/logs/" + key + ".log"},
GeneratedConfigs: []string{"runs/" + runID + "/config/" + key + ".yml"},
}
}

View File

@@ -44,17 +44,19 @@ type ArtifactRecord struct {
// StageRecord tracks lifecycle and provenance for one pipeline stage.
type StageRecord struct {
Name string `json:"name"`
Status StageStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Outputs []ArtifactRecord `json:"outputs,omitempty"`
Logs []string `json:"logs,omitempty"`
GeneratedConfigs []string `json:"generated_configs,omitempty"`
Error *ErrorRecord `json:"error,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Name string `json:"name"`
Status StageStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Outputs []ArtifactRecord `json:"outputs,omitempty"`
Logs []string `json:"logs,omitempty"`
GeneratedConfigs []string `json:"generated_configs,omitempty"`
Error *ErrorRecord `json:"error,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
}
// CleanupTarget records one root-confined local deletion requested by a

View File

@@ -22,18 +22,20 @@ const (
// RunStageRecord tracks lifecycle and provenance for one stage within a single invocation.
type RunStageRecord struct {
Name string `json:"name"`
Action RunStageAction `json:"action"`
Status StageStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Outputs []ArtifactRecord `json:"outputs,omitempty"`
Logs []string `json:"logs,omitempty"`
GeneratedConfigs []string `json:"generated_configs,omitempty"`
Error *ErrorRecord `json:"error,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
Name string `json:"name"`
Action RunStageAction `json:"action"`
Status StageStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Outputs []ArtifactRecord `json:"outputs,omitempty"`
Logs []string `json:"logs,omitempty"`
GeneratedConfigs []string `json:"generated_configs,omitempty"`
Error *ErrorRecord `json:"error,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
}
// RunManifest is the invocation-scoped execution record under runs/{run_id}/manifest.json.

View File

@@ -110,6 +110,15 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
return fmt.Errorf("save manifest: %w", err)
}
for name, stage := range m.Stages {
if stage == nil {
continue
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("save manifest: stages.%s: %w", name, err)
}
}
m.UpdatedAt = time.Now().UTC()
if m.Stages == nil {
@@ -216,6 +225,15 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
return fmt.Errorf("save run manifest: %w", err)
}
for name, stage := range m.Stages {
if stage == nil {
continue
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("save run manifest: stages.%s: %w", name, err)
}
}
m.UpdatedAt = time.Now().UTC()
if m.Stages == nil {
@@ -250,6 +268,14 @@ func validateLoadedManifest(m *Manifest) error {
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
return err
}
for name, stage := range m.Stages {
if stage == nil {
continue
}
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("stages.%s: %w", name, err)
}
}
return nil
}
@@ -266,6 +292,7 @@ func normalizeManifest(m *Manifest) {
if stage.Name == "" {
stage.Name = name
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
}
}
@@ -309,6 +336,14 @@ func validateLoadedRunManifest(m *RunManifest) error {
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
return err
}
for name, stage := range m.Stages {
if stage == nil {
continue
}
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
return fmt.Errorf("stages.%s: %w", name, err)
}
}
return nil
}
@@ -358,6 +393,7 @@ func normalizeRunManifest(m *RunManifest) {
if stage.Name == "" {
stage.Name = name
}
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
}
}

View File

@@ -0,0 +1,43 @@
// Package notariusref owns Narratio's Notarius CLI reference-selector
// vocabulary without depending on Notarius implementation packages.
package notariusref
import (
"fmt"
"strings"
)
// NormalizeSelector validates and normalizes a Notarius v0.6 reference
// selector. It intentionally validates selector structure only; Notarius owns
// target, slot, and media compatibility.
func NormalizeSelector(value string) (string, error) {
selector := strings.TrimSpace(value)
if selector == "" {
return "", fmt.Errorf("reference selector is required")
}
if strings.Contains(selector, "=") {
return "", fmt.Errorf("reference selector must not contain '='")
}
parts := strings.Split(selector, ".")
for index := range parts {
parts[index] = strings.TrimSpace(parts[index])
if parts[index] == "" {
return "", fmt.Errorf("reference selector components must not be empty")
}
}
switch len(parts) {
case 1, 2:
return strings.Join(parts, "."), nil
case 3:
switch parts[1] {
case "extract", "merge", "normalize":
return strings.Join(parts, "."), nil
default:
return "", fmt.Errorf("three-component reference selector must use extract, merge, or normalize as its middle component")
}
default:
return "", fmt.Errorf("reference selector must use slot, chunk.slot, lane.slot, or lane.stage.slot")
}
}

View File

@@ -0,0 +1,58 @@
package notariusref
import (
"strings"
"testing"
)
func TestNormalizeSelectorAcceptsDocumentedForms(t *testing.T) {
tests := []struct {
name string
value string
want string
}{
{name: "pipeline", value: "party", want: "party"},
{name: "chunk", value: "chunk.party", want: "chunk.party"},
{name: "lane", value: "npc-registry.party", want: "npc-registry.party"},
{name: "extract", value: "npc-registry.extract.party", want: "npc-registry.extract.party"},
{name: "merge", value: "npc-registry.merge.party", want: "npc-registry.merge.party"},
{name: "normalize", value: "npc-registry.normalize.party", want: "npc-registry.normalize.party"},
{name: "whitespace", value: " npc-registry . extract . party ", want: "npc-registry.extract.party"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := NormalizeSelector(test.value)
if err != nil {
t.Fatalf("NormalizeSelector(%q) error = %v", test.value, err)
}
if got != test.want {
t.Fatalf("NormalizeSelector(%q) = %q, want %q", test.value, got, test.want)
}
})
}
}
func TestNormalizeSelectorRejectsInvalidForms(t *testing.T) {
tests := []struct {
name string
value string
wantErr string
}{
{name: "empty", value: "", wantErr: "required"},
{name: "whitespace", value: " ", wantErr: "required"},
{name: "empty first", value: ".party", wantErr: "components"},
{name: "empty middle", value: "lane..party", wantErr: "components"},
{name: "empty final", value: "lane.", wantErr: "components"},
{name: "equals", value: "party=/tmp/party.yml", wantErr: "must not contain"},
{name: "invalid stage", value: "lane.chunk.party", wantErr: "extract, merge, or normalize"},
{name: "too many components", value: "lane.extract.party.extra", wantErr: "must use"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := NormalizeSelector(test.value)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("NormalizeSelector(%q) error = %v, want containing %q", test.value, err, test.wantErr)
}
})
}
}

View File

@@ -2,8 +2,9 @@ package stage
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"sort"
@@ -11,6 +12,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -28,6 +30,8 @@ type analyzeArtifactExecutionPlan struct {
type analyzeArtifactExecutionResult struct {
Output artifacts.Ref
OutputSize int64
Scriptorium manifest.AnalyzeArtifactProvenance
Logs []string
GeneratedConfigs []string
Metadata map[string]any
@@ -44,21 +48,6 @@ type analyzeExecutionContext struct {
Catalog *artifacts.ArtifactCatalog
}
type analyzeInputResolutionState uint8
const (
analyzeInputPresent analyzeInputResolutionState = iota
analyzeInputAbsent
analyzeInputError
)
type analyzeInputResolution struct {
State analyzeInputResolutionState
Path string
Artifact *artifacts.ResolvedSessionArtifact
Err error
}
func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
if env == nil || env.Config == nil {
return nil, fmt.Errorf("analyze: stage environment config is required")
@@ -69,10 +58,6 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if env.Config.Pipeline == nil || env.Config.Session == nil {
return nil, fmt.Errorf("analyze: resolved config must include pipeline and session")
}
if env.Scriptorium == nil {
return nil, fmt.Errorf("analyze: scriptorium adapter is required")
}
var sessionID string
if m != nil {
sessionID = strings.TrimSpace(m.SessionID)
@@ -96,6 +81,13 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
"reason": "pipeline.scriptorium is not configured",
}}, nil
}
if len(env.Config.Pipeline.Scriptorium.Artifacts) == 0 {
return &StageResult{Metadata: map[string]any{
"stage": "analyze",
"skipped": true,
"reason": "no scriptorium artifacts configured",
}}, nil
}
effective := env.EffectiveArtifacts
if !effective.Resolved() {
@@ -119,15 +111,11 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err)
}
plans, skipReason, err := buildAnalyzeExecutionPlans(env.Config.Pipeline.Scriptorium, effective, runtimeCatalog)
if err != nil {
return nil, fmt.Errorf("analyze: %w", err)
}
if skipReason != "" {
if len(effective.Keys()) == 0 {
return &StageResult{Metadata: map[string]any{
"stage": "analyze",
"skipped": true,
"reason": skipReason,
"reason": "no selected scriptorium artifacts to execute",
}}, nil
}
@@ -140,21 +128,81 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths),
Catalog: runtimeCatalog,
}
reconciliation, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, execution)
if err != nil {
return nil, fmt.Errorf("analyze: reconcile configured artifacts: %w", err)
}
workPlan, err := planAnalyzeWork(
env.Config.Pipeline.Scriptorium,
env.SelectedArtifactKeys,
env.Force,
reconciliation,
)
if err != nil {
return nil, fmt.Errorf("analyze: plan configured artifacts: %w", err)
}
if len(workPlan.ExecutionOrder) > 0 && env.Scriptorium == nil {
return nil, fmt.Errorf("analyze: scriptorium adapter is required")
}
outputs := make([]artifacts.Ref, 0, len(plans))
logs := []string{}
generatedConfigs := []string{}
artifactMetadata := make([]map[string]any, 0, len(plans))
artifactMetadata := make([]map[string]any, 0, len(workPlan.ExecutionOrder))
reusedArtifacts := []map[string]any{}
reusedSeen := map[string]struct{}{}
sessionRecords := manifest.CloneAnalyzeArtifactCollection(workPlan.ProjectedRecords)
invocationRecords := make(map[string]manifest.AnalyzeArtifactRecord)
priorCurrentRecords := make(map[string]manifest.AnalyzeArtifactRecord)
for _, item := range reconciliation.Ordered {
if item.Stored != nil && item.Stored.Status == manifest.AnalyzeArtifactCurrent {
priorCurrentRecords[item.Key] = *item.Stored
}
}
invocationKeys := make(map[string]struct{}, len(workPlan.ExecutionOrder)+len(workPlan.ReusedCurrent))
for _, item := range workPlan.ExecutionOrder {
invocationKeys[item.Key] = struct{}{}
}
for _, item := range workPlan.ReusedCurrent {
invocationKeys[item.Key] = struct{}{}
if record, ok := sessionRecords[item.Key]; ok {
invocationRecords[item.Key] = record
}
}
for _, plan := range plans {
for _, item := range workPlan.ExecutionOrder {
artifactCfg := env.Config.Pipeline.Scriptorium.Artifacts[item.Key]
fingerprint, _, _, err := computeAnalyzeArtifactFingerprint(
item.Key,
env.Config.Pipeline.Scriptorium,
execution,
)
if err != nil {
executionErr := fmt.Errorf("analyze: compute execution fingerprint for artifact %q: %w", item.Key, err)
return failedAnalyzeResult(
execution,
item,
artifactCfg,
"",
executionErr,
sessionRecords,
invocationRecords,
), executionErr
}
priorRecord, hadPriorRecord := priorCurrentRecords[item.Key]
plan := analyzeArtifactExecutionPlan{Name: item.Key, Cfg: artifactCfg}
artifactResult, err := executeAnalyzeArtifact(ctx, execution, plan)
if err != nil {
return nil, err
return failedAnalyzeResult(
execution,
item,
artifactCfg,
fingerprint,
err,
sessionRecords,
invocationRecords,
), err
}
outputs = append(outputs, artifactResult.Output)
logs = append(logs, artifactResult.Logs...)
generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...)
artifactMetadata = append(artifactMetadata, artifactResult.Metadata)
@@ -169,18 +217,64 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
reusedArtifacts = append(reusedArtifacts, reused)
}
record, err := currentAnalyzeArtifactRecord(
execution,
plan,
fingerprint,
artifactResult,
)
if err != nil {
recordErr := fmt.Errorf("analyze: record artifact %q: %w", plan.Name, err)
return failedAnalyzeResult(
execution,
item,
artifactCfg,
fingerprint,
recordErr,
sessionRecords,
invocationRecords,
), recordErr
}
sessionRecords[plan.Name] = record
invocationRecords[plan.Name] = record
sourceID, ok := runtimeCatalog.SourceIDForConfiguredKey(plan.Name)
if !ok {
return nil, fmt.Errorf("analyze: source id not found for artifact %q", plan.Name)
catalogErr := fmt.Errorf("analyze: source id not found for artifact %q", plan.Name)
return failedAnalyzeResult(
execution, item, artifactCfg, fingerprint, catalogErr,
sessionRecords, invocationRecords,
), catalogErr
}
if err := runtimeCatalog.MarkAvailableGenerated(sourceID, artifactResult.Output.AbsolutePath); err != nil {
return nil, fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err)
if err := runtimeCatalog.MarkAvailableGeneratedEvidence(
sourceID,
artifactResult.Output.AbsolutePath,
record.ProducerRunID,
record.Output.Checksum,
record.OutputSize,
record.Output.Contract,
); err != nil {
catalogErr := fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err)
return failedAnalyzeResult(
execution, item, artifactCfg, fingerprint, catalogErr,
sessionRecords, invocationRecords,
), catalogErr
}
if !hadPriorRecord || !sameAnalyzeOutputIdentity(priorRecord, record) {
staleUnscheduledAnalyzeDependents(
env.Config.Pipeline.Scriptorium.Artifacts,
plan.Name,
invocationKeys,
sessionRecords,
)
}
}
metadata := map[string]any{
"stage": "analyze",
"selected_artifacts": extractPlanNames(plans),
"selected_artifacts": append([]string(nil), workPlan.ExplicitTargets...),
"executed_artifacts": analyzePlanKeysForMetadata(workPlan.ExecutionOrder),
"reused_current": analyzePlanKeysForMetadata(workPlan.ReusedCurrent),
"generated_artifacts": artifactMetadata,
"reused_artifacts": reusedArtifacts,
"artifact_count": len(artifactMetadata),
@@ -193,133 +287,200 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
}
return &StageResult{
Outputs: outputs,
Logs: dedupeAndSortPaths(logs),
GeneratedConfigs: dedupeAndSortPaths(generatedConfigs),
Metadata: metadata,
AnalyzeState: &AnalyzeStateProjection{
Session: sessionRecords,
Invocation: manifest.CloneAnalyzeArtifactCollection(invocationRecords),
},
}, nil
}
func buildAnalyzeExecutionPlans(
scriptoriumCfg *config.ScriptoriumConfig,
effective artifacts.EffectiveArtifactSet,
catalog *artifacts.ArtifactCatalog,
) ([]analyzeArtifactExecutionPlan, string, error) {
if scriptoriumCfg == nil {
return nil, "pipeline.scriptorium is not configured", nil
func failedAnalyzeResult(
execution analyzeExecutionContext,
item analyzePlanItem,
artifactCfg config.ScriptoriumArtifactConfig,
fingerprint string,
cause error,
sessionRecords map[string]manifest.AnalyzeArtifactRecord,
invocationRecords map[string]manifest.AnalyzeArtifactRecord,
) *StageResult {
record := manifest.AnalyzeArtifactRecord{
Key: item.Key,
Status: manifest.AnalyzeArtifactFailed,
Dependencies: normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn),
ProducerRunID: analyzeProducerRunID(execution),
UpdatedAt: time.Now().UTC(),
Error: NonResumable(cause.Error()).Reason,
}
if len(scriptoriumCfg.Artifacts) == 0 {
return nil, "no scriptorium artifacts configured", nil
if fingerprint != "" {
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
record.Fingerprint = fingerprint
}
if len(effective.Keys()) == 0 {
return nil, "no selected scriptorium artifacts to execute", nil
}
ordered, err := orderSelectedScriptoriumArtifacts(scriptoriumCfg.Artifacts, effective, catalog)
if err != nil {
return nil, "", err
}
plans := make([]analyzeArtifactExecutionPlan, 0, len(ordered))
for _, name := range ordered {
artifactCfg, ok := scriptoriumCfg.Artifacts[name]
if !ok {
return nil, "", fmt.Errorf("selected artifact %q is not configured", name)
if artifactCfg.PromptID != "" || artifactCfg.ProfileID != "" {
record.Scriptorium = &manifest.AnalyzeArtifactProvenance{
PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID,
}
plans = append(plans, analyzeArtifactExecutionPlan{Name: name, Cfg: artifactCfg})
}
return plans, "", nil
sessionRecords[item.Key] = record
invocationRecords[item.Key] = record
staleAnalyzeDependents(artifactCfgMap(execution), item.Key, sessionRecords)
return &StageResult{AnalyzeState: &AnalyzeStateProjection{
Session: manifest.CloneAnalyzeArtifactCollection(sessionRecords),
Invocation: manifest.CloneAnalyzeArtifactCollection(invocationRecords),
}}
}
func orderSelectedScriptoriumArtifacts(
artifactsCfg map[string]config.ScriptoriumArtifactConfig,
effective artifacts.EffectiveArtifactSet,
catalog *artifacts.ArtifactCatalog,
) ([]string, error) {
selectedSet := map[string]struct{}{}
selected := effective.Keys()
for _, key := range selected {
selectedSet[key] = struct{}{}
func artifactCfgMap(execution analyzeExecutionContext) map[string]config.ScriptoriumArtifactConfig {
if execution.Env == nil || execution.Env.Config == nil || execution.Env.Config.Pipeline == nil ||
execution.Env.Config.Pipeline.Scriptorium == nil {
return nil
}
return execution.Env.Config.Pipeline.Scriptorium.Artifacts
}
dependencyErrors := []string{}
for _, selectedKey := range selected {
cfg, ok := artifactsCfg[selectedKey]
if !ok {
dependencyErrors = append(dependencyErrors, fmt.Sprintf("selected artifact %q is not configured", selectedKey))
func staleAnalyzeDependents(
configured map[string]config.ScriptoriumArtifactConfig,
changed string,
records map[string]manifest.AnalyzeArtifactRecord,
) {
staleAnalyzeDependentClosure(configured, changed, records, nil)
}
func currentAnalyzeArtifactRecord(
execution analyzeExecutionContext,
plan analyzeArtifactExecutionPlan,
fingerprint string,
result *analyzeArtifactExecutionResult,
) (manifest.AnalyzeArtifactRecord, error) {
if result == nil {
return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("execution result is required")
}
producerRunID := analyzeProducerRunID(execution)
relativePath, err := normalizedAnalyzeOutputIdentity(plan.Cfg.OutputPath)
if err != nil {
return manifest.AnalyzeArtifactRecord{}, err
}
if relativePath == "" {
return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("configured output path is required")
}
contract := result.Output.Contract
if contract == nil {
return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("validated output contract is required")
}
record := manifest.AnalyzeArtifactRecord{
Key: plan.Name,
Status: manifest.AnalyzeArtifactCurrent,
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
Fingerprint: fingerprint,
Dependencies: normalizedAnalyzeDependencyKeys(plan.Cfg.DependsOn),
Output: &manifest.ArtifactRecord{
Kind: "scriptorium_artifact",
SourceID: artifacts.ConfiguredArtifactSourceID(plan.Name),
LocalPath: relativePath,
Contract: cloneAnalyzeOutputContract(contract),
ProducerRunID: producerRunID,
Checksum: result.Output.Checksum,
},
OutputSize: result.OutputSize,
ProducerRunID: producerRunID,
UpdatedAt: time.Now().UTC(),
Scriptorium: &result.Scriptorium,
Logs: dedupeAndSortPaths(result.Logs),
GeneratedConfigs: dedupeAndSortPaths(result.GeneratedConfigs),
}
if err := manifest.ValidateAnalyzeArtifactCollection(
manifest.AnalyzeStateContractVersion,
map[string]manifest.AnalyzeArtifactRecord{plan.Name: record},
); err != nil {
return manifest.AnalyzeArtifactRecord{}, err
}
return record, nil
}
func analyzeProducerRunID(execution analyzeExecutionContext) string {
if execution.Manifest != nil {
if runID := strings.TrimSpace(execution.Manifest.RunID); runID != "" {
return runID
}
}
// Direct stage callers predate invocation manifests. Application-owned
// execution always supplies the actual run identity.
return "direct-analyze"
}
func sameAnalyzeOutputIdentity(left, right manifest.AnalyzeArtifactRecord) bool {
if left.Status != manifest.AnalyzeArtifactCurrent || right.Status != manifest.AnalyzeArtifactCurrent ||
left.Output == nil || right.Output == nil || left.Output.Contract == nil || right.Output.Contract == nil {
return false
}
return left.OutputSize == right.OutputSize &&
left.Output.Checksum == right.Output.Checksum &&
*left.Output.Contract == *right.Output.Contract
}
func staleUnscheduledAnalyzeDependents(
configured map[string]config.ScriptoriumArtifactConfig,
changed string,
invocationKeys map[string]struct{},
records map[string]manifest.AnalyzeArtifactRecord,
) {
staleAnalyzeDependentClosure(configured, changed, records, func(key string) bool {
_, evaluated := invocationKeys[key]
return !evaluated
})
}
func staleAnalyzeDependentClosure(
configured map[string]config.ScriptoriumArtifactConfig,
changed string,
records map[string]manifest.AnalyzeArtifactRecord,
eligible func(string) bool,
) {
reverse := make(map[string][]string, len(configured))
for key, artifactCfg := range configured {
for _, dependency := range normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn) {
reverse[dependency] = append(reverse[dependency], key)
}
}
for key := range reverse {
sort.Strings(reverse[key])
}
queue := append([]string(nil), reverse[changed]...)
seen := make(map[string]struct{}, len(queue))
for len(queue) > 0 {
key := queue[0]
queue = queue[1:]
if _, visited := seen[key]; visited {
continue
}
for _, dep := range cfg.DependsOn {
trimmedDep := strings.TrimSpace(dep)
if trimmedDep == "" {
continue
}
if _, ok := selectedSet[trimmedDep]; ok {
continue
}
sourceID, ok := catalog.SourceIDForConfiguredKey(trimmedDep)
if !ok {
dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep))
continue
}
entry, ok := catalog.Lookup(sourceID)
if !ok || !entry.Available {
dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID))
}
seen[key] = struct{}{}
if eligible != nil && !eligible(key) {
continue
}
staleProjectedAnalyzeRecord(records, key)
queue = append(queue, reverse[key]...)
}
if len(dependencyErrors) > 0 {
return nil, errors.New(strings.Join(dependencyErrors, "; "))
}
}
indegree := map[string]int{}
edges := map[string][]string{}
for _, key := range selected {
indegree[key] = 0
func analyzePlanKeysForMetadata(items []analyzePlanItem) []string {
if len(items) == 0 {
return nil
}
for _, key := range selected {
cfg := artifactsCfg[key]
for _, dep := range cfg.DependsOn {
trimmedDep := strings.TrimSpace(dep)
if _, ok := selectedSet[trimmedDep]; !ok {
continue
}
edges[trimmedDep] = append(edges[trimmedDep], key)
indegree[key]++
}
keys := make([]string, 0, len(items))
for _, item := range items {
keys = append(keys, item.Key)
}
return keys
}
for key := range edges {
sort.Strings(edges[key])
func cloneAnalyzeOutputContract(value *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
if value == nil {
return nil
}
ready := make([]string, 0, len(indegree))
for key, degree := range indegree {
if degree == 0 {
ready = append(ready, key)
}
}
sort.Strings(ready)
order := make([]string, 0, len(selectedSet))
for len(ready) > 0 {
node := ready[0]
ready = ready[1:]
order = append(order, node)
for _, dep := range edges[node] {
indegree[dep]--
if indegree[dep] == 0 {
ready = append(ready, dep)
sort.Strings(ready)
}
}
}
if len(order) != len(selectedSet) {
return nil, fmt.Errorf("selected scriptorium artifacts contain a dependency cycle")
}
return order, nil
cloned := *value
return &cloned
}
func executeAnalyzeArtifact(
@@ -335,34 +496,25 @@ func executeAnalyzeArtifact(
artifactName := plan.Name
artifactCfg := plan.Cfg
inputPaths := map[string]string{}
resolvedInputs, err := resolveAnalyzeInputIdentities(artifactCfg.Inputs, execution)
if err != nil {
return nil, fmt.Errorf("analyze: resolve inputs for artifact %q: %w", artifactName, err)
}
inputPaths := resolvedInputs.Paths()
omittedOptionalInputs := []string{}
reusedArtifacts := []map[string]any{}
inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs)
for _, inputName := range inputNames {
inputCfg := artifactCfg.Inputs[inputName]
resolution := resolveScriptoriumInput(inputCfg, execution)
switch resolution.State {
case analyzeInputError:
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolution.Err)
case analyzeInputAbsent:
if inputCfg.Required {
return nil, fmt.Errorf("analyze: required input %q for artifact %q could not be resolved", inputName, artifactName)
}
omittedOptionalInputs = append(omittedOptionalInputs, inputName)
for _, identity := range resolvedInputs.Ordered {
if !identity.Present {
omittedOptionalInputs = append(omittedOptionalInputs, identity.Name)
continue
case analyzeInputPresent:
inputPaths[inputName] = resolution.Path
default:
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: invalid resolution state", inputName, artifactName)
}
if resolution.Artifact != nil && resolution.Artifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk {
resolvedArtifact := resolvedInputs.Artifact(identity.Name)
if resolvedArtifact != nil && resolvedArtifact.Provenance == artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
reusedArtifacts = append(reusedArtifacts, map[string]any{
"name": configuredArtifactNameFromSourceID(resolution.Artifact.ID),
"source_id": resolution.Artifact.ID,
"path": resolution.Artifact.Path,
"provenance": resolution.Artifact.Provenance,
"name": configuredArtifactNameFromSourceID(resolvedArtifact.ID),
"source_id": resolvedArtifact.ID,
"path": resolvedArtifact.Path,
"provenance": resolvedArtifact.Provenance,
})
}
}
@@ -528,14 +680,32 @@ func executeAnalyzeArtifact(
if err := requireNonEmptyFile(finalOutputPath, artifactName+" output"); err != nil {
return nil, fmt.Errorf("analyze: %w", err)
}
validatedOutput, err := readExternalResult(finalOutputPath, artifactName+" output")
if err != nil {
return nil, fmt.Errorf("analyze: %w", err)
}
contract := &artifactmodel.ContractMetadata{
MediaType: "text/markdown", SchemaID: "narratio." + artifactName, SchemaVersion: "1",
}
relativeOutputPath, err := normalizedAnalyzeOutputIdentity(artifactCfg.OutputPath)
if err != nil {
return nil, fmt.Errorf("analyze: normalize output identity for artifact %q: %w", artifactName, err)
}
materializedArtifact, err := materializeRunLocalOutput(env.ArtifactStore, finalOutputPath, canonicalOutputPath, artifacts.Ref{
Kind: artifactName,
Category: "artifacts",
SessionID: sessionID,
Kind: artifactName,
SourceID: artifacts.ConfiguredArtifactSourceID(artifactName),
Category: "artifacts",
SessionID: sessionID,
RelativePath: relativeOutputPath,
Contract: contract,
})
if err != nil {
return nil, fmt.Errorf("analyze: materialize artifact output for %q: %w", artifactName, err)
}
expectedDigest := sha256.Sum256(validatedOutput)
if materializedArtifact.Checksum != hex.EncodeToString(expectedDigest[:]) {
return nil, fmt.Errorf("analyze: materialized artifact output for %q differs from validated run-local bytes", artifactName)
}
logPaths = append(logPaths, stdoutLogPath, stderrLogPath)
generatedConfigs = append(generatedConfigs, generatedConfigPath)
@@ -561,7 +731,11 @@ func executeAnalyzeArtifact(
}
return &analyzeArtifactExecutionResult{
Output: materializedArtifact,
Output: materializedArtifact,
OutputSize: int64(len(validatedOutput)),
Scriptorium: manifest.AnalyzeArtifactProvenance{
PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, CommandMode: res.CommandMode,
},
Logs: logPaths,
GeneratedConfigs: generatedConfigs,
Metadata: meta,
@@ -569,17 +743,6 @@ func executeAnalyzeArtifact(
}, nil
}
func extractPlanNames(plans []analyzeArtifactExecutionPlan) []string {
if len(plans) == 0 {
return nil
}
out := make([]string, 0, len(plans))
for _, plan := range plans {
out = append(out, plan.Name)
}
return out
}
func configuredArtifactNameFromSourceID(sourceID string) string {
name, _ := artifactpolicy.ParseConfiguredSource(sourceID)
return name
@@ -620,83 +783,6 @@ func discoverAnalyzeArtifactRef(m *manifest.Manifest, paths artifacts.SessionPat
return resolved.Path, resolved.Provenance
}
func resolveScriptoriumInput(inputCfg config.ScriptoriumInputConfig, execution analyzeExecutionContext) analyzeInputResolution {
source := strings.TrimSpace(inputCfg.Source)
descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source)
if describeErr != nil {
return analyzeInputFailure(describeErr)
}
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, execution.Paths)
if err != nil {
if inputCfg.Required {
return analyzeInputFailure(err)
}
return analyzeInputMissing()
}
if !ok {
return analyzeInputMissing()
}
return analyzeInputFound(resolvedPath, nil)
}
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
if err == nil {
copy := resolved
return analyzeInputFound(resolved.Path, &copy)
}
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
if inputCfg.Required {
return analyzeInputFailure(fmt.Errorf(
"required previous-session input source %q is unavailable; run narratio run-stage prepare %s --force",
source,
execution.SessionID,
))
}
return analyzeInputMissing()
}
return analyzeInputFailure(err)
}
resolved, err := artifacts.ResolveSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
if err == nil {
copy := resolved
return analyzeInputFound(resolved.Path, &copy)
}
if !errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
return analyzeInputFailure(err)
}
if !inputCfg.Required {
return analyzeInputMissing()
}
switch descriptor.Source.Kind {
case artifactpolicy.SourceKindExtraction:
return analyzeInputFailure(fmt.Errorf(
"required extraction source %q is unavailable; enable and configure pipeline.notarius output %q, then run narratio run-stage extract %s --force",
source,
descriptor.Source.ConfiguredKey,
execution.SessionID,
))
case artifactpolicy.SourceKindConfiguredArtifact:
return analyzeInputFailure(fmt.Errorf("configured artifact source %q is unavailable", source))
default:
return analyzeInputFailure(requiredBuiltInInputError(descriptor.Source.ID, execution))
}
}
func analyzeInputFound(path string, artifact *artifacts.ResolvedSessionArtifact) analyzeInputResolution {
return analyzeInputResolution{State: analyzeInputPresent, Path: path, Artifact: artifact}
}
func analyzeInputMissing() analyzeInputResolution {
return analyzeInputResolution{State: analyzeInputAbsent}
}
func analyzeInputFailure(err error) analyzeInputResolution {
return analyzeInputResolution{State: analyzeInputError, Err: err}
}
func requiredBuiltInInputError(source string, execution analyzeExecutionContext) error {
entry, ok := execution.Catalog.Lookup(source)
if !ok || strings.TrimSpace(entry.ProducerStage) == "" {
@@ -710,36 +796,6 @@ func requiredBuiltInInputError(source string, execution analyzeExecutionContext)
)
}
func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) {
filename, ok := preparedStableInputFilename(sourceID)
if !ok {
return "", false, fmt.Errorf("unsupported prepared input source %q", sourceID)
}
path := filepath.Join(paths.InputsDir, filename)
if err := requireNonEmptyFile(path, "prepared input "+sourceID); err != nil {
return "", false, fmt.Errorf(
"prepared input source %q is unavailable; run narratio run-stage prepare %s --force: %w",
sourceID,
paths.SessionID,
err,
)
}
return path, true, nil
}
func preparedStableInputFilename(sourceID string) (string, bool) {
switch strings.TrimSpace(sourceID) {
case artifactpolicy.SourceInputPlayers:
return "players.yml", true
case artifactpolicy.SourceInputParty:
return "party.yml", true
case artifactpolicy.SourceInputGlossary:
return "glossary.yml", true
default:
return "", false
}
}
func buildAnalyzeRuntimeArtifactCatalog(
paths artifacts.SessionPaths,
m *manifest.Manifest,
@@ -759,25 +815,7 @@ func buildAnalyzeRuntimeArtifactCatalog(
if notariusCfg != nil && notariusCfg.Enabled {
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
}
for _, entry := range catalog.ListConfigured() {
if entry.Executable {
continue
}
if strings.TrimSpace(entry.CanonicalRelPath) == "" {
continue
}
resolvedPath, err := resolveScriptoriumOutputPath(paths, entry.CanonicalRelPath)
if err != nil {
continue
}
if err := requireNonEmptyFile(resolvedPath, "configured artifact "+entry.SourceID); err != nil {
continue
}
if err := catalog.MarkAvailableFromDisk(entry.SourceID, resolvedPath); err != nil {
return nil, err
}
}
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
return catalog, nil
}

Some files were not shown because too many files have changed in this diff Show More