27 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
90 changed files with 8378 additions and 1637 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

@@ -310,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 |
@@ -323,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>`:

View File

@@ -35,16 +35,29 @@ 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
prepared reference resolution, receipt, bundle, and configured-lane policy.
The adapter validates the ordered selector/absolute-path pairs and is the sole

View File

@@ -47,22 +47,34 @@ kind and prepared filename vocabulary.
- `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`
@@ -75,7 +87,17 @@ 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.*`):

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
@@ -21,30 +22,104 @@ Supported source families:
## 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 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.
@@ -59,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
@@ -72,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.
@@ -50,8 +50,9 @@ 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

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

@@ -80,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`
@@ -90,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`
@@ -111,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
@@ -436,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

@@ -1,546 +0,0 @@
# Notarius v0.6 CLI References Implementation Plan
## Purpose And Status
This is the executable implementation plan for the accepted target state in
[Notarius v0.6 CLI Reference Integration](notarius-v0.6-cli-references.md). It
is written for a `gpt-5.6-terra` coding agent that will implement exactly one
pending stage per prompt, in order.
The feature roadmap owns user intent, architectural boundaries, settled policy,
and the target end state. This document owns delivery order, concrete changes,
test allocation, and implementation status. Do not restate or change a roadmap
decision here during implementation; if current Notarius v0.6.0 evidence
contradicts the roadmap, stop and record the conflict instead of inventing a
different contract.
| Stage | Outcome | Status |
| ---: | --- | --- |
| 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Completed |
| 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Completed |
| 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Completed |
| 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Completed |
| 5 | Resolve references in extract and bind fingerprints, resume, and metadata to their identities. | Completed |
| 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Completed |
| 7 | Update canonical documentation and maintained examples for the completed feature. | Completed |
| 8 | Perform compatibility, quality, and repository-wide closure validation. | Completed |
## Governing Decisions
The following requirements are settled and are not questions for the
implementing agent:
1. `pipeline.notarius.references` is a map whose key is a Notarius v0.6 CLI
reference selector and whose value is a prepared Narratio source ID. It is
not a map of paths.
2. Every configured binding is required. There is no per-entry `required`
field. An optional Notarius reference is omitted by omitting the map entry.
An empty or omitted map remains valid for custom pipelines and backward
compatibility.
The map is limited by the centrally declared configuration constant
`MaxNotariusReferenceBindings = 256`, which is far above the four-entry
maintained D&D case while bounding argv and manifest growth.
3. The supported prepared reference sources are
`narratio.input.party`, `narratio.input.players`,
`narratio.input.glossary`, and `narratio.input.spell_catalog`.
Arbitrary Notarius slot names and qualified selectors may bind those sources;
direct paths and later-stage artifacts may not.
4. Campaign and session `spell_catalog_file` are optional. A session value
overrides the campaign value; an empty session value inherits the campaign
value. A `narratio.input.spell_catalog` reference binding requires an
effective configured file.
5. Prepared sources are authoritative only when the current session manifest
records the matching canonical input and checksum. Consumers do not fall
back to campaign/session source paths or accept an incidental workspace file.
6. Narratio passes absolute prepared-file paths to Notarius. Fingerprints and
metadata use the canonical workspace-relative path identity together with
selector, source ID, SHA-256 checksum, and size so workspace relocation does
not become the only identity signal.
7. External CLI references are sorted by normalized selector. They override
matching external references in the Notarius configuration. Narratio never
emits `--without-reference`, the deprecated `roster` alias, or CLI bindings
for generated D&D artifact handoffs.
8. Notarius remains authoritative for whether a selected target declares a
slot, accepted reference media types and sizes, generated-handoff collisions,
pipeline topology, and D&D payload schemas. Narratio validates selector
structure and its own source contract only.
9. Notarius v0.6.0 is the minimum supported CLI contract when references are
configured. Do not add version-string parsing or an automatic per-session
`notarius config validate` subprocess.
10. The existing receipt-v2, bundle-confinement, diagnostic, ten-lane selection,
immutable promotion, and analysis-source behavior must remain intact.
11. The default test suite remains offline, deterministic, and independent of
a sibling checkout or installed Notarius binary. A real v0.6.0 smoke run is
useful supplementary evidence when locally available, not a default-suite
dependency.
12. Add no external Go dependency for this feature. Use narrow owner-specific
types and existing file, path, artifact, manifest, adapter, and stage
facilities.
## Instructions For Every Stage
For each implementation prompt, the coding agent must:
1. Read `docs/development.md`, all three files under `docs/policy/`, the feature
roadmap, this plan, and the stage-specific documents and source named below.
Inspect the current tree because earlier stages may have changed names or
ownership boundaries.
2. Use the repository knowledge graph first for code discovery and call tracing;
use text search for documentation, configuration, examples, string literals,
and evidence the graph cannot supply.
3. Confirm the worktree state and preserve unrelated changes. Implement only the
current stage. Do not begin a later stage merely because an adjacent file is
open.
4. Keep production code, focused tests, fakes, and fixtures consistent within
the stage. Remove superseded helpers when their final caller migrates. Do not
retain two competing source maps, path resolvers, fingerprint paths, or
subprocess argument builders.
5. Follow the testing policy's ownership rule. Parser/config tests own selector
and configuration cases; artifact tests own prepared-file identity and
integrity; adapter tests own exact arguments; stage tests own orchestration and
resume; application tests own lifecycle invalidation. Do not repeat every
lower-level case at higher levels.
6. Keep errors actionable and content-free. They may identify a selector,
Narratio source ID, canonical path, or checksum mismatch, but must not include
reference contents. Preserve ordinary group-workspace permissions and
restrictive API-key handling.
7. Run `gofmt` on changed Go files and focused tests while iterating. Before
marking any stage complete, run at minimum:
```sh
go test ./...
go test -race ./...
go vet ./...
go build ./...
go test ./internal/doccheck
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
```
Default tests must not contact live services or require credentials.
8. Compare the final diff against the stage goal and exit criteria. Update only
the current stage's status row from `Pending` to `Completed`. Do not mark a
stage complete while a required check fails or required behavior is absent.
Intermediate commits are implementation-branch state and must not be released
before Stage 7 has reconciled current-behavior documentation.
## Stage 1 — Reference And Configuration Vocabulary
**Read first:** `docs/config.md`, `docs/integrations/notarius.md`,
`internal/config/config.go`, `internal/config/defaults.go`,
`internal/config/load.go`, `internal/config/validate.go`,
`internal/config/notarius_test.go`, `internal/config/campaign_config_test.go`,
and `internal/artifactpolicy/policy.go` and its tests. Read the tagged Notarius
v0.6.0 `docs/cli.md` reference-selector section from `../notarius` when that
checkout is available; otherwise use the canonical link from the feature
roadmap.
**Depends on:** None.
**Goal:** Establish one normalized reference-selector grammar and the strict
configuration model needed by later stages, without adding path-valued Notarius
configuration or making spell catalogs mandatory for every campaign.
**Work:**
- Add a small dependency-free `internal/notariusref` package as the contract
owner for Notarius reference selector normalization. Its exported normalizer
must trim the selector and each component, reject empty components and `=`,
and accept only the v0.6 forms `slot`,
`chunk.slot`, `lane.slot`, `lane.extract.slot`, `lane.merge.slot`, and
`lane.normalize.slot`. A three-component selector accepts only `extract`,
`merge`, or `normalize` in its middle component. Do not check the selector
against a Notarius module or lane registry.
- Add `References map[string]string` with YAML key `references` to
`config.NotariusConfig`. During enabled Notarius validation, sort raw keys,
normalize each selector through the shared contract, trim each source value,
reject empty values and normalized-selector collisions, require each value to
be one of the four prepared reference sources, and replace the config map with
its normalized form. Enforce the named, centrally discoverable
`config.MaxNotariusReferenceBindings` limit of 256 entries with an error that
identifies the field and limit. Keep a nil/empty map valid. Rely on strict YAML
decoding to reject duplicate identical keys, but explicitly reject distinct
raw keys that normalize to one selector.
- Add `SpellCatalogFile string` with YAML key `spell_catalog_file` to campaign
inputs and session inputs, plus `SpellCatalogFile ResolvedInputFile` to
resolved stable inputs. Merge it with the existing session-over-campaign
helper. It is not part of the campaign-required input set. Reject a non-empty
configured scalar that becomes empty after trimming.
- Add `artifactpolicy.SourceInputSpellCatalog` and make the artifact-policy
owner describe all four prepared reference sources, including their canonical
manifest kind and filename. Use that owner for stable-source recognition
instead of adding a second switch in configuration validation. Preserve the
existing three source IDs and their behavior.
- Extend cross-configuration validation so a normalized reference to
`narratio.input.spell_catalog` requires a non-empty effective resolved
`spell_catalog_file`. Existing campaign requirements already guarantee party,
players, and glossary declarations. Do not check filesystem existence during
configuration validation.
- If analyze's current private filename switch must change to keep the tree
behaviorally coherent, make it delegate to the artifact-policy descriptor and
recognize spell catalog; Stage 3 will replace the filesystem-only resolver.
**Tests and exit criteria:** At the contract/config owners, cover every accepted
selector shape; zero, empty, excess, invalid-stage, and `=` forms; whitespace
normalization; normalized collisions; unsupported and empty source IDs; nil and
empty maps; exactly the configured binding limit and limit plus one; strict
unknown fields; campaign inheritance and session override; optional omission;
and the cross-config missing-spell-catalog failure. Prefer table-driven parser
and validator tests over assertions against private helper structure. Existing
configuration and example tests must still pass without adding spell catalogs
to every campaign. The codebase has one selector grammar owner and one
prepared-source descriptor owner.
## Stage 2 — Spell Catalog Prepare And Operator Lifecycle
**Read first:** `docs/internal/stage-prepare.md`, `docs/internal/workspace.md`,
`docs/operations.md`, `internal/stage/prepare.go` and its tests,
`internal/app/operator_inspection.go`, `internal/app/operator_findings.go` and
their focused tests, `internal/manifest/manifest.go`, and the relevant confined
file-operation helpers.
**Depends on:** Stage 1.
**Goal:** Make the effective optional spell catalog a normal prepared session
input with canonical storage, checksum/provenance, safe stale-file handling, and
operator visibility.
**Work:**
- Resolve `StableInputs.SpellCatalogFile` with the same origin-preserving
campaign/session behavior as the five existing stable inputs. When configured,
require a regular readable source, copy it atomically to
`inputs/spell_catalog.json`, preserve ordinary workspace permissions, and add
one manifest input record with kind `spell_catalog`, canonical destination,
checksum, and `campaign_config` or `session_config` source provenance.
- Treat the input as optional when no effective path is configured. Do not call
the required-input path resolver with an empty value and do not create a
manifest record. Remove an obsolete canonical `inputs/spell_catalog.json`
without following it when a forced prepare transitions from configured to
absent; refuse to recursively remove a directory or other ambiguous object at
that exact file path.
- Include a configured spell catalog in operator inspection and validation
findings. Omission is not an error unless Stage 1 cross-configuration policy
says the enabled Notarius reference requires it. Reuse the common resolved
stable-input enumeration where practical instead of extending parallel
hand-written lists in several functions.
- Adjust input-slice capacity, deterministic ordering, test fixtures, and any
manifest assumptions affected by the optional sixth stable file. Do not parse
or schema-validate the JSON payload in Narratio; Notarius owns that contract.
**Tests and exit criteria:** Through prepare and operator package behavior, cover
campaign and session source provenance, canonical destination bytes and
checksum, optional omission, missing configured source, replacement after source
change, safe removal when configuration is removed, rejection of an ambiguous
destination object, deterministic manifest ordering, and operator reporting.
Do not duplicate selector-validation cases from Stage 1. Existing sessions with
no spell catalog remain valid and produce no stale manifest entry.
## Stage 3 — Manifest-Authoritative Prepared Input Resolution
**Read first:** `docs/internal/artifacts.md`, `docs/internal/manifest.md`,
`docs/internal/stage-analyze.md`, `internal/artifacts/artifact_resolver.go`,
`internal/artifacts/resolve.go`, `internal/artifacts/checksum.go`, their tests,
and the prepared stable-input resolution path in `internal/stage/analyze.go` and
`internal/stage/analyze_test.go`.
**Depends on:** Stage 2.
**Goal:** Give analyze and extract one integrity-checked resolver for prepared
stable sources so neither stage trusts incidental files or reconstructs its own
source-to-filename table.
**Work:**
- Add an artifacts-owned `PreparedInputIdentity` contract containing source ID,
manifest kind, absolute canonical path, slash-separated path relative to the
session root, SHA-256 checksum, and byte size. Add one resolver that accepts
session paths, the current session manifest, and a stable source ID. Provide a
typed or sentinel absence classification so callers can distinguish no current
manifest record from corrupt or unsafe recorded evidence.
- Derive kind and filename exclusively from the artifact-policy descriptor. The
resolver must require exactly one current manifest input record with the
expected kind and canonical path; resolve/rebase recorded local paths through
existing session-local path safety helpers; require the result to equal the
canonical file below `inputs/`; reject escapes, symlinks, non-regular files,
empty files, missing checksums, duplicate records, and checksum mismatches; and
calculate size without loading the complete file into memory. Do not fall back
to the configured campaign/session path or accept canonical file presence
without manifest evidence. Zero matching manifest records is the typed absent
case; once a record exists, a missing or invalid file is an integrity error,
not optional absence.
- Return owner-neutral errors from `internal/artifacts`. At stage boundaries,
wrap unavailable or stale prepared inputs with the source ID and actionable
`narratio run-stage prepare <session_id> --force` guidance. Do not include file
contents.
- Replace analyze's private prepared-source filename switch and filesystem-only
resolver with the shared artifact resolver. Preserve required-versus-optional
Scriptorium input behavior: a typed absent optional source is omitted, an
absent required source fails with prepare guidance, and invalid recorded
evidence fails regardless of optionality. Make `narratio.input.spell_catalog`
usable wherever another prepared Scriptorium source is accepted.
**Tests and exit criteria:** Artifact-package tests own valid resolution and the
missing-record, duplicate-record, wrong-kind/path, traversal/rebase, symlink,
non-regular, empty, missing-checksum, and checksum-mismatch boundaries. Analyze
tests need only prove required/optional stage behavior and successful use of the
shared source, including spell catalog; do not repeat the artifact resolver's
full matrix. Remove the old filename/path resolver after its final caller moves.
## Stage 4 — Notarius Adapter Reference Arguments
**Read first:** `docs/internal/adapters.md`, `docs/integrations/notarius.md`,
`internal/adapters/notarius/runner.go`, `fake.go`, `subprocess.go`, and focused
adapter tests. Re-read the Notarius v0.6.0 subprocess and CLI reference-selector
contracts from the tagged sibling checkout when available.
**Depends on:** Stage 1.
**Goal:** Extend the transport-neutral Notarius request and production adapter
to emit safe, exact, repeatable v0.6 `--reference` arguments without changing
receipt or bundle ingestion.
**Work:**
- Add a transport-neutral reference binding containing normalized selector and
absolute path, and add an ordered slice of those bindings to `RunRequest`.
Keep source IDs and manifest identities out of the adapter contract; those are
stage policy.
- Validate each adapter binding before process launch: normalize/validate the
selector through the shared Stage 1 contract, require a non-empty absolute
path, reject duplicate normalized selectors, and avoid mutating the caller's
slice. Do not open or parse the reference file in the adapter.
- Build arguments as repeated pairs `--reference`,
`<normalized-selector>=<absolute-path>` after `--output-dir` and before
`--json`. Preserve one argument for the combined selector/path value so spaces,
additional `=` characters within the path portion, and platform separators do
not involve shell interpretation. The request order is authoritative; Stage 5
will supply sorted bindings.
- Preserve current executable, environment, timeout, cancellation, diagnostic,
receipt-v2, bounded-read, confinement, and bundle-discovery behavior. Do not
add `--without-reference`, generated reference arguments, version probing, or
configuration preflight.
- Update the fake only as required to retain and expose the extended request.
**Tests and exit criteria:** Adapter tests own exact argv with zero and multiple
references, position before `--json`, spaces and `=` in paths, selector
normalization, duplicate/invalid selector rejection, relative/empty path
rejection, and no subprocess start after request-validation failure. Existing
receipt-v2 and bundle fixture tests must remain unchanged in meaning and pass.
Do not assert stage-level source sorting here beyond preserving the request
order.
## Stage 5 — Extract Reference Identity, Invocation, And Resume
**Read first:** `docs/internal/stage-extract.md`,
`docs/integrations/notarius.md`, `docs/internal/manifest.md`,
`internal/stage/extract.go`, `internal/stage/extract_resume.go`, their focused
tests, the Stage 3 prepared-input identity contract, and the Stage 4 Notarius
request contract.
**Depends on:** Stages 3 and 4.
**Goal:** Make configured references part of the actual extraction invocation
and durable reuse contract, using one resolution path for initial execution and
resume validation.
**Work:**
- Add one extract-owned reference-resolution helper used by both `Run` and
`ValidateResume`. Iterate normalized config bindings in lexical selector
order, resolve each source through the Stage 3 manifest-authoritative resolver,
and produce both adapter bindings and immutable reference identities. Resolve
every reference before creating run-local receipt, log, output, or promotion
directories and before invoking the adapter.
- Define the fingerprint/metadata identity as normalized selector, source ID,
canonical session-relative slash path, SHA-256 checksum, and byte size. Do not
include contents or original campaign/session absolute paths. Pass only
selector and absolute prepared path to the adapter.
- Extend the extraction fingerprint document with the sorted reference
identities. Keep all existing binary, config path, pipeline, timeout, working
directory, trimmed-transcript identity, and required-output identities. The
result must be independent of YAML map iteration order and must change for a
selector, source, relative path, checksum, or size change.
- Persist `reference_count` and a bounded deterministic `references` metadata
list on successful extraction. Each entry contains exactly `selector`,
`source_id`, `path`, `checksum`, and `size_bytes`. Empty bindings produce count
zero and an empty list. Do not duplicate Notarius reference payloads or
downstream error messages.
- Make resume recompute current reference identities through the same helper
before comparing the configuration fingerprint. A valid changed prepared
input yields a fingerprint mismatch and a non-resumable result so extraction
reruns. Missing, unsafe, or checksum-inconsistent current input is an error
with prepare-force guidance because immediately rerunning extract cannot
succeed. Do not silently reuse the old bundle.
- Preserve explicit disabled-stage skip without resolving references. Preserve
required lane selection, immutable promotion, receipt identity, and bundle
evidence behavior.
**Tests and exit criteria:** Stage tests own sorted request construction for all
four D&D bindings, zero bindings, failure before adapter invocation for an
unavailable source, content-free metadata, and fingerprint changes for each
identity field while remaining stable across map order. Resume tests must prove
reuse with unchanged references, non-reuse after a valid prepared-reference
change, hard failure for missing or checksum-invalid current evidence, and no
reference resolution when disabled. Use the fake adapter; do not duplicate exact
subprocess argv cases from Stage 4.
## Stage 6 — Assembled Lifecycle And Invalidation Coverage
**Read first:** `docs/internal/overview.md`, `docs/internal/manifest.md`,
`docs/internal/stage-extract.md`, `docs/internal/stage-prepare.md`,
`internal/app/runner.go`,
`internal/app/extract_lifecycle_test.go`, and representative full pipeline and
stage fixtures. Inspect existing downstream invalidation tests before adding
new cases.
**Depends on:** Stage 5.
**Goal:** Prove at the application boundary that prepared campaign context
reaches Notarius and that reference changes cannot leave extraction or later
analysis falsely current.
**Work:**
- Extend the smallest existing assembled runner fixture to execute prepare and
extract with party, players, glossary, and spell catalog bindings. Assert that
the fake Notarius request receives the four canonical prepared absolute paths,
not the original campaign/session source paths, and that the successful
manifest records bounded reference identity.
- Add one lifecycle regression covering a valid reference-content change:
rerun/force prepare so the manifest and prepared checksum change, then verify
extract resume is rejected, Notarius runs again, and succeeded canonical
downstream stages are invalidated according to the existing stage-order
policy. Assert outcomes, not private runner call choreography.
- Add one representative session override case to prove the overridden prepared
bytes/checksum reach extraction. Do not repeat all four configuration merge
cases or artifact-integrity failures already owned by earlier stages.
- Confirm an empty reference map preserves the pre-v0.6 invocation behavior and
that all ten configured D&D lanes remain registered as the same
`narratio.extraction.<key>` sources available to analyze.
- Fix production integration defects exposed by these assembled tests without
broadening the feature or adding a DAG, generic reference workflow, or direct
Notarius payload parsing.
**Tests and exit criteria:** The application-level tests must be deterministic,
offline, and fake only the external Notarius boundary. They must credibly fail
if Narratio passes original paths, omits one configured reference, reuses stale
extraction, or loses a configured lane, while remaining insensitive to private
helper structure and exact non-contractual diagnostics. Earlier focused suites
and the repository baseline remain green.
## Stage 7 — Canonical Documentation And Maintained Examples
**Read first:** `docs/policy/documentation.md`, `docs/config.md`,
`docs/operations.md`, `docs/troubleshooting.md`,
`docs/integrations/notarius.md`, `docs/internal/overview.md`,
`docs/internal/adapters.md`, `docs/internal/artifacts.md`,
`docs/internal/stage-prepare.md`, `docs/internal/stage-extract.md`,
`docs/internal/stage-analyze.md`, `examples/README.md`, and all maintained
pipeline, campaign, and session examples affected by the new fields.
**Depends on:** Stage 6.
**Goal:** Move the completed behavior from roadmap-only future state into its
canonical current-behavior owners and provide valid copyable D&D examples
without duplicating volatile Notarius contracts.
**Work:**
- Update `docs/config.md` with `pipeline.notarius.references`, its selector-to-
source shape, normalization/validation rules, required-by-presence behavior,
supported stable source IDs, and campaign/session `spell_catalog_file`
precedence and optionality. Keep complete copyable YAML in `examples/`.
- Update `docs/integrations/notarius.md` to the v0.6.0 baseline and exact
repeatable-reference invocation boundary. Explain absolute CLI paths,
precedence over configured external paths, the four maintained external D&D
slots, the generated-handoff exclusion, and unchanged receipt-v2/ten-lane
output compatibility. Link to Notarius's canonical v0.6 CLI and D&D consumer
docs instead of copying its target/module matrix.
- Update operations and troubleshooting with prepared input location,
fingerprint/rerun consequences, operator inspection, missing-reference
diagnosis, and Notarius undeclared-slot/generated-collision failures. Update
internal component documents only with implemented ownership and flow; do not
duplicate configuration field definitions there.
- Add a valid, secret-free sample spell catalog following Notarius v0.6's
published overlay schema, add `spell_catalog_file` to the sample campaign, and
configure all four external reference bindings in the complete annotated D&D
pipeline. Add the same bindings to other Notarius-enabled maintained examples
only when their selected pipeline declares them; do not add a Notarius section
to examples that intentionally omit extraction.
- Ensure the maintained command snippets place repeated `--reference` arguments
before `--json`, use `party` rather than `roster`, and never show generated
handoffs on the CLI. Remove stale v0.5 compatibility wording where it refers to
the supported invocation baseline.
**Tests and exit criteria:** Run documentation-link checks and the example
loader explicitly. Verify every changed example is accepted by strict config
validation, contains no credentials or private infrastructure values, and has
one canonical owner for each volatile fact. Search current-behavior docs and
examples for stale v0.5 invocation wording, deprecated `roster` emission, and
generated D&D CLI handoff examples. Do not mark the roadmap itself implemented;
its status remains target-state context until the implementation sprint is
reviewed and closed.
## Stage 8 — Compatibility And Quality Closure
**Read first:** The feature roadmap, every completed stage diff, the final
current-behavior docs, `.woodpecker/verify.yml`, `.woodpecker/release.yml`, and
`.woodpecker/shuffle.yml`. Re-read the tagged Notarius v0.6.0
`docs/consumers/dnd-pipeline.md`, `docs/cli.md`, and linked spell-catalog overlay
contract when the sibling checkout is available.
**Depends on:** Stage 7.
**Goal:** Verify the delivered code matches the accepted boundary, remains
compatible with all ten default D&D artifacts, and is ready for review without
dead compatibility paths or duplicated policy.
**Work:**
- Audit the final diff against every target-state and out-of-scope statement in
the feature roadmap. Confirm only four external prepared sources are exposed,
custom selectors remain possible, every configured binding is required, and
no Notarius pipeline topology or generated-handoff logic moved into Narratio.
- Trace initial extract and resume paths to confirm both use the same prepared
identity and reference resolution, the adapter is the sole argv builder, and
artifact policy is the sole source-to-kind/filename vocabulary. Remove dead
helpers, redundant switches, stale fixtures, and low-value duplicate tests
found during this review.
- Confirm the complete D&D example still declares and validates the exact ten
output lanes and that analyze can consume those sources after reference-
enabled extraction. Confirm empty-reference custom pipelines remain supported.
- If a local Notarius v0.6.0 binary and its required offline/test configuration
are already available, perform a non-credentialed smoke invocation with all
four reference flags and record the result in the implementation handoff. Do
not download tools, contact paid providers, add a default test dependency, or
block completion solely because this supplementary environment is absent.
- Run the repository baseline plus the scheduled shuffled suite and release
cross-build commands:
```sh
go test ./...
go test -race ./...
go test -race -shuffle=on -count=3 ./...
go vet ./...
go build ./...
go test ./internal/doccheck
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
narratio_cross_dir="$(mktemp -d)"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-linux-amd64" ./cmd/narratio
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-darwin-amd64" ./cmd/narratio
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-windows-amd64.exe" ./cmd/narratio
```
Cross-builds are compilation evidence only; do not claim native macOS or
Windows runtime validation.
**Tests and exit criteria:** Every required command passes, `git diff --check`
is clean, the worktree contains no unintended generated test artifacts, and the
implementation is traceably complete against the roadmap. Summarize any
unavailable supplementary smoke evidence without treating it as a product
question or silently weakening the default suite.
## Open Questions
None. The feature roadmap and governing decisions above are sufficient to
implement the plan without additional product or architecture choices.

View File

@@ -1,274 +0,0 @@
# Notarius v0.6 CLI Reference Integration
## Status
Accepted target state. Delivery sequencing and implementation status are owned
by [implementation.md](implementation.md).
## Purpose
Upgrade Narratio's extraction boundary to the Notarius v0.6.0 subprocess
contract and supply session reference documents explicitly with repeatable
`--reference selector=path` arguments.
The maintained D&D integration must make the prepared party roster, player
context, glossary, and optional spell catalog available to every compatible
Notarius target. Notarius must continue to own pipeline topology, reference-slot
compatibility, generated artifact handoffs, prompts, and D&D schemas. Narratio
owns selection and preparation of its external reference files, exact CLI
invocation, provenance, and extraction reuse correctness.
## Current State And Gap
Narratio currently invokes Notarius as:
```text
notarius run <pipeline_id> --config <config_path> --input <transcript> --output-dir <staging_dir> --json
```
The `prepare` stage already materializes campaign/session party, players, and
glossary files under the session `inputs/` directory, but `extract` does not
pass them to Notarius. Narratio also has no stable spell-catalog input. As a
result, a Notarius deployment must duplicate these paths in its own
configuration, cannot reliably receive session overrides, and may extract
without the same campaign context supplied to Narratio's analysis stage.
Notarius v0.6.0 makes an unqualified CLI selector pipeline-scoped. For example,
`--reference party=/absolute/path/party.yml` supplies the file to every
selected target that declares `party`. Scoped selectors remain available for
exceptional overrides. CLI paths are resolved from the Notarius process working
directory, so subprocess callers are expected to provide absolute paths.
The v0.6.0 receipt, index, warning, diagnostic, and ten-lane D&D artifact
contracts remain compatible with Narratio's current v0.5 integration. This
feature changes the invocation and input-provenance contract rather than the
accepted output inventory.
## User Outcome
With the maintained complete D&D configuration, an operator can declare the
campaign reference sources once in Narratio. For each extraction Narratio will:
1. materialize the effective campaign/session files during `prepare`;
2. resolve those prepared files by stable Narratio source ID;
3. pass absolute paths for `party`, `players`, `glossary`, and, when configured,
`spell_catalog` to Notarius through repeatable CLI arguments;
4. fail before launching Notarius when a configured reference is unavailable;
5. rerun extraction when a selector, source binding, or reference file changes;
and
6. retain bounded reference identities and checksums for diagnosis and
provenance without copying reference contents into manifest metadata.
Session-level stable-input overrides must flow through the same mechanism. A
custom Notarius pipeline may bind different external slots without requiring a
Narratio code change.
## Chosen Architecture
### Explicit Reference Bindings
Extend `pipeline.notarius` with an explicit map from a Notarius CLI selector to
a prepared Narratio input source:
```yaml
notarius:
enabled: true
binary: notarius
config_path: /usr/local/etc/notarius/config.yml
pipeline_id: dnd-session
working_directory: /usr/local/etc/notarius
references:
party: narratio.input.party
players: narratio.input.players
glossary: narratio.input.glossary
spell_catalog: narratio.input.spell_catalog
outputs:
# Existing required lane contracts remain unchanged.
```
Each configured binding is required. An operator who does not maintain an
optional Notarius reference, such as a spell catalog, omits that binding. This
keeps missing-input behavior explicit and avoids a second required/optional
policy inside each entry.
The maintained complete D&D example will show all four external reference
slots. The three existing campaign context bindings use the canonical `party`,
`players`, and `glossary` spellings. Narratio will not emit the deprecated
`roster` alias.
The binding is deliberately source-based rather than path-based. Pipeline
configuration should not reconstruct session workspace paths or bypass
`prepare`; it names the stable input whose effective campaign/session value is
already owned by Narratio. The map also avoids hard-coded behavior keyed to the
literal `dnd-session` pipeline ID, preserving custom-pipeline support.
Narratio accepts the selector forms published by Notarius v0.6.0:
- `slot`;
- `chunk.slot`;
- `lane.slot`; and
- `lane.extract.slot`, `lane.merge.slot`, or `lane.normalize.slot`.
Configuration validation will reject empty or structurally invalid selectors,
selectors containing `=`, unsupported source IDs, and duplicate YAML keys.
Notarius remains authoritative for whether a selected target actually declares
the slot and whether a file satisfies that slot's media type and size contract.
Narratio will not duplicate the Notarius module registry.
### Stable Reference Inputs
Continue to use the existing prepared sources and canonical files:
| Narratio source | Prepared file | Notarius slot |
| --- | --- | --- |
| `narratio.input.party` | `inputs/party.yml` | `party` |
| `narratio.input.players` | `inputs/players.yml` | `players` |
| `narratio.input.glossary` | `inputs/glossary.yml` | `glossary` |
| `narratio.input.spell_catalog` | `inputs/spell_catalog.json` | `spell_catalog` |
Add optional `spell_catalog_file` fields to campaign and session inputs, with
the existing campaign-default/session-override resolution behavior. When
provided, `prepare` copies it into the session input area and records its
origin and checksum consistently with the other stable inputs. The prepared
filename remains JSON so Notarius can apply its published spell-catalog media
contract.
The new source must be added everywhere stable inputs are enumerated: strict
configuration decoding and merging, validation, prepare materialization,
artifact policy/source descriptions, operator inspection, manifest input
records, examples, and canonical documentation. It remains optional at the
campaign level; a configured Notarius binding makes it mandatory for that
extraction.
Extract and analyze should use one shared prepared-input source resolver rather
than maintain separate source-to-filename tables. The resolver must return an
absolute, regular, non-empty file beneath the current session workspace and
produce actionable `prepare --force` guidance when a configured source is
missing. It must not fall back to the original campaign path after preparation.
### Adapter Request And CLI Construction
Extend the transport-neutral Notarius run request with an ordered collection of
resolved reference bindings. Each binding contains only its selector and
absolute prepared-file path. The extraction stage resolves source IDs and file
identity; the subprocess adapter validates and serializes the request.
The production command becomes:
```text
notarius run <pipeline_id>
--config <config_path>
--input <trimmed_json>
--output-dir <staging_dir>
--reference party=<absolute_prepared_party_path>
--reference players=<absolute_prepared_players_path>
--reference glossary=<absolute_prepared_glossary_path>
--reference spell_catalog=<absolute_prepared_spell_catalog_path>
--json
```
Only configured bindings are emitted. Selectors are sorted before request
construction so argument order, tests, logs, and fingerprints are deterministic.
Arguments are passed directly to the subprocess without shell interpretation;
paths containing spaces or platform-specific separators remain one argument.
CLI bindings intentionally override matching external paths in the deployed
Notarius configuration. Narratio must not pass `--without-reference` and must
not synthesize CLI bindings for `location_registry`, `item_registry`,
`npc_registry`, `scene_descriptions`, `combat_turns`, or `npc_occurrences`.
Those are generated same-run artifact handoffs in the complete D&D pipeline and
remain entirely under Notarius configuration and execution control. A custom
configuration that collides an external CLI binding with a generated handoff is
expected to fail with Notarius's normal resolution error.
### Fingerprints, Resume, And Provenance
Reference identity is part of the extraction input contract. The extraction
fingerprint and resume validator must include, in deterministic selector order:
- the selector;
- the configured Narratio source ID;
- the resolved prepared path identity; and
- the prepared file's content checksum and size.
This is required even though Notarius generates a prompt session ID from the
input module and transcript bytes: Notarius intentionally does not include
references in that identifier. Narratio must therefore prevent an old
extraction from being reused after a roster, player list, glossary, spell
catalog, selector, or source mapping changes.
A changed reference makes the prior `extract` result non-reusable and follows
Narratio's normal downstream invalidation rules. A failed reference-resolution
or checksum check also prevents reuse; it must not silently accept the prior
bundle.
Successful extract metadata should record a bounded, deterministic list of
selector, source ID, workspace-relative path, checksum, and size. It must not
record reference contents, original absolute operator paths, or values from the
files. Existing receipt and bundle provenance behavior remains unchanged.
### Error And Compatibility Behavior
Narratio's documented minimum supported Notarius version becomes v0.6.0 for an
enabled reference binding. Compatibility remains contract-based rather than
dependent on parsing `notarius --version`: an older or incompatible executable
will fail at the CLI boundary with captured diagnostics.
Errors must identify the responsible selector and Narratio source without
including file contents. Configuration errors are reported before pipeline
execution. Missing, empty, non-regular, unsafe, or unreadable prepared files
fail extraction before the Notarius subprocess starts. Notarius continues to
report undeclared slots, media incompatibility, size limits, required-slot
failures, and generated-handoff collisions.
When Notarius is disabled, extraction retains its current explicit skip
behavior and does not resolve reference inputs. Receipt v2 ingestion, bundle
confinement, ten-lane selection, and downstream artifact source IDs are not
otherwise changed by this feature.
## Target End State
Narratio and Notarius have a clear orchestration boundary:
- `prepare` owns the effective, immutable session copies of external campaign
context;
- `extract` maps configured stable source IDs to Notarius v0.6 CLI selectors,
supplies absolute file paths, and owns reuse/provenance policy;
- the Notarius adapter owns exact subprocess serialization and supported result
decoding;
- Notarius owns slot compatibility, reference precedence within its pipeline,
generated artifact handoffs, and output schemas; and
- `analyze` consumes the resulting ten structured lane artifacts exactly as it
does today.
The maintained complete D&D workflow passes party, players, glossary, and spell
catalog context from the same prepared session inputs used elsewhere in
Narratio. Updating any of those documents deterministically causes fresh
extraction, and operators can diagnose the effective bindings without exposing
file contents.
## Out Of Scope
- Reproducing Notarius pipeline, lane, binding, or media-type validation in
Narratio.
- Passing or overriding Notarius generated artifact handoffs.
- Adding `--without-reference`, Notarius resume/recompute controls, lane
selection, model selection, profile selection, or session-ID overrides.
- Changing the ten accepted D&D lane contracts or the Scriptorium analysis
design.
- Reading reference payloads into Narratio manifests or logs.
- Automatically running `notarius config validate` for every session.
## Settled Policy Choices
The implementation must preserve these choices unless implementation evidence
shows a contract conflict:
- explicit selector-to-source mappings are preferred over pipeline-ID-specific
defaults;
- every configured mapping is required;
- `spell_catalog_file` is optional until a mapping requests its prepared
source;
- the complete D&D example demonstrates all four external references; and
- Notarius v0.6.0 is the minimum supported CLI contract for reference-enabled
extraction.

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:
@@ -321,6 +355,103 @@ 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

@@ -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

@@ -256,7 +256,10 @@ func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDo
if after.Stages["extract"].Status != manifest.StatusSucceeded {
t.Fatalf("extract status = %#v", after.Stages["extract"])
}
for _, name := range []string{"render", "analyze", "publish"} {
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])
}

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -761,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
}
@@ -983,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
@@ -1030,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

@@ -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

@@ -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,92 +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 {
identity, err := artifacts.ResolvePreparedInput(execution.Paths, execution.Manifest, descriptor.Source.ID)
if err == nil {
return analyzeInputFound(identity.Path, nil)
}
if errors.Is(err, artifacts.ErrPreparedInputAbsent) {
if inputCfg.Required {
return analyzeInputFailure(fmt.Errorf(
"required prepared input source %q is unavailable; run narratio run-stage prepare %s --force",
descriptor.Source.ID,
execution.SessionID,
))
}
return analyzeInputMissing()
}
return analyzeInputFailure(fmt.Errorf(
"prepared input source %q is invalid; run narratio run-stage prepare %s --force: %w",
descriptor.Source.ID,
execution.SessionID,
err,
))
}
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) == "" {
@@ -738,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
}

View File

@@ -0,0 +1,45 @@
package stage
import (
"os"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func setCurrentAnalyzeEvidence(t *testing.T, m *manifest.Manifest, key, relativePath, absolutePath string) {
t.Helper()
checksum, err := artifacts.SHA256File(absolutePath)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(absolutePath)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 5, 16, 1, 2, 3, 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{}
}
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: checksum,
},
OutputSize: info.Size(), ProducerRunID: "run-1", UpdatedAt: now,
}
}

View File

@@ -0,0 +1,217 @@
package stage
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"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"
)
func TestAnalyzeAdapterFailureProjectsCompletedAndFailedArtifacts(t *testing.T) {
for _, failAt := range []int{1, 2, 3} {
t.Run(map[int]string{1: "first", 2: "middle", 3: "last"}[failAt], func(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
env.Config.Pipeline.Scriptorium.Artifacts = independentAnalyzeArtifacts()
cause := errors.New("injected adapter failure")
runner := &indexedAnalyzeFailureRunner{FailAt: failAt, Err: cause}
env.Scriptorium = runner
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if !errors.Is(err, cause) {
t.Fatalf("Run() error = %v, want injected cause", err)
}
if result == nil || result.AnalyzeState == nil {
t.Fatal("failure result has no analyze projection")
}
keys := []string{"alpha", "beta", "gamma"}
for index, key := range keys {
record, exists := result.AnalyzeState.Invocation[key]
switch {
case index < failAt-1:
if !exists || record.Status != manifest.AnalyzeArtifactCurrent || record.Output == nil {
t.Fatalf("completed %s = %#v", key, record)
}
case index == failAt-1:
if !exists || record.Status != manifest.AnalyzeArtifactFailed || record.Output != nil || record.Error == "" {
t.Fatalf("failed %s = %#v", key, record)
}
default:
if exists {
t.Fatalf("unattempted %s appeared in invocation: %#v", key, record)
}
}
}
})
}
}
func TestAnalyzeMiddleFailureStalesDependentsAndPreservesUnrelatedCurrent(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
env.Config.Pipeline.Scriptorium.Artifacts = chainedAnalyzeArtifacts()
first, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
installAnalyzeProjection(m, first.AnalyzeState)
unrelated := m.Stages["analyze"].AnalyzeArtifacts["unrelated"]
stale := m.Stages["analyze"].AnalyzeArtifacts["alpha"]
stale.Status = manifest.AnalyzeArtifactStale
stale.Output = nil
stale.OutputSize = 0
m.Stages["analyze"].AnalyzeArtifacts["alpha"] = stale
env.SelectedArtifactKeys = []string{"gamma"}
cause := errors.New("middle artifact failed")
env.Scriptorium = &indexedAnalyzeFailureRunner{FailAt: 2, Err: cause}
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if !errors.Is(err, cause) {
t.Fatalf("Run() error = %v", err)
}
if result.AnalyzeState.Session["alpha"].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("completed prerequisite = %#v", result.AnalyzeState.Session["alpha"])
}
if result.AnalyzeState.Session["beta"].Status != manifest.AnalyzeArtifactFailed {
t.Fatalf("failed middle = %#v", result.AnalyzeState.Session["beta"])
}
if target := result.AnalyzeState.Session["gamma"]; target.Status != manifest.AnalyzeArtifactStale || target.Output != nil {
t.Fatalf("dependent target = %#v, want stale", target)
}
if got := result.AnalyzeState.Session["unrelated"]; got.Status != manifest.AnalyzeArtifactCurrent ||
got.Fingerprint != unrelated.Fingerprint || got.Output == nil || got.Output.Checksum != unrelated.Output.Checksum {
t.Fatalf("unrelated record = %#v, want preserved %#v", got, unrelated)
}
if _, attempted := result.AnalyzeState.Invocation["gamma"]; attempted {
t.Fatal("dependent target was reported as attempted")
}
if old := m.Stages["analyze"].AnalyzeArtifacts["beta"]; old.Output == nil {
t.Fatal("fixture did not retain old canonical evidence before failure")
}
if failed := result.AnalyzeState.Session["beta"]; failed.Output != nil {
t.Fatal("old canonical bytes made failed artifact current")
}
}
func TestAnalyzeInvalidOutputReturnsFailedProjection(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
m.Campaign = env.Config.Session.Campaign
m.RunID = "run-invalid-output"
env.Scriptorium = invalidAnalyzeOutputRunner{}
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil {
t.Fatal("Run() error = nil")
}
record := result.AnalyzeState.Session["session_recap"]
if record.Status != manifest.AnalyzeArtifactFailed || record.Output != nil {
t.Fatalf("failed record = %#v", record)
}
}
func TestAnalyzeMaterializationFailureReturnsFailedProjection(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
m.Campaign = env.Config.Session.Campaign
m.RunID = "run-materialize-failure"
cause := errors.New("injected canonical write failure")
canonical := filepath.Join(paths.ArtifactsDir, "session_recap.md")
env.ArtifactStore = &failingAnalyzeArtifactStore{Store: env.ArtifactStore, FailPath: canonical, Err: cause}
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if !errors.Is(err, cause) {
t.Fatalf("Run() error = %v, want write cause", err)
}
record := result.AnalyzeState.Session["session_recap"]
if record.Status != manifest.AnalyzeArtifactFailed || record.Output != nil {
t.Fatalf("failed record = %#v", record)
}
if _, statErr := os.Stat(canonical); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("canonical output stat error = %v, want absent", statErr)
}
}
type indexedAnalyzeFailureRunner struct {
Calls int
FailAt int
Err error
}
func (r *indexedAnalyzeFailureRunner) RunArtifact(_ context.Context, req scriptorium.RunArtifactRequest) (scriptorium.ArtifactResult, error) {
r.Calls++
if r.Calls == r.FailAt {
return scriptorium.ArtifactResult{}, r.Err
}
writeAnalyzeFileNoTest(req.OutputPath, "generated "+req.PromptID+"\n")
return scriptorium.ArtifactResult{
OutputPath: req.OutputPath, CommandMode: scriptorium.CommandModeRun,
PromptID: req.PromptID, ProfileID: req.ProfileID,
}, nil
}
func (r *indexedAnalyzeFailureRunner) RenderArtifact(context.Context, scriptorium.RenderArtifactRequest) (scriptorium.ArtifactResult, error) {
return scriptorium.ArtifactResult{}, errors.New("unexpected render")
}
type invalidAnalyzeOutputRunner struct{}
func (invalidAnalyzeOutputRunner) RunArtifact(_ context.Context, req scriptorium.RunArtifactRequest) (scriptorium.ArtifactResult, error) {
if err := os.MkdirAll(filepath.Dir(req.OutputPath), 0o755); err != nil {
return scriptorium.ArtifactResult{}, err
}
target := req.OutputPath + ".target"
if err := os.WriteFile(target, []byte("unsafe\n"), 0o644); err != nil {
return scriptorium.ArtifactResult{}, err
}
if err := os.Symlink(target, req.OutputPath); err != nil {
return scriptorium.ArtifactResult{}, err
}
return scriptorium.ArtifactResult{OutputPath: req.OutputPath, CommandMode: scriptorium.CommandModeRun}, nil
}
func (invalidAnalyzeOutputRunner) RenderArtifact(context.Context, scriptorium.RenderArtifactRequest) (scriptorium.ArtifactResult, error) {
return scriptorium.ArtifactResult{}, errors.New("unexpected render")
}
type failingAnalyzeArtifactStore struct {
artifacts.Store
FailPath string
Err error
}
func (s *failingAnalyzeArtifactStore) WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
if filepath.Clean(path) == filepath.Clean(s.FailPath) {
return s.Err
}
return s.Store.WriteFileAtomic(path, data, perm)
}
func independentAnalyzeArtifacts() map[string]config.ScriptoriumArtifactConfig {
return map[string]config.ScriptoriumArtifactConfig{
"alpha": {Enabled: true, PromptID: "alpha", OutputPath: "artifacts/alpha.md"},
"beta": {Enabled: true, PromptID: "beta", OutputPath: "artifacts/beta.md"},
"gamma": {Enabled: true, PromptID: "gamma", OutputPath: "artifacts/gamma.md"},
}
}
func chainedAnalyzeArtifacts() map[string]config.ScriptoriumArtifactConfig {
return map[string]config.ScriptoriumArtifactConfig{
"alpha": {Enabled: true, PromptID: "alpha", OutputPath: "artifacts/alpha.md"},
"beta": {
Enabled: true, DependsOn: []string{"alpha"}, PromptID: "beta", OutputPath: "artifacts/beta.md",
Inputs: map[string]config.ScriptoriumInputConfig{"alpha": {Source: "narratio.artifact.alpha", Required: true}},
},
"gamma": {
Enabled: true, DependsOn: []string{"beta"}, PromptID: "gamma", OutputPath: "artifacts/gamma.md",
Inputs: map[string]config.ScriptoriumInputConfig{"beta": {Source: "narratio.artifact.beta", Required: true}},
},
"unrelated": {Enabled: true, PromptID: "unrelated", OutputPath: "artifacts/unrelated.md"},
}
}

View File

@@ -0,0 +1,322 @@
package stage
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
type analyzeFingerprintCandidate struct {
Key string
Fingerprint string
Inputs resolvedAnalyzeInputs
DependencyOutputs []analyzeDependencyOutputIdentity
Err error
}
type analyzeFingerprintSet struct {
Ordered []analyzeFingerprintCandidate
}
func (s analyzeFingerprintSet) Lookup(key string) (analyzeFingerprintCandidate, bool) {
for _, candidate := range s.Ordered {
if candidate.Key == key {
return candidate, true
}
}
return analyzeFingerprintCandidate{}, false
}
type analyzeDependencyOutputIdentity struct {
Key string
SourceID string
LogicalID string
Contract analyzeInputContract
Checksum string
Size int64
}
// analyzeFingerprintPayload is the canonical serialization contract used only
// as SHA-256 input. It contains slices and fixed-field structs, never maps.
type analyzeFingerprintPayload struct {
Version int `json:"version"`
Artifact analyzeFingerprintArtifact `json:"artifact"`
Inputs []analyzeFingerprintInput `json:"inputs"`
DependencyOutputs []analyzeFingerprintDependencyOutput `json:"dependency_outputs"`
Variables []analyzeFingerprintVariable `json:"variables"`
}
type analyzeFingerprintArtifact struct {
Key string `json:"key"`
PromptID string `json:"prompt_id"`
ProfileID string `json:"profile_id"`
EffectiveRenderDebug bool `json:"effective_render_debug"`
OutputIdentity string `json:"output_identity"`
Dependencies []string `json:"dependencies"`
}
type analyzeFingerprintInput struct {
Name string `json:"name"`
SourceID string `json:"source_id"`
Required bool `json:"required"`
Present bool `json:"present"`
LogicalID string `json:"logical_id"`
Contract analyzeFingerprintContract `json:"contract"`
Checksum string `json:"checksum"`
Size int64 `json:"size"`
}
type analyzeFingerprintDependencyOutput struct {
Key string `json:"key"`
SourceID string `json:"source_id"`
LogicalID string `json:"logical_id"`
Contract analyzeFingerprintContract `json:"contract"`
Checksum string `json:"checksum"`
Size int64 `json:"size"`
}
type analyzeFingerprintContract struct {
OutputKind string `json:"output_kind"`
ManifestKind string `json:"manifest_kind"`
MediaType string `json:"media_type"`
SchemaID string `json:"schema_id"`
SchemaVersion string `json:"schema_version"`
ModuleKey string `json:"module_key"`
}
type analyzeFingerprintVariable struct {
Name string `json:"name"`
Value string `json:"value"`
}
func computeAnalyzeFingerprints(
scriptoriumCfg *config.ScriptoriumConfig,
execution analyzeExecutionContext,
) (analyzeFingerprintSet, error) {
if scriptoriumCfg == nil {
return analyzeFingerprintSet{}, nil
}
if err := config.ValidateScriptoriumArtifactDependencies(scriptoriumCfg.Artifacts); err != nil {
return analyzeFingerprintSet{}, err
}
order, err := orderConfiguredAnalyzeArtifacts(scriptoriumCfg.Artifacts)
if err != nil {
return analyzeFingerprintSet{}, err
}
result := analyzeFingerprintSet{Ordered: make([]analyzeFingerprintCandidate, 0, len(order))}
for _, key := range order {
candidate := analyzeFingerprintCandidate{Key: key}
candidate.Fingerprint, candidate.Inputs, candidate.DependencyOutputs, candidate.Err =
computeAnalyzeArtifactFingerprint(key, scriptoriumCfg, execution)
result.Ordered = append(result.Ordered, candidate)
}
return result, nil
}
func computeAnalyzeArtifactFingerprint(
key string,
scriptoriumCfg *config.ScriptoriumConfig,
execution analyzeExecutionContext,
) (string, resolvedAnalyzeInputs, []analyzeDependencyOutputIdentity, error) {
artifactCfg, ok := scriptoriumCfg.Artifacts[key]
if !ok {
return "", resolvedAnalyzeInputs{}, nil, fmt.Errorf("configured artifact %q is not defined", key)
}
inputs, err := resolveAnalyzeInputIdentities(artifactCfg.Inputs, execution)
if err != nil {
return "", resolvedAnalyzeInputs{}, nil, err
}
dependencies := normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn)
dependencyOutputs := make([]analyzeDependencyOutputIdentity, 0, len(dependencies))
for _, dependency := range dependencies {
sourceID := artifacts.ConfiguredArtifactSourceID(dependency)
identity, _, _, resolveErr := resolveAnalyzeInputIdentity(
dependency,
config.ScriptoriumInputConfig{Source: sourceID, Required: true},
execution,
)
if resolveErr != nil {
return "", inputs, dependencyOutputs, fmt.Errorf("resolve dependency %q: %w", dependency, resolveErr)
}
dependencyOutputs = append(dependencyOutputs, analyzeDependencyOutputIdentity{
Key: dependency, SourceID: identity.SourceID, LogicalID: identity.LogicalID,
Contract: identity.Contract, Checksum: identity.Checksum, Size: identity.Size,
})
}
variables, err := effectiveAnalyzeFingerprintVariables(artifactCfg.Vars, execution)
if err != nil {
return "", inputs, dependencyOutputs, err
}
outputIdentity, err := normalizedAnalyzeOutputIdentity(artifactCfg.OutputPath)
if err != nil {
return "", inputs, dependencyOutputs, err
}
payload := analyzeFingerprintPayload{
Version: manifest.AnalyzeFingerprintContractVersion,
Artifact: analyzeFingerprintArtifact{
Key: key, PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID,
EffectiveRenderDebug: resolveRenderDebugEnabled(scriptoriumCfg.RenderDebug, artifactCfg.RenderDebug),
OutputIdentity: outputIdentity, Dependencies: dependencies,
},
Inputs: canonicalAnalyzeFingerprintInputs(inputs.Ordered),
DependencyOutputs: canonicalAnalyzeFingerprintDependencies(dependencyOutputs),
Variables: variables,
}
fingerprint, err := hashAnalyzeFingerprintPayload(payload)
if err != nil {
return "", inputs, dependencyOutputs, fmt.Errorf("serialize analysis fingerprint for %q: %w", key, err)
}
return fingerprint, inputs, dependencyOutputs, nil
}
func hashAnalyzeFingerprintPayload(payload analyzeFingerprintPayload) (string, error) {
serialized, err := json.Marshal(payload)
if err != nil {
return "", err
}
digest := sha256.Sum256(serialized)
return hex.EncodeToString(digest[:]), nil
}
func orderConfiguredAnalyzeArtifacts(
configured map[string]config.ScriptoriumArtifactConfig,
) ([]string, error) {
indegree := make(map[string]int, len(configured))
edges := make(map[string][]string, len(configured))
for key := range configured {
indegree[key] = 0
}
for key, artifactCfg := range configured {
for _, dependency := range normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn) {
if _, ok := configured[dependency]; !ok {
return nil, fmt.Errorf("configured artifact %q depends on unknown artifact %q", key, dependency)
}
edges[dependency] = append(edges[dependency], key)
indegree[key]++
}
}
for key := range edges {
sort.Strings(edges[key])
}
ready := make([]string, 0, len(indegree))
for key, degree := range indegree {
if degree == 0 {
ready = append(ready, key)
}
}
sort.Strings(ready)
order := make([]string, 0, len(indegree))
for len(ready) > 0 {
key := ready[0]
ready = ready[1:]
order = append(order, key)
for _, dependent := range edges[key] {
indegree[dependent]--
if indegree[dependent] == 0 {
ready = append(ready, dependent)
sort.Strings(ready)
}
}
}
if len(order) != len(configured) {
return nil, fmt.Errorf("pipeline.scriptorium.artifacts dependencies must not contain cycles")
}
return order, nil
}
func normalizedAnalyzeDependencyKeys(values []string) []string {
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
seen[trimmed] = struct{}{}
}
}
result := make([]string, 0, len(seen))
for value := range seen {
result = append(result, value)
}
sort.Strings(result)
return result
}
func normalizedAnalyzeOutputIdentity(value string) (string, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "", nil
}
normalized, err := pathsafe.NormalizeRelativeDestination(filepath.ToSlash(trimmed))
if err != nil {
return "", fmt.Errorf("normalize configured output identity %q: %w", value, err)
}
return normalized, nil
}
func effectiveAnalyzeFingerprintVariables(
configured map[string]any,
execution analyzeExecutionContext,
) ([]analyzeFingerprintVariable, error) {
if execution.Env == nil || execution.Env.Config == nil {
return nil, fmt.Errorf("analysis fingerprint requires resolved stage configuration")
}
variables, err := buildScriptoriumVars(configured, execution.Env.Config.Session)
if err != nil {
return nil, err
}
variables = withScriptoriumStickySessionVar(variables, execution.SessionID)
keys := make([]string, 0, len(variables))
for key := range variables {
keys = append(keys, key)
}
sort.Strings(keys)
result := make([]analyzeFingerprintVariable, 0, len(keys))
for _, key := range keys {
result = append(result, analyzeFingerprintVariable{Name: key, Value: variables[key]})
}
return result, nil
}
func canonicalAnalyzeFingerprintInputs(values []analyzeInputIdentity) []analyzeFingerprintInput {
result := make([]analyzeFingerprintInput, 0, len(values))
for _, value := range values {
result = append(result, analyzeFingerprintInput{
Name: value.Name, SourceID: value.SourceID, Required: value.Required,
Present: value.Present, LogicalID: value.LogicalID,
Contract: canonicalAnalyzeFingerprintContract(value.Contract),
Checksum: value.Checksum, Size: value.Size,
})
}
return result
}
func canonicalAnalyzeFingerprintDependencies(
values []analyzeDependencyOutputIdentity,
) []analyzeFingerprintDependencyOutput {
result := make([]analyzeFingerprintDependencyOutput, 0, len(values))
for _, value := range values {
result = append(result, analyzeFingerprintDependencyOutput{
Key: value.Key, SourceID: value.SourceID, LogicalID: value.LogicalID,
Contract: canonicalAnalyzeFingerprintContract(value.Contract),
Checksum: value.Checksum, Size: value.Size,
})
}
return result
}
func canonicalAnalyzeFingerprintContract(value analyzeInputContract) analyzeFingerprintContract {
return analyzeFingerprintContract{
OutputKind: value.OutputKind, ManifestKind: value.ManifestKind,
MediaType: value.MediaType, SchemaID: value.SchemaID,
SchemaVersion: value.SchemaVersion, ModuleKey: value.ModuleKey,
}
}

View File

@@ -0,0 +1,306 @@
package stage
import (
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestAnalyzeFingerprintCanonicalPayloadSensitivity(t *testing.T) {
tests := []struct {
name string
mutate func(*analyzeFingerprintPayload)
}{
{name: "version", mutate: func(p *analyzeFingerprintPayload) { p.Version++ }},
{name: "artifact key", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.Key = "other" }},
{name: "prompt", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.PromptID = "prompt.changed" }},
{name: "profile", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.ProfileID = "profile.changed" }},
{name: "render debug", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.EffectiveRenderDebug = !p.Artifact.EffectiveRenderDebug }},
{name: "output identity", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.OutputIdentity = "artifacts/changed.md" }},
{name: "dependency keys", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.Dependencies[0] = "other" }},
{name: "input name", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Name = "other" }},
{name: "input source", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].SourceID = "narratio.transcript.final" }},
{name: "input required", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Required = !p.Inputs[0].Required }},
{name: "input presence", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Present = !p.Inputs[0].Present }},
{name: "input logical identity", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].LogicalID = "other" }},
{name: "input output kind", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.OutputKind = "other" }},
{name: "input manifest kind", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.ManifestKind = "other" }},
{name: "input media type", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.MediaType = "text/plain" }},
{name: "input schema id", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.SchemaID = "other" }},
{name: "input schema version", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.SchemaVersion = "2" }},
{name: "input module key", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.ModuleKey = "other" }},
{name: "input checksum", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Checksum = strings.Repeat("b", 64) }},
{name: "input size", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Size++ }},
{name: "dependency key", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Key = "other" }},
{name: "dependency source", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].SourceID = "narratio.artifact.other" }},
{name: "dependency logical identity", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].LogicalID = "other" }},
{name: "dependency output kind", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.OutputKind = "other" }},
{name: "dependency manifest kind", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.ManifestKind = "other" }},
{name: "dependency media type", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.MediaType = "text/plain" }},
{name: "dependency schema id", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.SchemaID = "other" }},
{name: "dependency schema version", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.SchemaVersion = "2" }},
{name: "dependency module key", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.ModuleKey = "other" }},
{name: "dependency checksum", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Checksum = strings.Repeat("c", 64) }},
{name: "dependency size", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Size++ }},
{name: "variable name", mutate: func(p *analyzeFingerprintPayload) { p.Variables[0].Name = "other" }},
{name: "variable value", mutate: func(p *analyzeFingerprintPayload) { p.Variables[0].Value = "other" }},
}
baseline, err := hashAnalyzeFingerprintPayload(analyzeFingerprintPayloadFixture())
if err != nil {
t.Fatal(err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload := analyzeFingerprintPayloadFixture()
tt.mutate(&payload)
got, err := hashAnalyzeFingerprintPayload(payload)
if err != nil {
t.Fatal(err)
}
if got == baseline {
t.Fatalf("fingerprint did not change after %s mutation", tt.name)
}
})
}
}
func TestComputeAnalyzeFingerprintsIsDeterministicAcrossIncidentalDifferences(t *testing.T) {
baseline := computeFingerprintFixture(t, fingerprintFixtureOptions{
producerRunID: "run-a", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml",
topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC(), reverseMaps: false,
})
tests := []struct {
name string
options fingerprintFixtureOptions
}{
{name: "workspace relocation", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC()}},
{name: "map insertion", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC(), reverseMaps: true}},
{name: "producer run", options: fingerprintFixtureOptions{producerRunID: "run-b", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC()}},
{name: "timestamp", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(999, 0).UTC()}},
{name: "timeouts", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", topTimeout: "8m", artifactTimeout: "9m", updatedAt: time.Unix(100, 0).UTC()}},
{name: "absolute executable and config paths", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "/srv/b/scriptorium-v2", configPath: "/srv/b/alternate.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC()}},
{name: "relative executable and config paths", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "bin/scriptorium-v2", configPath: "config/alternate.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC()}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := computeFingerprintFixture(t, tt.options); got != baseline {
t.Fatalf("fingerprint = %q, want stable %q", got, baseline)
}
})
}
}
func TestComputeAnalyzeFingerprintsTracksEffectiveConfiguration(t *testing.T) {
baseline := computeFingerprintFixture(t, fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n"})
tests := []struct {
name string
options fingerprintFixtureOptions
}{
{name: "prompt", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", promptID: "prompt.changed"}},
{name: "profile", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", profileID: "profile.changed"}},
{name: "render debug", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", renderDebug: true}},
{name: "output", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", outputPath: "artifacts/changed.md"}},
{name: "effective variable", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", label: "changed"}},
{name: "dependency bytes", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "other\n"}},
{name: "optional input appears", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", includePlayers: true}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := computeFingerprintFixture(t, tt.options); got == baseline {
t.Fatalf("fingerprint did not change for %s", tt.name)
}
})
}
}
func TestComputeAnalyzeFingerprintsOrdersDependenciesAndRejectsInvalidGraphs(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
writeAnalyzeFile(t, filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
env.Config.Pipeline.Scriptorium.Artifacts = map[string]config.ScriptoriumArtifactConfig{
"zeta": {DependsOn: []string{"middle"}},
"alpha": {},
"middle": {DependsOn: []string{"alpha"}},
}
fingerprints, err := computeAnalyzeFingerprints(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
got := make([]string, 0, len(fingerprints.Ordered))
for _, candidate := range fingerprints.Ordered {
got = append(got, candidate.Key)
}
if want := []string{"alpha", "middle", "zeta"}; !reflect.DeepEqual(got, want) {
t.Fatalf("order = %#v, want %#v", got, want)
}
tests := []struct {
name string
configured map[string]config.ScriptoriumArtifactConfig
}{
{name: "unknown", configured: map[string]config.ScriptoriumArtifactConfig{"alpha": {DependsOn: []string{"missing"}}}},
{name: "cycle", configured: map[string]config.ScriptoriumArtifactConfig{
"alpha": {DependsOn: []string{"beta"}}, "beta": {DependsOn: []string{"alpha"}},
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env.Config.Pipeline.Scriptorium.Artifacts = tt.configured
_, err := computeAnalyzeFingerprints(env.Config.Pipeline.Scriptorium, analyzeExecutionContext{})
if err == nil {
t.Fatal("computeAnalyzeFingerprints() error = nil, want invalid graph")
}
})
}
}
func analyzeFingerprintPayloadFixture() analyzeFingerprintPayload {
contract := analyzeFingerprintContract{
OutputKind: "notarius_lane", ManifestKind: "lane", MediaType: "application/json",
SchemaID: "encounters", SchemaVersion: "1", ModuleKey: "encounters",
}
return analyzeFingerprintPayload{
Version: manifest.AnalyzeFingerprintContractVersion,
Artifact: analyzeFingerprintArtifact{
Key: "session_recap", PromptID: "dnd.session_recap", ProfileID: "local",
EffectiveRenderDebug: true, OutputIdentity: "artifacts/session_recap.md",
Dependencies: []string{"source_notes"},
},
Inputs: []analyzeFingerprintInput{{
Name: "encounters", SourceID: "narratio.extraction.encounters", Required: true, Present: true,
LogicalID: "narratio.extraction.encounters", Contract: contract,
Checksum: strings.Repeat("a", 64), Size: 42,
}},
DependencyOutputs: []analyzeFingerprintDependencyOutput{{
Key: "source_notes", SourceID: "narratio.artifact.source_notes", LogicalID: "narratio.artifact.source_notes",
Contract: analyzeFingerprintContract{OutputKind: "scriptorium_artifact", MediaType: "text/markdown", SchemaID: "narratio.source_notes", SchemaVersion: "1"},
Checksum: strings.Repeat("d", 64), Size: 12,
}},
Variables: []analyzeFingerprintVariable{{Name: "label", Value: "recap"}},
}
}
type fingerprintFixtureOptions struct {
producerRunID string
binary string
configPath string
topTimeout string
artifactTimeout string
updatedAt time.Time
reverseMaps bool
dependencyBody string
promptID string
profileID string
renderDebug bool
outputPath string
label string
includePlayers bool
}
func computeFingerprintFixture(t *testing.T, options fingerprintFixtureOptions) string {
t.Helper()
env, m, _ := setupAnalyzeEnv(t)
if options.producerRunID == "" {
options.producerRunID = "run-a"
}
if options.dependencyBody == "" {
options.dependencyBody = "notes\n"
}
if options.promptID == "" {
options.promptID = "dnd.session_recap"
}
if options.profileID == "" {
options.profileID = "local-quality"
}
if options.outputPath == "" {
options.outputPath = "artifacts/session_recap.md"
}
if options.label == "" {
options.label = "recap"
}
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
dependencyPath := filepath.Join(paths.ArtifactsDir, "source_notes.md")
writeAnalyzeFile(t, dependencyPath, options.dependencyBody)
env.Config.Pipeline.Scriptorium.Binary = options.binary
env.Config.Pipeline.Scriptorium.ConfigPath = options.configPath
env.Config.Pipeline.Scriptorium.Timeout = options.topTimeout
env.Config.Pipeline.Scriptorium.RenderDebug = options.renderDebug
env.Config.Pipeline.Scriptorium.Artifacts["source_notes"] = config.ScriptoriumArtifactConfig{
Enabled: false, OutputPath: "artifacts/source_notes.md",
}
inputs := map[string]config.ScriptoriumInputConfig{}
vars := map[string]any{}
if options.reverseMaps {
inputs["players"] = config.ScriptoriumInputConfig{Source: "narratio.input.players", Required: false}
inputs["notes"] = config.ScriptoriumInputConfig{Source: artifacts.ConfiguredArtifactSourceID("source_notes"), Required: true}
inputs["transcript"] = config.ScriptoriumInputConfig{Source: "narratio.transcript.final_trimmed", Required: true}
vars["session_date"] = true
vars["label"] = options.label
} else {
inputs["transcript"] = config.ScriptoriumInputConfig{Source: "narratio.transcript.final_trimmed", Required: true}
inputs["notes"] = config.ScriptoriumInputConfig{Source: artifacts.ConfiguredArtifactSourceID("source_notes"), Required: true}
inputs["players"] = config.ScriptoriumInputConfig{Source: "narratio.input.players", Required: false}
vars["label"] = options.label
vars["session_date"] = true
}
if options.includePlayers {
recordPreparedAnalyzeInput(t, m, "narratio.input.players", filepath.Join(paths.InputsDir, "players.yml"), "players:\n - Hrank\n")
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{
Enabled: true, DependsOn: []string{"source_notes"}, PromptID: options.promptID,
ProfileID: options.profileID, OutputPath: options.outputPath, Timeout: options.artifactTimeout,
Inputs: inputs, Vars: vars,
}
setCurrentAnalyzeEvidence(t, m, "source_notes", "artifacts/source_notes.md", dependencyPath)
dependencyRecord := m.Stages["analyze"].AnalyzeArtifacts["source_notes"]
dependencyRecord.ProducerRunID = options.producerRunID
dependencyRecord.Output.ProducerRunID = options.producerRunID
if !options.updatedAt.IsZero() {
dependencyRecord.UpdatedAt = options.updatedAt
}
m.Stages["analyze"].AnalyzeArtifacts["source_notes"] = dependencyRecord
m.RunID = options.producerRunID
fingerprints, err := computeAnalyzeFingerprints(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
candidate, ok := fingerprints.Lookup("session_recap")
if !ok {
t.Fatal("session_recap fingerprint missing")
}
if candidate.Err != nil {
t.Fatalf("session_recap fingerprint error = %v", candidate.Err)
}
return candidate.Fingerprint
}
func setAnalyzeRecordFingerprint(t *testing.T, m *manifest.Manifest, key, fingerprint string) {
t.Helper()
record := m.Stages["analyze"].AnalyzeArtifacts[key]
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
record.Fingerprint = fingerprint
m.Stages["analyze"].AnalyzeArtifacts[key] = record
}
func updateAnalyzeEvidenceBytes(t *testing.T, m *manifest.Manifest, key, path, body, producerRunID string) {
t.Helper()
writeAnalyzeFile(t, path, body)
checksum, err := artifacts.SHA256File(path)
if err != nil {
t.Fatal(err)
}
record := m.Stages["analyze"].AnalyzeArtifacts[key]
record.Output.Checksum = checksum
record.OutputSize = int64(len(body))
record.ProducerRunID = producerRunID
record.Output.ProducerRunID = producerRunID
m.Stages["analyze"].AnalyzeArtifacts[key] = record
}

View File

@@ -0,0 +1,249 @@
package stage
import (
"context"
"path/filepath"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestAnalyzeReusesCurrentArtifactWithoutScriptorium(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
first, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
installAnalyzeProjection(m, first.AnalyzeState)
if len(fake.RunRequests) != 1 {
t.Fatalf("initial requests = %d, want 1", len(fake.RunRequests))
}
env.Scriptorium = nil
second, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("reuse Run() error = %v", err)
}
if got, _ := second.Metadata["executed_artifacts"].([]string); len(got) != 0 {
t.Fatalf("executed artifacts = %#v, want none", got)
}
if got := second.Metadata["reused_current"]; !reflect.DeepEqual(got, []string{"session_recap"}) {
t.Fatalf("reused current = %#v, want session_recap", got)
}
if second.AnalyzeState.Invocation["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("invocation state = %#v", second.AnalyzeState.Invocation)
}
}
func TestAnalyzePartialForcePreservesUnrelatedCurrentRecord(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
addAnalyzeDependentArtifact(env)
first, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
installAnalyzeProjection(m, first.AnalyzeState)
if len(fake.RunRequests) != 2 {
t.Fatalf("initial requests = %d, want 2", len(fake.RunRequests))
}
priorDependent := m.Stages["analyze"].AnalyzeArtifacts["player_handout"]
env.SelectedArtifactKeys = []string{"session_recap"}
env.Force = true
secondRunner := &orderedScriptoriumRunner{RunBody: "scriptorium noop/fake run artifact\n"}
env.Scriptorium = secondRunner
second, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if got := secondRunner.Calls; !reflect.DeepEqual(got, []string{"run"}) {
t.Fatalf("calls = %#v, want one forced target run", got)
}
if got := second.AnalyzeState.Session["player_handout"]; !reflect.DeepEqual(got, priorDependent) {
t.Fatalf("unrelated dependent changed = %#v, want %#v", got, priorDependent)
}
if _, attempted := second.AnalyzeState.Invocation["player_handout"]; attempted {
t.Fatal("unselected dependent appeared in invocation state")
}
}
func TestAnalyzeExposesReusedPrerequisiteToScheduledDependent(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
addAnalyzeDependentArtifact(env)
first, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
installAnalyzeProjection(m, first.AnalyzeState)
dependent := env.Config.Pipeline.Scriptorium.Artifacts["player_handout"]
dependent.PromptID = "dnd.player_handout.revised"
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = dependent
env.SelectedArtifactKeys = []string{"player_handout"}
fake.RunRequests = nil
second, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if len(fake.RunRequests) != 1 || fake.RunRequests[0].PromptID != "dnd.player_handout.revised" {
t.Fatalf("requests = %#v, want only revised dependent", fake.RunRequests)
}
if got := fake.RunRequests[0].InputPaths["recap"]; got != filepath.Join(paths.ArtifactsDir, "session_recap.md") {
t.Fatalf("reused prerequisite input = %q", got)
}
if second.AnalyzeState.Invocation["session_recap"].Status != manifest.AnalyzeArtifactCurrent ||
second.AnalyzeState.Invocation["player_handout"].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("invocation records = %#v, want reused prerequisite and produced dependent", second.AnalyzeState.Invocation)
}
}
func TestAnalyzeChangedOutputStalesUnselectedDependents(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
addAnalyzeDependentArtifact(env)
first, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
installAnalyzeProjection(m, first.AnalyzeState)
env.SelectedArtifactKeys = []string{"session_recap"}
env.Force = true
env.Scriptorium = &orderedScriptoriumRunner{RunBody: "changed recap\n"}
second, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
dependent := second.AnalyzeState.Session["player_handout"]
if dependent.Status != manifest.AnalyzeArtifactStale || dependent.Output != nil || dependent.OutputSize != 0 {
t.Fatalf("dependent record = %#v, want stale without output", dependent)
}
if _, attempted := second.AnalyzeState.Invocation["player_handout"]; attempted {
t.Fatal("changed-output invalidation executed the unselected dependent")
}
}
func TestAnalyzeRebuildsNestedStalePrerequisitesInDependencyOrder(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
addAnalyzeDependentArtifact(env)
env.Config.Pipeline.Scriptorium.Artifacts["quest_log"] = config.ScriptoriumArtifactConfig{
Enabled: false, DependsOn: []string{"player_handout"}, PromptID: "dnd.quest_log",
OutputPath: "artifacts/quest_log.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"handout": {Source: "narratio.artifact.player_handout", Required: true},
},
}
first, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
installAnalyzeProjection(m, first.AnalyzeState)
if len(fake.RunRequests) != 2 {
t.Fatalf("initial enabled requests = %d, want 2", len(fake.RunRequests))
}
stale := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
stale.Status = manifest.AnalyzeArtifactStale
stale.Output = nil
stale.OutputSize = 0
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = stale
env.SelectedArtifactKeys = []string{"quest_log"}
fake.RunRequests = nil
second, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
gotPrompts := make([]string, 0, len(fake.RunRequests))
for _, request := range fake.RunRequests {
gotPrompts = append(gotPrompts, request.PromptID)
}
wantPrompts := []string{"dnd.session_recap", "dnd.player_handout", "dnd.quest_log"}
if !reflect.DeepEqual(gotPrompts, wantPrompts) {
t.Fatalf("prompt order = %#v, want %#v", gotPrompts, wantPrompts)
}
for _, key := range []string{"session_recap", "player_handout", "quest_log"} {
if second.AnalyzeState.Session[key].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("%s state = %#v, want current", key, second.AnalyzeState.Session[key])
}
}
}
func TestAnalyzeLegacyExecutionPromotesOnlyEffectiveArtifacts(t *testing.T) {
for _, test := range []struct {
name string
selected []string
wantPrompts []string
}{
{name: "full selection", wantPrompts: []string{"dnd.player_handout", "dnd.session_recap"}},
{name: "partial selection", selected: []string{"session_recap"}, wantPrompts: []string{"dnd.session_recap"}},
} {
t.Run(test.name, func(t *testing.T) {
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
writeAnalyzeFile(t, filepath.Join(paths.ArtifactsDir, "player_handout.md"), "legacy handout\n")
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: true, PromptID: "dnd.player_handout", OutputPath: "artifacts/player_handout.md",
}
env.SelectedArtifactKeys = test.selected
m.Stages["analyze"] = &manifest.StageRecord{
Name: "analyze", Status: manifest.StatusSucceeded,
Outputs: []manifest.ArtifactRecord{{Kind: "player_handout", LocalPath: filepath.Join(paths.ArtifactsDir, "player_handout.md")}},
}
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
gotPrompts := make([]string, 0, len(fake.RunRequests))
for _, request := range fake.RunRequests {
gotPrompts = append(gotPrompts, request.PromptID)
}
if !reflect.DeepEqual(gotPrompts, test.wantPrompts) {
t.Fatalf("prompts = %#v, want %#v", gotPrompts, test.wantPrompts)
}
if len(result.AnalyzeState.Session) != len(test.wantPrompts) {
t.Fatalf("session records = %#v, want only regenerated effective artifacts", result.AnalyzeState.Session)
}
if len(test.selected) > 0 {
if _, promoted := result.AnalyzeState.Session["player_handout"]; promoted {
t.Fatal("legacy unselected canonical output was promoted")
}
}
})
}
}
func addAnalyzeDependentArtifact(env *Env) {
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: true, DependsOn: []string{"session_recap"}, PromptID: "dnd.player_handout",
OutputPath: "artifacts/player_handout.md",
Inputs: map[string]config.ScriptoriumInputConfig{
"recap": {Source: "narratio.artifact.session_recap", Required: true},
},
}
}
func installAnalyzeProjection(m *manifest.Manifest, projection *AnalyzeStateProjection) {
if m.Stages["analyze"] == nil {
m.Stages["analyze"] = &manifest.StageRecord{Name: "analyze"}
}
m.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
m.Stages["analyze"].AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(projection.Session)
}

View File

@@ -0,0 +1,294 @@
package stage
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"path/filepath"
"strings"
"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/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
// analyzeInputContract captures the Narratio-visible content contract without
// producer identity or local placement.
type analyzeInputContract struct {
OutputKind string
ManifestKind string
MediaType string
SchemaID string
SchemaVersion string
ModuleKey string
}
// analyzeInputIdentity is the stable semantic identity of one configured
// Scriptorium input. It deliberately contains no filesystem path or run ID.
type analyzeInputIdentity struct {
Name string
SourceID string
Required bool
Present bool
LogicalID string
Contract analyzeInputContract
Checksum string
Size int64
}
type resolvedAnalyzeInputs struct {
Ordered []analyzeInputIdentity
paths map[string]string
artifacts map[string]*artifacts.ResolvedSessionArtifact
}
func (r resolvedAnalyzeInputs) Paths() map[string]string {
if len(r.paths) == 0 {
return map[string]string{}
}
out := make(map[string]string, len(r.paths))
for name, path := range r.paths {
out[name] = path
}
return out
}
func (r resolvedAnalyzeInputs) Artifact(name string) *artifacts.ResolvedSessionArtifact {
artifact := r.artifacts[name]
if artifact == nil {
return nil
}
copy := *artifact
if artifact.Contract != nil {
contract := *artifact.Contract
copy.Contract = &contract
}
return &copy
}
func resolveAnalyzeInputIdentities(
inputs map[string]config.ScriptoriumInputConfig,
execution analyzeExecutionContext,
) (resolvedAnalyzeInputs, error) {
result := resolvedAnalyzeInputs{
Ordered: make([]analyzeInputIdentity, 0, len(inputs)),
paths: make(map[string]string, len(inputs)),
artifacts: make(map[string]*artifacts.ResolvedSessionArtifact, len(inputs)),
}
for _, name := range sortedScriptoriumInputNames(inputs) {
identity, path, artifact, err := resolveAnalyzeInputIdentity(name, inputs[name], execution)
if err != nil {
return resolvedAnalyzeInputs{}, fmt.Errorf("resolve input %q: %w", name, err)
}
result.Ordered = append(result.Ordered, identity)
if !identity.Present {
continue
}
result.paths[name] = path
if artifact != nil {
result.artifacts[name] = artifact
}
}
return result, nil
}
func resolveAnalyzeInputIdentity(
name string,
inputCfg config.ScriptoriumInputConfig,
execution analyzeExecutionContext,
) (analyzeInputIdentity, string, *artifacts.ResolvedSessionArtifact, error) {
descriptor, err := artifactpolicy.DescribeScriptoriumInputSource(inputCfg.Source)
if err != nil {
return analyzeInputIdentity{}, "", nil, err
}
identity := analyzeInputIdentity{
Name: name,
SourceID: descriptor.Source.ID,
Required: inputCfg.Required,
LogicalID: descriptor.Source.ID,
Contract: declaredAnalyzeInputContract(descriptor, execution.Catalog),
}
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
prepared, preparedErr := artifacts.ResolvePreparedInput(execution.Paths, execution.Manifest, descriptor.Source.ID)
if preparedErr == nil {
identity.Present = true
identity.Checksum = prepared.Checksum
identity.Size = prepared.Size
identity.Contract.ManifestKind = prepared.ManifestKind
return identity, prepared.Path, nil, nil
}
if errors.Is(preparedErr, artifacts.ErrPreparedInputAbsent) {
if !inputCfg.Required {
return identity, "", nil, nil
}
return analyzeInputIdentity{}, "", nil, fmt.Errorf(
"required prepared input source %q is unavailable; run narratio run-stage prepare %s --force",
descriptor.Source.ID,
execution.SessionID,
)
}
return analyzeInputIdentity{}, "", nil, fmt.Errorf(
"prepared input source %q is invalid; run narratio run-stage prepare %s --force: %w",
descriptor.Source.ID,
execution.SessionID,
preparedErr,
)
}
var resolved artifacts.ResolvedSessionArtifact
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
resolved, err = artifacts.ResolvePreviousSessionArtifactWithCatalog(
execution.Paths, execution.Manifest, descriptor.Source.ID, execution.Catalog,
)
} else {
resolved, err = artifacts.ResolveSessionArtifactWithCatalog(
execution.Paths, execution.Manifest, descriptor.Source.ID, execution.Catalog,
)
}
if err != nil {
if !errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
return analyzeInputIdentity{}, "", nil, err
}
if !inputCfg.Required {
return identity, "", nil, nil
}
return analyzeInputIdentity{}, "", nil, missingAnalyzeInputError(descriptor, execution)
}
identity.Present = true
identity.Contract = contractForResolvedAnalyzeInput(resolved)
if strings.TrimSpace(resolved.Checksum) != "" && resolved.Size > 0 {
identity.Checksum = resolved.Checksum
identity.Size = resolved.Size
} else {
identity.Checksum, identity.Size, err = hashAnalyzeInputFile(execution.Paths, resolved.Path)
if err != nil {
return analyzeInputIdentity{}, "", nil, fmt.Errorf("verify source %q: %w", descriptor.Source.ID, err)
}
}
copy := resolved
return identity, resolved.Path, &copy, nil
}
func missingAnalyzeInputError(
descriptor artifactpolicy.ScriptoriumInputSourceDescriptor,
execution analyzeExecutionContext,
) error {
source := descriptor.Source.ID
switch descriptor.Source.Kind {
case artifactpolicy.SourceKindPreviousArtifact:
return fmt.Errorf(
"required previous-session input source %q is unavailable; run narratio run-stage prepare %s --force",
source,
execution.SessionID,
)
case artifactpolicy.SourceKindExtraction:
return 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 fmt.Errorf("configured artifact source %q is unavailable", source)
default:
return requiredBuiltInInputError(source, execution)
}
}
func declaredAnalyzeInputContract(
descriptor artifactpolicy.ScriptoriumInputSourceDescriptor,
catalog *artifacts.ArtifactCatalog,
) analyzeInputContract {
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
if prepared, ok := artifactpolicy.DescribePreparedInputSource(descriptor.Source.ID); ok {
return analyzeInputContract{OutputKind: "prepared_input", ManifestKind: prepared.ManifestKind}
}
}
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
return analyzeInputContract{OutputKind: "previous_session_cache"}
}
if catalog != nil {
if entry, ok := catalog.Lookup(descriptor.Source.ID); ok {
return analyzeInputContractFromMetadata(entry.OutputKind, "", entry.Contract)
}
}
return analyzeInputContract{}
}
func contractForResolvedAnalyzeInput(resolved artifacts.ResolvedSessionArtifact) analyzeInputContract {
return analyzeInputContractFromMetadata(resolved.OutputKind, "", resolved.Contract)
}
func analyzeInputContractFromMetadata(
outputKind, manifestKind string,
contract *artifactmodel.ContractMetadata,
) analyzeInputContract {
result := analyzeInputContract{OutputKind: outputKind, ManifestKind: manifestKind}
if contract != nil {
result.MediaType = contract.MediaType
result.SchemaID = contract.SchemaID
result.SchemaVersion = contract.SchemaVersion
result.ModuleKey = contract.ModuleKey
}
return result
}
func hashAnalyzeInputFile(paths artifacts.SessionPaths, path string) (string, int64, error) {
root, 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 "", 0, err
}
target, err := filepath.Abs(strings.TrimSpace(path))
if err != nil || strings.TrimSpace(path) == "" {
if err == nil {
err = fmt.Errorf("input path is required")
}
return "", 0, err
}
relative, err := filepath.Rel(root, target)
if err != nil {
return "", 0, fmt.Errorf("resolve input below session root: %w", err)
}
relative, err = pathsafe.NormalizeRelativeDestination(filepath.ToSlash(relative))
if err != nil {
return "", 0, fmt.Errorf("input path is outside session root: %w", err)
}
file, err := fileops.OpenConfinedRegularFile(root, relative)
if err != nil {
return "", 0, err
}
info, err := file.Stat()
if err != nil {
_ = file.Close()
return "", 0, fmt.Errorf("inspect input: %w", err)
}
digest := sha256.New()
size, readErr := io.Copy(digest, io.LimitReader(file, artifacts.MaxResolvedArtifactBytes+1))
closeErr := file.Close()
if readErr != nil {
return "", 0, fmt.Errorf("checksum input: %w", readErr)
}
if closeErr != nil {
return "", 0, fmt.Errorf("close input: %w", closeErr)
}
if size > artifacts.MaxResolvedArtifactBytes {
return "", 0, fmt.Errorf("input exceeds %d-byte limit", artifacts.MaxResolvedArtifactBytes)
}
if size == 0 {
return "", 0, fmt.Errorf("input is empty")
}
if size != info.Size() {
return "", 0, fmt.Errorf("input size changed while hashing")
}
return hex.EncodeToString(digest.Sum(nil)), size, nil
}

View File

@@ -0,0 +1,365 @@
package stage
import (
"math/rand"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"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"
)
func TestResolveAnalyzeInputIdentitiesSupportsEverySourceKind(t *testing.T) {
tests := []struct {
name string
sourceID string
setup func(*testing.T, *Env, *manifest.Manifest) string
wantOutputKind string
wantContract *artifactmodel.ContractMetadata
}{
{
name: "transcript", sourceID: "narratio.transcript.final_trimmed",
setup: func(t *testing.T, env *Env, m *manifest.Manifest) string {
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
writeAnalyzeFile(t, path, `{"segments":[]}`)
return path
},
wantOutputKind: "transcript_final_trimmed",
},
{
name: "prepared", sourceID: artifactpolicy.SourceInputPlayers,
setup: func(t *testing.T, env *Env, m *manifest.Manifest) string {
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "players.yml")
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, path, "players:\n - Hrank\n")
return path
},
wantOutputKind: "prepared_input",
},
{
name: "extraction", sourceID: artifacts.ExtractionArtifactSourceID("encounters"),
setup: func(t *testing.T, env *Env, m *manifest.Manifest) string {
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
return configureAnalyzeExtractionFixture(t, env, m)["encounters"]
},
wantOutputKind: "notarius_lane",
wantContract: &artifactmodel.ContractMetadata{
MediaType: "application/json", SchemaID: "encounters", SchemaVersion: "1", ModuleKey: "encounters",
},
},
{
name: "previous", sourceID: "narratio.previous_session.artifact.session_recap",
setup: func(t *testing.T, env *Env, m *manifest.Manifest) string {
path := mustPreviousArtifactPath(t, sessionPathsForEnv(env, m.SessionID), "artifacts/session_recap.md")
writeAnalyzeFile(t, path, "previous recap\n")
m.Inputs = append(m.Inputs, manifest.InputRecord{Kind: "previous_artifact", Path: path})
return path
},
wantOutputKind: "previous_session_cache",
},
{
name: "configured", sourceID: artifacts.ConfiguredArtifactSourceID("player_handout"),
setup: func(t *testing.T, env *Env, m *manifest.Manifest) string {
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: false, OutputPath: "artifacts/player_handout.md",
}
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).ArtifactsDir, "player_handout.md")
writeAnalyzeFile(t, path, "player handout\n")
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", path)
return path
},
wantOutputKind: "scriptorium_artifact",
wantContract: &artifactmodel.ContractMetadata{
MediaType: "text/markdown", SchemaID: "narratio.player_handout", SchemaVersion: "1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
wantPath := tt.setup(t, env, m)
execution := newAnalyzeIdentityExecution(t, env, m)
resolved, err := resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{
"subject": {Source: tt.sourceID, Required: true},
}, execution)
if err != nil {
t.Fatalf("resolveAnalyzeInputIdentities() error = %v", err)
}
if len(resolved.Ordered) != 1 {
t.Fatalf("identities = %#v, want one", resolved.Ordered)
}
identity := resolved.Ordered[0]
if identity.Name != "subject" || identity.SourceID != tt.sourceID || identity.LogicalID != tt.sourceID {
t.Fatalf("identity names = %#v", identity)
}
if !identity.Required || !identity.Present {
t.Fatalf("identity availability = %#v", identity)
}
if identity.Contract.OutputKind != tt.wantOutputKind {
t.Fatalf("output kind = %q, want %q", identity.Contract.OutputKind, tt.wantOutputKind)
}
if tt.name == "prepared" && identity.Contract.ManifestKind != "players" {
t.Fatalf("manifest kind = %q, want players", identity.Contract.ManifestKind)
}
if tt.wantContract != nil {
got := artifactmodel.ContractMetadata{
MediaType: identity.Contract.MediaType, SchemaID: identity.Contract.SchemaID,
SchemaVersion: identity.Contract.SchemaVersion, ModuleKey: identity.Contract.ModuleKey,
}
if !reflect.DeepEqual(got, *tt.wantContract) {
t.Fatalf("contract = %#v, want %#v", got, *tt.wantContract)
}
}
checksum, err := artifacts.SHA256File(wantPath)
if err != nil {
t.Fatal(err)
}
info, err := os.Stat(wantPath)
if err != nil {
t.Fatal(err)
}
if identity.Checksum != checksum || identity.Size != info.Size() {
t.Fatalf("content identity = (%q, %d), want (%q, %d)", identity.Checksum, identity.Size, checksum, info.Size())
}
if got := resolved.Paths()["subject"]; got != wantPath {
t.Fatalf("runtime path = %q, want %q", got, wantPath)
}
})
}
}
func TestResolveAnalyzeInputIdentitiesRecordsOptionalAbsenceAndRejectsRequiredAbsence(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
execution := newAnalyzeIdentityExecution(t, env, m)
sourceID := artifactpolicy.SourceInputPlayers
resolved, err := resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{
"players": {Source: sourceID, Required: false},
}, execution)
if err != nil {
t.Fatalf("optional input error = %v", err)
}
if len(resolved.Ordered) != 1 {
t.Fatalf("identities = %#v, want one", resolved.Ordered)
}
identity := resolved.Ordered[0]
if identity.Present || identity.Required || identity.SourceID != sourceID || identity.Checksum != "" || identity.Size != 0 {
t.Fatalf("optional absent identity = %#v", identity)
}
if len(resolved.Paths()) != 0 {
t.Fatalf("optional absent paths = %#v, want none", resolved.Paths())
}
_, err = resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{
"players": {Source: sourceID, Required: true},
}, execution)
if err == nil || !strings.Contains(err.Error(), "run narratio run-stage prepare") {
t.Fatalf("required input error = %v, want prepare guidance", err)
}
}
func TestResolveAnalyzeInputIdentitiesRejectsUnsafePathsAndFileTypes(t *testing.T) {
tests := []struct {
name string
sourceID string
setup func(*testing.T, *Env, *manifest.Manifest)
}{
{
name: "directory", sourceID: "narratio.transcript.final_trimmed",
setup: func(t *testing.T, env *Env, m *manifest.Manifest) {
t.Helper()
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
},
},
{
name: "symlink", sourceID: "narratio.transcript.final_trimmed",
setup: func(t *testing.T, env *Env, m *manifest.Manifest) {
t.Helper()
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
outside := filepath.Join(t.TempDir(), "outside.json")
writeAnalyzeFile(t, outside, `{"segments":[]}`)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, path); err != nil {
t.Fatal(err)
}
},
},
{
name: "manifest path outside session", sourceID: artifactpolicy.SourceInputPlayers,
setup: func(t *testing.T, _ *Env, m *manifest.Manifest) {
t.Helper()
outside := filepath.Join(t.TempDir(), "players.yml")
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, outside, "players:\n - Hrank\n")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
tt.setup(t, env, m)
execution := newAnalyzeIdentityExecution(t, env, m)
_, err := resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{
"subject": {Source: tt.sourceID, Required: true},
}, execution)
if err == nil {
t.Fatal("resolveAnalyzeInputIdentities() error = nil, want unsafe-file rejection")
}
})
}
}
func TestAnalyzeInputIdentityIsStableAcrossWorkspaceRelocationAndChangesWithBytes(t *testing.T) {
resolve := func(t *testing.T, body string) analyzeInputIdentity {
t.Helper()
env, m, _ := setupAnalyzeEnv(t)
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
writeAnalyzeFile(t, path, body)
resolved, err := resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{
"transcript": {Source: "narratio.transcript.final_trimmed", Required: true},
}, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
return resolved.Ordered[0]
}
first := resolve(t, `{"segments":[]}`)
relocated := resolve(t, `{"segments":[]}`)
if !reflect.DeepEqual(first, relocated) {
t.Fatalf("relocated identity changed:\nfirst: %#v\nsecond: %#v", first, relocated)
}
changed := resolve(t, `{"segments":[1]}`)
if first.Checksum == changed.Checksum {
t.Fatalf("checksum did not change with bytes: %#v", changed)
}
}
func TestAnalyzeInputIdentityChangesWithContract(t *testing.T) {
resolve := func(t *testing.T, schemaVersion string) analyzeInputIdentity {
t.Helper()
env, m, _ := setupAnalyzeEnv(t)
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: false, OutputPath: "artifacts/player_handout.md",
}
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).ArtifactsDir, "player_handout.md")
writeAnalyzeFile(t, path, "same bytes\n")
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", path)
record := m.Stages["analyze"].AnalyzeArtifacts["player_handout"]
record.Output.Contract.SchemaVersion = schemaVersion
m.Stages["analyze"].AnalyzeArtifacts["player_handout"] = record
resolved, err := resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{
"handout": {Source: artifacts.ConfiguredArtifactSourceID("player_handout"), Required: true},
}, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
return resolved.Ordered[0]
}
first := resolve(t, "1")
changed := resolve(t, "2")
if first.Checksum != changed.Checksum || first.Size != changed.Size {
t.Fatalf("test fixture content changed: %#v vs %#v", first, changed)
}
if reflect.DeepEqual(first, changed) {
t.Fatalf("identity did not change with contract: %#v", changed)
}
}
func TestConfiguredAnalyzeInputRequiresCurrentManifestEvidence(t *testing.T) {
tests := []struct {
name string
stale bool
}{
{name: "incidental file"},
{name: "stale evidence", stale: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: false, OutputPath: "artifacts/player_handout.md",
}
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).ArtifactsDir, "player_handout.md")
writeAnalyzeFile(t, path, "untrusted handout\n")
if tt.stale {
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", path)
record := m.Stages["analyze"].AnalyzeArtifacts["player_handout"]
record.Status = manifest.AnalyzeArtifactStale
record.Output = nil
record.OutputSize = 0
m.Stages["analyze"].AnalyzeArtifacts["player_handout"] = record
}
_, err := resolveAnalyzeInputIdentities(map[string]config.ScriptoriumInputConfig{
"handout": {Source: artifacts.ConfiguredArtifactSourceID("player_handout"), Required: true},
}, newAnalyzeIdentityExecution(t, env, m))
if err == nil || !strings.Contains(err.Error(), "configured artifact source") {
t.Fatalf("error = %v, want configured source unavailable", err)
}
})
}
}
func TestResolveAnalyzeInputIdentitiesOrdersConfiguredNamesDeterministically(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json")
writeAnalyzeFile(t, path, `{"segments":[]}`)
execution := newAnalyzeIdentityExecution(t, env, m)
names := []string{"zeta", "alpha", "middle", "beta"}
want := append([]string(nil), names...)
slices.Sort(want)
random := rand.New(rand.NewSource(42))
for attempt := 0; attempt < 20; attempt++ {
inputs := make(map[string]config.ScriptoriumInputConfig, len(names))
for _, index := range random.Perm(len(names)) {
inputs[names[index]] = config.ScriptoriumInputConfig{
Source: "narratio.transcript.final_trimmed", Required: true,
}
}
resolved, err := resolveAnalyzeInputIdentities(inputs, execution)
if err != nil {
t.Fatal(err)
}
got := make([]string, 0, len(resolved.Ordered))
for _, identity := range resolved.Ordered {
got = append(got, identity.Name)
}
if !slices.Equal(got, want) {
t.Fatalf("ordered names = %#v, want %#v", got, want)
}
}
}
func newAnalyzeIdentityExecution(t *testing.T, env *Env, m *manifest.Manifest) analyzeExecutionContext {
t.Helper()
configured := artifacts.ConfiguredArtifactDefinitions(env.Config.Pipeline.Scriptorium.Artifacts)
effective, err := artifacts.ResolveEffectiveArtifactSet(configured, nil)
if err != nil {
t.Fatal(err)
}
paths := sessionPathsForEnv(env, m.SessionID)
catalog, err := buildAnalyzeRuntimeArtifactCatalog(
paths, m, env.Config.Pipeline.Scriptorium, env.Config.Pipeline.Notarius, effective,
)
if err != nil {
t.Fatal(err)
}
return analyzeExecutionContext{
Env: env, Manifest: m, Paths: paths, SessionID: m.SessionID, Catalog: catalog,
}
}

View File

@@ -1,44 +0,0 @@
package stage
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestOrderSelectedScriptoriumArtifactsReportsUnavailableDependenciesDeterministically(t *testing.T) {
configured := map[string]config.ScriptoriumArtifactConfig{
"alpha": {Enabled: true, DependsOn: []string{"alpha_dep"}},
"alpha_dep": {Enabled: false, OutputPath: "artifacts/alpha_dep.md"},
"zeta": {Enabled: true, DependsOn: []string{"zeta_dep"}},
"zeta_dep": {Enabled: false, OutputPath: "artifacts/zeta_dep.md"},
}
effective, err := artifacts.ResolveEffectiveArtifactSet(
artifacts.ConfiguredArtifactDefinitions(configured),
[]string{"alpha", "zeta"},
)
if err != nil {
t.Fatalf("ResolveEffectiveArtifactSet() error = %v", err)
}
catalog, err := artifacts.BootstrapRuntimeCatalog(
artifacts.ConfiguredArtifactDefinitions(configured),
effective,
nil,
)
if err != nil {
t.Fatalf("BootstrapRuntimeCatalog() error = %v", err)
}
for i := 0; i < 100; i++ {
_, err := orderSelectedScriptoriumArtifacts(configured, effective, catalog)
if err == nil {
t.Fatal("orderSelectedScriptoriumArtifacts() error = nil, want unavailable dependency")
}
want := `artifact "alpha" depends on "alpha_dep", but "narratio.artifact.alpha_dep" is unavailable; artifact "zeta" depends on "zeta_dep", but "narratio.artifact.zeta_dep" is unavailable`
if !strings.Contains(err.Error(), want) {
t.Fatalf("attempt %d error = %q, want %q", i, err, want)
}
}
}

View File

@@ -0,0 +1,231 @@
package stage
import (
"fmt"
"sort"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
type analyzePlanRole string
const (
analyzePlanTarget analyzePlanRole = "target"
analyzePlanPrerequisite analyzePlanRole = "prerequisite"
)
type analyzePlanAction string
const (
analyzePlanExecute analyzePlanAction = "execute"
analyzePlanReuse analyzePlanAction = "reuse"
)
type analyzePlanItem struct {
Key string
Role analyzePlanRole
Action analyzePlanAction
Reason analyzeReconciliationReason
Forced bool
Dependencies []string
ExpectedFingerprint string
}
type analyzeWorkPlan struct {
ExplicitTargets []string
PrerequisiteWork []analyzePlanItem
ExecutionOrder []analyzePlanItem
ReusedCurrent []analyzePlanItem
Invalidated []analyzePlanItem
Removed []analyzePlanItem
ProjectedRecords map[string]manifest.AnalyzeArtifactRecord
}
// planAnalyzeWork turns selection and reconciliation into one deterministic,
// read-only work plan. It never invokes adapters or mutates manifest state.
func planAnalyzeWork(
scriptoriumCfg *config.ScriptoriumConfig,
selected []string,
force bool,
reconciliation analyzeReconciliation,
) (analyzeWorkPlan, error) {
if scriptoriumCfg == nil {
return analyzeWorkPlan{}, nil
}
if err := config.ValidateScriptoriumArtifactDependencies(scriptoriumCfg.Artifacts); err != nil {
return analyzeWorkPlan{}, err
}
effective, err := artifacts.ResolveEffectiveArtifactSet(
artifacts.ConfiguredArtifactDefinitions(scriptoriumCfg.Artifacts),
selected,
)
if err != nil {
return analyzeWorkPlan{}, err
}
targets := effective.Keys()
targetSet := stringSet(targets)
closure, err := analyzeTargetDependencyClosure(scriptoriumCfg.Artifacts, targets)
if err != nil {
return analyzeWorkPlan{}, err
}
order, err := orderConfiguredAnalyzeArtifacts(scriptoriumCfg.Artifacts)
if err != nil {
return analyzeWorkPlan{}, err
}
reconciled := make(map[string]analyzeArtifactReconciliation, len(reconciliation.Ordered))
removed := make([]analyzePlanItem, 0)
for _, item := range reconciliation.Ordered {
if item.Reason == analyzeReconciliationRemoved {
removed = append(removed, analyzePlanItem{Key: item.Key, Reason: item.Reason})
continue
}
reconciled[item.Key] = item
}
sort.Slice(removed, func(i, j int) bool { return removed[i].Key < removed[j].Key })
plan := analyzeWorkPlan{
ExplicitTargets: append([]string(nil), targets...),
Removed: removed,
ProjectedRecords: projectedAnalyzeRecords(scriptoriumCfg.Artifacts, reconciliation),
}
invalidated := make(map[string]struct{})
for _, state := range reconciliation.Ordered {
if _, configured := scriptoriumCfg.Artifacts[state.Key]; !configured || state.Stored == nil {
continue
}
if state.Stored.Status == manifest.AnalyzeArtifactCurrent && state.Reason != analyzeReconciliationCurrent {
plan.Invalidated = append(plan.Invalidated, analyzePlanItem{
Key: state.Key, Reason: state.Reason, ExpectedFingerprint: state.ExpectedFingerprint,
})
invalidated[state.Key] = struct{}{}
}
}
scheduled := make(map[string]bool, len(closure))
for _, key := range order {
if _, included := closure[key]; !included {
continue
}
state, ok := reconciled[key]
if !ok {
return analyzeWorkPlan{}, fmt.Errorf("analysis reconciliation is missing configured artifact %q", key)
}
dependencies := normalizedAnalyzeDependencyKeys(scriptoriumCfg.Artifacts[key].DependsOn)
dependencyWork := false
for _, dependency := range dependencies {
dependencyWork = dependencyWork || scheduled[dependency]
}
_, explicit := targetSet[key]
forced := force && explicit
needsWork := forced || state.Reason != analyzeReconciliationCurrent || dependencyWork
item := analyzePlanItem{
Key: key, Reason: state.Reason, Forced: forced,
Dependencies: append([]string(nil), dependencies...),
ExpectedFingerprint: state.ExpectedFingerprint,
}
if explicit {
item.Role = analyzePlanTarget
} else {
item.Role = analyzePlanPrerequisite
}
if needsWork {
item.Action = analyzePlanExecute
scheduled[key] = true
plan.ExecutionOrder = append(plan.ExecutionOrder, item)
if !explicit {
plan.PrerequisiteWork = append(plan.PrerequisiteWork, item)
}
if state.Stored != nil && state.Stored.Status == manifest.AnalyzeArtifactCurrent {
if _, exists := invalidated[key]; !exists {
plan.Invalidated = append(plan.Invalidated, item)
invalidated[key] = struct{}{}
}
staleProjectedAnalyzeRecord(plan.ProjectedRecords, key)
}
continue
}
item.Action = analyzePlanReuse
plan.ReusedCurrent = append(plan.ReusedCurrent, item)
}
sort.Slice(plan.Invalidated, func(i, j int) bool { return plan.Invalidated[i].Key < plan.Invalidated[j].Key })
return plan, nil
}
func analyzeTargetDependencyClosure(
configured map[string]config.ScriptoriumArtifactConfig,
targets []string,
) (map[string]struct{}, error) {
closure := make(map[string]struct{}, len(targets))
var visit func(string) error
visit = func(key string) error {
if _, seen := closure[key]; seen {
return nil
}
artifactCfg, ok := configured[key]
if !ok {
return fmt.Errorf("selected artifact %q is not configured", key)
}
closure[key] = struct{}{}
for _, dependency := range normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn) {
if err := visit(dependency); err != nil {
return err
}
}
return nil
}
for _, target := range targets {
if err := visit(target); err != nil {
return nil, err
}
}
return closure, nil
}
func projectedAnalyzeRecords(
configured map[string]config.ScriptoriumArtifactConfig,
reconciliation analyzeReconciliation,
) map[string]manifest.AnalyzeArtifactRecord {
projected := make(map[string]manifest.AnalyzeArtifactRecord)
for _, item := range reconciliation.Ordered {
if item.Stored == nil || item.Reason == analyzeReconciliationRemoved {
continue
}
if _, exists := configured[item.Key]; !exists {
continue
}
record := *item.Stored
if err := manifest.ValidateAnalyzeArtifactCollection(
manifest.AnalyzeStateContractVersion,
map[string]manifest.AnalyzeArtifactRecord{item.Key: record},
); err != nil {
continue
}
projected[item.Key] = record
if item.Reason != analyzeReconciliationCurrent && record.Status == manifest.AnalyzeArtifactCurrent {
staleProjectedAnalyzeRecord(projected, item.Key)
}
}
return manifest.CloneAnalyzeArtifactCollection(projected)
}
func staleProjectedAnalyzeRecord(records map[string]manifest.AnalyzeArtifactRecord, key string) {
record, ok := records[key]
if !ok || record.Status != manifest.AnalyzeArtifactCurrent {
return
}
record.Status = manifest.AnalyzeArtifactStale
record.Output = nil
record.OutputSize = 0
record.Error = ""
records[key] = record
}
func stringSet(values []string) map[string]struct{} {
result := make(map[string]struct{}, len(values))
for _, value := range values {
result[value] = struct{}{}
}
return result
}

View File

@@ -0,0 +1,341 @@
package stage
import (
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestPlanAnalyzeWorkConsumesSemanticReconciliation(t *testing.T) {
env, m := currentAnalyzeReconciliationFixture(t)
reconciled, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
plan, err := planAnalyzeWork(env.Config.Pipeline.Scriptorium, nil, false, reconciled)
if err != nil {
t.Fatal(err)
}
assertAnalyzePlanKeys(t, "current execution", plan.ExecutionOrder, nil)
assertAnalyzePlanKeys(t, "current reuse", plan.ReusedCurrent, []string{"session_recap"})
paths := sessionPathsForEnv(env, m.SessionID)
recordPreparedAnalyzeInput(t, m, "narratio.input.players", filepath.Join(paths.InputsDir, "players.yml"), "players:\n - Hrank\n")
reconciled, err = reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
plan, err = planAnalyzeWork(env.Config.Pipeline.Scriptorium, nil, false, reconciled)
if err != nil {
t.Fatal(err)
}
assertAnalyzePlanKeys(t, "changed execution", plan.ExecutionOrder, []string{"session_recap"})
}
func TestPlanAnalyzeWorkSelectionAndDependencyClosure(t *testing.T) {
tests := []struct {
name string
configured map[string]config.ScriptoriumArtifactConfig
selected []string
force bool
reasons map[string]analyzeReconciliationReason
wantTargets []string
wantExecution []string
wantPrerequisiteWork []string
wantReused []string
wantForced []string
}{
{
name: "default selection targets every enabled artifact",
configured: map[string]config.ScriptoriumArtifactConfig{
"alpha": {Enabled: true}, "zeta": {Enabled: true}, "disabled": {Enabled: false},
},
reasons: map[string]analyzeReconciliationReason{
"alpha": analyzeReconciliationMissing, "zeta": analyzeReconciliationMissing, "disabled": analyzeReconciliationMissing,
},
wantTargets: []string{"alpha", "zeta"}, wantExecution: []string{"alpha", "zeta"},
},
{
name: "partial selection closes over nested dependencies",
configured: map[string]config.ScriptoriumArtifactConfig{
"base": {Enabled: false}, "middle": {Enabled: false, DependsOn: []string{"base"}},
"target": {Enabled: true, DependsOn: []string{"middle"}}, "unrelated": {Enabled: true},
},
selected: []string{"target"},
reasons: map[string]analyzeReconciliationReason{
"base": analyzeReconciliationMissing, "middle": analyzeReconciliationMissing,
"target": analyzeReconciliationMissing, "unrelated": analyzeReconciliationMissing,
},
wantTargets: []string{"target"}, wantExecution: []string{"base", "middle", "target"},
wantPrerequisiteWork: []string{"base", "middle"},
},
{
name: "current prerequisite is reused",
configured: map[string]config.ScriptoriumArtifactConfig{
"base": {Enabled: false}, "target": {Enabled: true, DependsOn: []string{"base"}},
},
selected: []string{"target"}, reasons: map[string]analyzeReconciliationReason{
"base": analyzeReconciliationCurrent, "target": analyzeReconciliationStale,
},
wantTargets: []string{"target"}, wantExecution: []string{"target"}, wantReused: []string{"base"},
},
{
name: "stale prerequisite schedules dependent after it",
configured: map[string]config.ScriptoriumArtifactConfig{
"base": {Enabled: false}, "target": {Enabled: true, DependsOn: []string{"base"}},
},
selected: []string{"target"}, reasons: map[string]analyzeReconciliationReason{
"base": analyzeReconciliationStale, "target": analyzeReconciliationCurrent,
},
wantTargets: []string{"target"}, wantExecution: []string{"base", "target"},
wantPrerequisiteWork: []string{"base"},
},
{
name: "force applies only to explicit target",
configured: map[string]config.ScriptoriumArtifactConfig{
"base": {Enabled: false}, "target": {Enabled: true, DependsOn: []string{"base"}},
},
selected: []string{"target"}, force: true, reasons: map[string]analyzeReconciliationReason{
"base": analyzeReconciliationCurrent, "target": analyzeReconciliationCurrent,
},
wantTargets: []string{"target"}, wantExecution: []string{"target"},
wantReused: []string{"base"}, wantForced: []string{"target"},
},
{
name: "explicit disabled target is valid",
configured: map[string]config.ScriptoriumArtifactConfig{
"disabled": {Enabled: false}, "unrelated": {Enabled: false},
},
selected: []string{"disabled"}, reasons: map[string]analyzeReconciliationReason{
"disabled": analyzeReconciliationMissing, "unrelated": analyzeReconciliationMissing,
},
wantTargets: []string{"disabled"}, wantExecution: []string{"disabled"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reconciliation := analyzePlanningReconciliation(tt.configured, tt.reasons, nil)
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: tt.configured}, tt.selected, tt.force, reconciliation)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(plan.ExplicitTargets, tt.wantTargets) {
t.Fatalf("targets = %#v, want %#v", plan.ExplicitTargets, tt.wantTargets)
}
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, tt.wantExecution)
assertAnalyzePlanKeys(t, "prerequisite work", plan.PrerequisiteWork, tt.wantPrerequisiteWork)
assertAnalyzePlanKeys(t, "reused", plan.ReusedCurrent, tt.wantReused)
forced := make([]string, 0)
for _, item := range plan.ExecutionOrder {
if item.Forced {
forced = append(forced, item.Key)
}
}
if !(len(forced) == 0 && len(tt.wantForced) == 0) && !reflect.DeepEqual(forced, tt.wantForced) {
t.Fatalf("forced = %#v, want %#v", forced, tt.wantForced)
}
})
}
}
func TestPlanAnalyzeWorkSchedulesEveryNonCurrentReason(t *testing.T) {
reasons := []analyzeReconciliationReason{
analyzeReconciliationStale,
analyzeReconciliationMissing,
analyzeReconciliationFailed,
analyzeReconciliationLegacy,
analyzeReconciliationNonResumable,
}
for _, reason := range reasons {
t.Run(string(reason), func(t *testing.T) {
configured := map[string]config.ScriptoriumArtifactConfig{"target": {Enabled: true}}
plan, err := planAnalyzeWork(
&config.ScriptoriumConfig{Artifacts: configured}, nil, false,
analyzePlanningReconciliation(configured, map[string]analyzeReconciliationReason{"target": reason}, nil),
)
if err != nil {
t.Fatal(err)
}
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"})
if plan.ExecutionOrder[0].Reason != reason {
t.Fatalf("reason = %q, want %q", plan.ExecutionOrder[0].Reason, reason)
}
})
}
}
func TestPlanAnalyzeWorkRejectsUnknownSelectionAndInvalidGraphs(t *testing.T) {
tests := []struct {
name string
configured map[string]config.ScriptoriumArtifactConfig
selected []string
want string
}{
{
name: "unknown selection", configured: map[string]config.ScriptoriumArtifactConfig{"target": {Enabled: true}},
selected: []string{"missing"}, want: "not configured",
},
{
name: "unknown dependency", configured: map[string]config.ScriptoriumArtifactConfig{"target": {Enabled: true, DependsOn: []string{"missing"}}},
want: "is not configured",
},
{
name: "cycle", configured: map[string]config.ScriptoriumArtifactConfig{
"alpha": {Enabled: true, DependsOn: []string{"beta"}}, "beta": {Enabled: false, DependsOn: []string{"alpha"}},
},
want: "must not contain cycles",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: tt.configured}, tt.selected, false, analyzeReconciliation{})
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error = %v, want %q", err, tt.want)
}
})
}
}
func TestPlanAnalyzeWorkProjectionPreservesOnlyValidConfiguredAuthority(t *testing.T) {
configured := map[string]config.ScriptoriumArtifactConfig{
"target": {Enabled: true}, "unrelated": {Enabled: true}, "invalid_unselected": {Enabled: true},
}
reasons := map[string]analyzeReconciliationReason{
"target": analyzeReconciliationMissing, "unrelated": analyzeReconciliationCurrent,
"invalid_unselected": analyzeReconciliationStale,
}
stored := map[string]*manifest.AnalyzeArtifactRecord{
"unrelated": analyzePlanningCurrentRecord("unrelated"),
"invalid_unselected": analyzePlanningCurrentRecord("invalid_unselected"),
}
reconciliation := analyzePlanningReconciliation(configured, reasons, stored)
removed := analyzePlanningCurrentRecord("removed")
reconciliation.Ordered = append(reconciliation.Ordered, analyzeArtifactReconciliation{
Key: "removed", Reason: analyzeReconciliationRemoved, Stored: removed,
})
before := manifest.CloneAnalyzeArtifactCollection(map[string]manifest.AnalyzeArtifactRecord{
"unrelated": *stored["unrelated"], "invalid_unselected": *stored["invalid_unselected"], "removed": *removed,
})
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, []string{"target"}, false, reconciliation)
if err != nil {
t.Fatal(err)
}
if got := plan.ProjectedRecords["unrelated"].Status; got != manifest.AnalyzeArtifactCurrent {
t.Fatalf("unrelated status = %q, want current", got)
}
if got := plan.ProjectedRecords["invalid_unselected"].Status; got != manifest.AnalyzeArtifactStale {
t.Fatalf("invalid unselected status = %q, want stale", got)
}
if _, exists := plan.ProjectedRecords["removed"]; exists {
t.Fatal("removed record survived projected authority")
}
assertAnalyzePlanKeys(t, "invalidated", plan.Invalidated, []string{"invalid_unselected"})
assertAnalyzePlanKeys(t, "removed", plan.Removed, []string{"removed"})
if !reflect.DeepEqual(before["unrelated"], *stored["unrelated"]) || !reflect.DeepEqual(before["invalid_unselected"], *stored["invalid_unselected"]) {
t.Fatal("planning mutated stored records")
}
}
func TestPlanAnalyzeWorkLegacyPartialSelectionDoesNotPromoteUnselectedOutput(t *testing.T) {
configured := map[string]config.ScriptoriumArtifactConfig{
"target": {Enabled: true}, "legacy_unselected": {Enabled: true},
}
reconciliation := analyzePlanningReconciliation(configured, map[string]analyzeReconciliationReason{
"target": analyzeReconciliationLegacy, "legacy_unselected": analyzeReconciliationLegacy,
}, nil)
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, []string{"target"}, false, reconciliation)
if err != nil {
t.Fatal(err)
}
assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"})
if len(plan.ProjectedRecords) != 0 {
t.Fatalf("legacy projection = %#v, want no current records", plan.ProjectedRecords)
}
}
func TestPlanAnalyzeWorkOrderIsDeterministicAcrossMapInsertion(t *testing.T) {
want := []string{"alpha", "middle", "zeta"}
for attempt := 0; attempt < 50; attempt++ {
configured := make(map[string]config.ScriptoriumArtifactConfig, 3)
if attempt%2 == 0 {
configured["zeta"] = config.ScriptoriumArtifactConfig{Enabled: true, DependsOn: []string{"middle"}}
configured["middle"] = config.ScriptoriumArtifactConfig{Enabled: false, DependsOn: []string{"alpha"}}
configured["alpha"] = config.ScriptoriumArtifactConfig{Enabled: false}
} else {
configured["alpha"] = config.ScriptoriumArtifactConfig{Enabled: false}
configured["middle"] = config.ScriptoriumArtifactConfig{Enabled: false, DependsOn: []string{"alpha"}}
configured["zeta"] = config.ScriptoriumArtifactConfig{Enabled: true, DependsOn: []string{"middle"}}
}
reasons := map[string]analyzeReconciliationReason{
"alpha": analyzeReconciliationMissing, "middle": analyzeReconciliationMissing, "zeta": analyzeReconciliationMissing,
}
plan, err := planAnalyzeWork(&config.ScriptoriumConfig{Artifacts: configured}, nil, false, analyzePlanningReconciliation(configured, reasons, nil))
if err != nil {
t.Fatal(err)
}
got := analyzePlanKeys(plan.ExecutionOrder)
if !reflect.DeepEqual(got, want) {
t.Fatalf("attempt %d order = %#v, want %#v", attempt, got, want)
}
}
}
func analyzePlanningReconciliation(
configured map[string]config.ScriptoriumArtifactConfig,
reasons map[string]analyzeReconciliationReason,
stored map[string]*manifest.AnalyzeArtifactRecord,
) analyzeReconciliation {
order, err := orderConfiguredAnalyzeArtifacts(configured)
if err != nil {
panic(err)
}
result := analyzeReconciliation{Ordered: make([]analyzeArtifactReconciliation, 0, len(order))}
for _, key := range order {
result.Ordered = append(result.Ordered, analyzeArtifactReconciliation{
Key: key, Reason: reasons[key], ExpectedFingerprint: strings.Repeat(string(key[0]), 64), Stored: stored[key],
})
}
return result
}
func analyzePlanningCurrentRecord(key string) *manifest.AnalyzeArtifactRecord {
return &manifest.AnalyzeArtifactRecord{
Key: key, Status: manifest.AnalyzeArtifactCurrent,
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
Fingerprint: strings.Repeat("a", 64),
Output: &manifest.ArtifactRecord{
Kind: "scriptorium_artifact", SourceID: artifactpolicy.ConfiguredSourceID(key),
LocalPath: "artifacts/" + key + ".md", ProducerRunID: "run-1", Checksum: strings.Repeat("b", 64),
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
},
OutputSize: 1, ProducerRunID: "run-1", UpdatedAt: time.Unix(1, 0).UTC(),
}
}
func assertAnalyzePlanKeys(t *testing.T, label string, items []analyzePlanItem, want []string) {
t.Helper()
got := analyzePlanKeys(items)
if len(got) == 0 && len(want) == 0 {
return
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("%s = %#v, want %#v", label, got, want)
}
}
func analyzePlanKeys(items []analyzePlanItem) []string {
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, item.Key)
}
return result
}

View File

@@ -0,0 +1,201 @@
package stage
import (
"fmt"
"sort"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// analyzeReconciliationReason is the typed resume classification for one
// configured artifact or one manifest record removed from configuration.
type analyzeReconciliationReason string
const (
analyzeReconciliationCurrent analyzeReconciliationReason = "current"
analyzeReconciliationStale analyzeReconciliationReason = "stale"
analyzeReconciliationMissing analyzeReconciliationReason = "missing"
analyzeReconciliationFailed analyzeReconciliationReason = "failed"
analyzeReconciliationLegacy analyzeReconciliationReason = "legacy"
analyzeReconciliationRemoved analyzeReconciliationReason = "removed"
analyzeReconciliationNonResumable analyzeReconciliationReason = "non_resumable"
)
type analyzeArtifactReconciliation struct {
Key string
Reason analyzeReconciliationReason
Detail string
ExpectedFingerprint string
Stored *manifest.AnalyzeArtifactRecord
}
type analyzeReconciliation struct {
Ordered []analyzeArtifactReconciliation
}
func (r analyzeReconciliation) Lookup(key string) (analyzeArtifactReconciliation, bool) {
for _, item := range r.Ordered {
if item.Key == key {
return item, true
}
}
return analyzeArtifactReconciliation{}, false
}
// reconcileAnalyzeArtifacts compares current configuration and semantic input
// identities with manifest-owned output evidence without mutating either.
func reconcileAnalyzeArtifacts(
scriptoriumCfg *config.ScriptoriumConfig,
execution analyzeExecutionContext,
) (analyzeReconciliation, error) {
fingerprints, err := computeAnalyzeFingerprints(scriptoriumCfg, execution)
if err != nil {
return analyzeReconciliation{}, err
}
if scriptoriumCfg == nil {
return analyzeReconciliation{}, nil
}
stageRecord := analyzeManifestStageRecord(execution.Manifest)
result := analyzeReconciliation{Ordered: make([]analyzeArtifactReconciliation, 0, len(fingerprints.Ordered))}
for _, candidate := range fingerprints.Ordered {
artifactCfg := scriptoriumCfg.Artifacts[candidate.Key]
result.Ordered = append(result.Ordered, reconcileAnalyzeArtifact(
candidate,
artifactCfg,
stageRecord,
execution,
))
}
if stageRecord != nil && len(stageRecord.AnalyzeArtifacts) > 0 {
removed := make([]string, 0)
for key := range stageRecord.AnalyzeArtifacts {
if _, configured := scriptoriumCfg.Artifacts[key]; !configured {
removed = append(removed, key)
}
}
sort.Strings(removed)
for _, key := range removed {
record := stageRecord.AnalyzeArtifacts[key]
result.Ordered = append(result.Ordered, analyzeArtifactReconciliation{
Key: key, Reason: analyzeReconciliationRemoved,
Detail: "artifact is no longer present in current configuration",
Stored: cloneAnalyzeReconciliationRecord(record),
})
}
}
return result, nil
}
func reconcileAnalyzeArtifact(
candidate analyzeFingerprintCandidate,
configured config.ScriptoriumArtifactConfig,
stageRecord *manifest.StageRecord,
execution analyzeExecutionContext,
) analyzeArtifactReconciliation {
result := analyzeArtifactReconciliation{
Key: candidate.Key, ExpectedFingerprint: candidate.Fingerprint,
}
if stageRecord == nil {
result.Reason = analyzeReconciliationMissing
result.Detail = "analyze manifest record is absent"
return result
}
if stageRecord.Name != "analyze" {
result.Reason = analyzeReconciliationNonResumable
result.Detail = fmt.Sprintf("manifest stage name is %q", stageRecord.Name)
return result
}
if stageRecord.AnalyzeStateVersion == 0 {
result.Reason = analyzeReconciliationLegacy
result.Detail = "analyze manifest uses aggregate-only legacy state"
return result
}
if stageRecord.AnalyzeStateVersion != manifest.AnalyzeStateContractVersion {
result.Reason = analyzeReconciliationNonResumable
result.Detail = fmt.Sprintf("unsupported analyze state version %d", stageRecord.AnalyzeStateVersion)
return result
}
record, ok := stageRecord.AnalyzeArtifacts[candidate.Key]
if !ok {
result.Reason = analyzeReconciliationMissing
result.Detail = "configured artifact has no analyze manifest record"
return result
}
result.Stored = cloneAnalyzeReconciliationRecord(record)
if err := manifest.ValidateAnalyzeArtifactCollection(
stageRecord.AnalyzeStateVersion,
map[string]manifest.AnalyzeArtifactRecord{candidate.Key: record},
); err != nil {
result.Reason = analyzeReconciliationNonResumable
result.Detail = err.Error()
return result
}
switch record.Status {
case manifest.AnalyzeArtifactMissing:
result.Reason = analyzeReconciliationMissing
result.Detail = "stored artifact status is missing"
return result
case manifest.AnalyzeArtifactFailed:
result.Reason = analyzeReconciliationFailed
result.Detail = "stored artifact status is failed"
return result
case manifest.AnalyzeArtifactStale:
result.Reason = analyzeReconciliationStale
result.Detail = "stored artifact status is stale"
return result
case manifest.AnalyzeArtifactUnselected:
result.Reason = analyzeReconciliationNonResumable
result.Detail = "stored artifact was not evaluated"
return result
case manifest.AnalyzeArtifactCurrent:
default:
result.Reason = analyzeReconciliationNonResumable
result.Detail = fmt.Sprintf("stored artifact status %q is unsupported", record.Status)
return result
}
if record.FingerprintVersion != manifest.AnalyzeFingerprintContractVersion {
result.Reason = analyzeReconciliationNonResumable
result.Detail = fmt.Sprintf("unsupported fingerprint version %d", record.FingerprintVersion)
return result
}
if candidate.Err != nil {
result.Reason = analyzeReconciliationNonResumable
result.Detail = candidate.Err.Error()
return result
}
evidence := artifacts.InspectAnalyzeEvidence(
execution.Paths,
execution.Manifest,
candidate.Key,
artifacts.ConfiguredArtifactDefinition{Enabled: configured.Enabled, OutputPath: configured.OutputPath},
)
if evidence.State != artifacts.AnalyzeEvidenceCurrent {
result.Reason = analyzeReconciliationStale
result.Detail = evidence.Reason
return result
}
if record.Fingerprint != candidate.Fingerprint {
result.Reason = analyzeReconciliationStale
result.Detail = "stored fingerprint differs from current semantic inputs"
return result
}
result.Reason = analyzeReconciliationCurrent
result.Detail = "stored fingerprint and output evidence are current"
return result
}
func analyzeManifestStageRecord(m *manifest.Manifest) *manifest.StageRecord {
if m == nil {
return nil
}
return m.Stages["analyze"]
}
func cloneAnalyzeReconciliationRecord(record manifest.AnalyzeArtifactRecord) *manifest.AnalyzeArtifactRecord {
cloned := manifest.CloneAnalyzeArtifactCollection(map[string]manifest.AnalyzeArtifactRecord{record.Key: record})
copy := cloned[record.Key]
return &copy
}

View File

@@ -0,0 +1,242 @@
package stage
import (
"path/filepath"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestReconcileAnalyzeArtifactsClassifiesStoredState(t *testing.T) {
tests := []struct {
name string
mutate func(*testing.T, *Env, *manifest.Manifest)
want analyzeReconciliationReason
}{
{name: "current", want: analyzeReconciliationCurrent},
{
name: "tampered output", want: analyzeReconciliationStale,
mutate: func(t *testing.T, env *Env, m *manifest.Manifest) {
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).ArtifactsDir, "session_recap.md")
writeAnalyzeFile(t, path, "tampered\n")
},
},
{
name: "optional input transition", want: analyzeReconciliationStale,
mutate: func(t *testing.T, env *Env, m *manifest.Manifest) {
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "players.yml")
recordPreparedAnalyzeInput(t, m, "narratio.input.players", path, "players:\n - Hrank\n")
},
},
{
name: "legacy record", want: analyzeReconciliationLegacy,
mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeStateVersion = 0
m.Stages["analyze"].AnalyzeArtifacts = nil
},
},
{
name: "version mismatch", want: analyzeReconciliationNonResumable,
mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) {
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion + 1
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = record
},
},
{
name: "stale status", want: analyzeReconciliationStale,
mutate: setAnalyzeReconciliationStatus(manifest.AnalyzeArtifactStale),
},
{
name: "missing status", want: analyzeReconciliationMissing,
mutate: setAnalyzeReconciliationStatus(manifest.AnalyzeArtifactMissing),
},
{
name: "failed status", want: analyzeReconciliationFailed,
mutate: setAnalyzeReconciliationStatus(manifest.AnalyzeArtifactFailed),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, m := currentAnalyzeReconciliationFixture(t)
if tt.mutate != nil {
tt.mutate(t, env, m)
}
reconciled, err := reconcileAnalyzeArtifacts(
env.Config.Pipeline.Scriptorium,
newAnalyzeIdentityExecution(t, env, m),
)
if err != nil {
t.Fatal(err)
}
item, ok := reconciled.Lookup("session_recap")
if !ok {
t.Fatal("session_recap reconciliation missing")
}
if item.Reason != tt.want {
t.Fatalf("reason = %q (%s), want %q", item.Reason, item.Detail, tt.want)
}
})
}
}
func TestReconcileAnalyzeArtifactsClassifiesMissingAndRemovedConfiguration(t *testing.T) {
t.Run("missing record", func(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
writeAnalyzeFile(t, filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
m.Stages["analyze"] = &manifest.StageRecord{
Name: "analyze", Status: manifest.StatusSucceeded,
AnalyzeStateVersion: manifest.AnalyzeStateContractVersion,
AnalyzeArtifacts: map[string]manifest.AnalyzeArtifactRecord{},
}
reconciled, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
item, _ := reconciled.Lookup("session_recap")
if item.Reason != analyzeReconciliationMissing {
t.Fatalf("reason = %q, want missing", item.Reason)
}
})
t.Run("removed config", func(t *testing.T) {
env, m := currentAnalyzeReconciliationFixture(t)
delete(env.Config.Pipeline.Scriptorium.Artifacts, "session_recap")
reconciled, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
item, ok := reconciled.Lookup("session_recap")
if !ok || item.Reason != analyzeReconciliationRemoved {
t.Fatalf("removed reconciliation = %#v, %v", item, ok)
}
})
}
func TestReconcileAnalyzeArtifactsTracksDependencyOutputIdentity(t *testing.T) {
tests := []struct {
name string
mutate func(*testing.T, *manifest.Manifest, string)
wantDependent analyzeReconciliationReason
}{
{
name: "changed dependency bytes", wantDependent: analyzeReconciliationStale,
mutate: func(t *testing.T, m *manifest.Manifest, path string) {
updateAnalyzeEvidenceBytes(t, m, "source_notes", path, "changed notes\n", "run-b")
},
},
{
name: "byte-identical upstream replacement", wantDependent: analyzeReconciliationCurrent,
mutate: func(t *testing.T, m *manifest.Manifest, path string) {
updateAnalyzeEvidenceBytes(t, m, "source_notes", path, "notes\n", "run-b")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, m, dependencyPath := currentAnalyzeDependencyReconciliationFixture(t)
tt.mutate(t, m, dependencyPath)
reconciled, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
dependency, _ := reconciled.Lookup("source_notes")
if dependency.Reason != analyzeReconciliationCurrent {
t.Fatalf("dependency reason = %q (%s), want current", dependency.Reason, dependency.Detail)
}
dependent, _ := reconciled.Lookup("session_recap")
if dependent.Reason != tt.wantDependent {
t.Fatalf("dependent reason = %q (%s), want %q", dependent.Reason, dependent.Detail, tt.wantDependent)
}
})
}
}
func TestReconcileAnalyzeArtifactsIsReadOnly(t *testing.T) {
env, m := currentAnalyzeReconciliationFixture(t)
before := manifest.CloneAnalyzeArtifactCollection(m.Stages["analyze"].AnalyzeArtifacts)
beforeUpdatedAt := m.UpdatedAt
if _, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m)); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(before, m.Stages["analyze"].AnalyzeArtifacts) || !m.UpdatedAt.Equal(beforeUpdatedAt) {
t.Fatal("reconciliation mutated manifest state")
}
}
func currentAnalyzeReconciliationFixture(t *testing.T) (*Env, *manifest.Manifest) {
t.Helper()
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
artifact.Inputs["players"] = config.ScriptoriumInputConfig{Source: "narratio.input.players", Required: false}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
fingerprints, err := computeAnalyzeFingerprints(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
candidate, _ := fingerprints.Lookup("session_recap")
if candidate.Err != nil {
t.Fatal(candidate.Err)
}
outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md")
writeAnalyzeFile(t, outputPath, "recap\n")
setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", outputPath)
setAnalyzeRecordFingerprint(t, m, "session_recap", candidate.Fingerprint)
return env, m
}
func currentAnalyzeDependencyReconciliationFixture(t *testing.T) (*Env, *manifest.Manifest, string) {
t.Helper()
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
dependencyPath := filepath.Join(paths.ArtifactsDir, "source_notes.md")
writeAnalyzeFile(t, dependencyPath, "notes\n")
env.Config.Pipeline.Scriptorium.Artifacts["source_notes"] = config.ScriptoriumArtifactConfig{
Enabled: false, OutputPath: "artifacts/source_notes.md",
}
child := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
child.DependsOn = []string{"source_notes"}
child.Inputs["notes"] = config.ScriptoriumInputConfig{
Source: artifacts.ConfiguredArtifactSourceID("source_notes"), Required: true,
}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = child
setCurrentAnalyzeEvidence(t, m, "source_notes", "artifacts/source_notes.md", dependencyPath)
fingerprints, err := computeAnalyzeFingerprints(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m))
if err != nil {
t.Fatal(err)
}
dependencyFingerprint, _ := fingerprints.Lookup("source_notes")
childFingerprint, _ := fingerprints.Lookup("session_recap")
if dependencyFingerprint.Err != nil || childFingerprint.Err != nil {
t.Fatalf("fingerprint errors: dependency=%v child=%v", dependencyFingerprint.Err, childFingerprint.Err)
}
setAnalyzeRecordFingerprint(t, m, "source_notes", dependencyFingerprint.Fingerprint)
childOutput := filepath.Join(paths.ArtifactsDir, "session_recap.md")
writeAnalyzeFile(t, childOutput, "recap\n")
setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", childOutput)
setAnalyzeRecordFingerprint(t, m, "session_recap", childFingerprint.Fingerprint)
return env, m, dependencyPath
}
func setAnalyzeReconciliationStatus(status manifest.AnalyzeArtifactStatus) func(*testing.T, *Env, *manifest.Manifest) {
return func(_ *testing.T, _ *Env, 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
}
}

View File

@@ -0,0 +1,94 @@
package stage
import (
"context"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func (analyzeStage) ValidateResume(_ context.Context, env *Env, m *manifest.Manifest) (ResumeValidation, error) {
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: resolved stage environment config is required")
}
cfg := env.Config.Pipeline.Scriptorium
if cfg == nil || len(cfg.Artifacts) == 0 {
return ResumeValidation{Resumable: true, Analyze: &AnalyzeResumeSummary{}}, nil
}
sessionID := ""
if m != nil {
sessionID = strings.TrimSpace(m.SessionID)
}
if sessionID == "" {
sessionID = strings.TrimSpace(env.Config.Session.SessionID)
}
if sessionID == "" {
return ResumeValidation{}, fmt.Errorf("analyze resume: session id is required")
}
effective := env.EffectiveArtifacts
var err error
if !effective.Resolved() {
effective, err = artifacts.ResolveEffectiveArtifactSet(
artifacts.ConfiguredArtifactDefinitions(cfg.Artifacts),
env.SelectedArtifactKeys,
)
if err != nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: resolve effective artifacts: %w", err)
}
}
if len(effective.Keys()) == 0 {
return ResumeValidation{Resumable: true, Analyze: &AnalyzeResumeSummary{}}, nil
}
paths := sessionPathsForEnv(env, sessionID)
catalog, err := buildAnalyzeRuntimeArtifactCatalog(
paths, m, cfg, env.Config.Pipeline.Notarius, effective,
)
if err != nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: build runtime artifact catalog: %w", err)
}
execution := analyzeExecutionContext{
Env: env, Manifest: m, Paths: paths, SessionID: sessionID,
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths), Catalog: catalog,
}
reconciliation, err := reconcileAnalyzeArtifacts(cfg, execution)
if err != nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: reconcile configured artifacts: %w", err)
}
plan, err := planAnalyzeWork(cfg, env.SelectedArtifactKeys, env.Force, reconciliation)
if err != nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: plan configured artifacts: %w", err)
}
summary := analyzeResumeSummary(plan)
if len(plan.ExecutionOrder) == 0 {
return ResumeValidation{Resumable: true, Analyze: summary}, nil
}
keys := analyzePlanKeysForMetadata(plan.ExecutionOrder)
return ResumeValidation{
Reason: "analysis artifacts require execution: " + strings.Join(keys, ", "),
Analyze: summary,
}, nil
}
func analyzeResumeSummary(plan analyzeWorkPlan) *AnalyzeResumeSummary {
return &AnalyzeResumeSummary{
ExplicitTargets: append([]string(nil), plan.ExplicitTargets...),
PrerequisiteWork: exportAnalyzeResumeItems(plan.PrerequisiteWork),
ExecutionOrder: exportAnalyzeResumeItems(plan.ExecutionOrder),
ReusedCurrent: exportAnalyzeResumeItems(plan.ReusedCurrent),
}
}
func exportAnalyzeResumeItems(items []analyzePlanItem) []AnalyzeResumeArtifact {
if len(items) == 0 {
return nil
}
result := make([]AnalyzeResumeArtifact, 0, len(items))
for _, item := range items {
result = append(result, AnalyzeResumeArtifact{
Key: item.Key, Role: string(item.Role), Reason: string(item.Reason), Forced: item.Forced,
})
}
return result
}

View File

@@ -0,0 +1,160 @@
package stage
import (
"context"
"path/filepath"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestAnalyzeResumeValidationHonorsFullAndPartialSelections(t *testing.T) {
env, m, _ := currentAnalyzeFixture(t, true)
record := m.Stages["analyze"].AnalyzeArtifacts["player_handout"]
record.Status = manifest.AnalyzeArtifactStale
record.Output = nil
record.OutputSize = 0
m.Stages["analyze"].AnalyzeArtifacts["player_handout"] = record
env.SelectedArtifactKeys = []string{"session_recap"}
partial, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if !partial.Resumable || !reflect.DeepEqual(partial.Analyze.ExplicitTargets, []string{"session_recap"}) {
t.Fatalf("partial validation = %#v, want resumable recap selection", partial)
}
env.SelectedArtifactKeys = nil
full, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if full.Resumable || !resumeArtifactKeysEqual(full.Analyze.ExecutionOrder, []string{"player_handout"}) {
t.Fatalf("full validation = %#v, want stale handout execution", full)
}
}
func TestAnalyzeResumeValidationReportsForceAndCurrentPrerequisites(t *testing.T) {
env, m, _ := currentAnalyzeFixture(t, true)
env.SelectedArtifactKeys = []string{"session_recap"}
env.Force = true
forced, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if forced.Resumable || len(forced.Analyze.ExecutionOrder) != 1 ||
forced.Analyze.ExecutionOrder[0].Key != "session_recap" || !forced.Analyze.ExecutionOrder[0].Forced {
t.Fatalf("forced validation = %#v, want only forced recap", forced)
}
env.Force = false
env.SelectedArtifactKeys = []string{"player_handout"}
dependent := env.Config.Pipeline.Scriptorium.Artifacts["player_handout"]
dependent.PromptID = "dnd.player_handout.revised"
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = dependent
changed, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if changed.Resumable || !resumeArtifactKeysEqual(changed.Analyze.ExecutionOrder, []string{"player_handout"}) {
t.Fatalf("changed dependent validation = %#v", changed)
}
if len(changed.Analyze.ReusedCurrent) != 1 || changed.Analyze.ReusedCurrent[0].Key != "session_recap" ||
changed.Analyze.ReusedCurrent[0].Role != "prerequisite" {
t.Fatalf("reused current = %#v, want recap prerequisite", changed.Analyze.ReusedCurrent)
}
}
func TestAnalyzeResumeValidationRejectsChangedInputsTamperedOutputsAndLegacyState(t *testing.T) {
for _, test := range []struct {
name string
mutate func(t *testing.T, env *Env, m *manifest.Manifest)
}{
{
name: "changed input",
mutate: func(t *testing.T, env *Env, m *manifest.Manifest) {
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"text":"changed"}]}`)
},
},
{
name: "tampered output",
mutate: func(t *testing.T, env *Env, m *manifest.Manifest) {
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.ArtifactsDir, "session_recap.md"), "tampered\n")
},
},
{
name: "legacy state",
mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeStateVersion = 0
m.Stages["analyze"].AnalyzeArtifacts = nil
},
},
} {
t.Run(test.name, func(t *testing.T) {
env, m, _ := currentAnalyzeFixture(t, false)
test.mutate(t, env, m)
validation, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if validation.Resumable || !resumeArtifactKeysEqual(validation.Analyze.ExecutionOrder, []string{"session_recap"}) {
t.Fatalf("validation = %#v, want recap execution", validation)
}
})
}
}
func TestAnalyzeStaleAggregateRestoresSuccessWithoutAdapterWork(t *testing.T) {
env, m, _ := currentAnalyzeFixture(t, false)
m.Stages["analyze"].Status = manifest.StatusStale
validation, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if !validation.Resumable || !resumeArtifactKeysEqual(validation.Analyze.ReusedCurrent, []string{"session_recap"}) {
t.Fatalf("validation = %#v, want current recap reuse", validation)
}
env.Scriptorium = nil
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() with no adapter = %v", err)
}
if got, _ := result.Metadata["executed_artifacts"].([]string); len(got) != 0 {
t.Fatalf("executed artifacts = %#v, want none", got)
}
if result.AnalyzeState.Session["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("session projection = %#v, want current recap", result.AnalyzeState.Session)
}
}
func currentAnalyzeFixture(t *testing.T, dependent bool) (*Env, *manifest.Manifest, int) {
t.Helper()
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
if dependent {
addAnalyzeDependentArtifact(env)
}
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
installAnalyzeProjection(m, result.AnalyzeState)
m.Stages["analyze"].Status = manifest.StatusSucceeded
return env, m, len(fake.RunRequests)
}
func resumeArtifactKeysEqual(items []AnalyzeResumeArtifact, want []string) bool {
got := make([]string, 0, len(items))
for _, item := range items {
got = append(got, item.Key)
}
return reflect.DeepEqual(got, want)
}

View File

@@ -62,8 +62,8 @@ func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) {
t.Fatalf("session_id var = %q, want sticky narratio session id", req.Vars["session_id"])
}
if len(result.Outputs) != 1 || result.Outputs[0].Kind != "session_recap" {
t.Fatalf("outputs = %#v, want one session_recap output", result.Outputs)
if result.AnalyzeState == nil || result.AnalyzeState.Invocation["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("analyze state = %#v, want current session_recap", result.AnalyzeState)
}
if len(result.Logs) != 2 {
t.Fatalf("logs = %#v, want stdout+stderr logs", result.Logs)
@@ -314,11 +314,11 @@ func TestAnalyzeUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) {
if !strings.Contains(fake.RunRequests[0].OutputPath, filepath.Join("runs", m.RunID, "analyze", "outputs")) {
t.Fatalf("run output path = %q, want run-local path", fake.RunRequests[0].OutputPath)
}
if len(result.Outputs) != 1 {
t.Fatalf("outputs len = %d, want 1", len(result.Outputs))
if result.AnalyzeState == nil || result.AnalyzeState.Invocation["session_recap"].Output == nil {
t.Fatalf("analyze state = %#v, want session recap output evidence", result.AnalyzeState)
}
if strings.Contains(result.Outputs[0].AbsolutePath, string(filepath.Separator)+"runs"+string(filepath.Separator)) {
t.Fatalf("materialized output path = %q, want canonical session path", result.Outputs[0].AbsolutePath)
if err := requireNonEmptyFile(filepath.Join(paths.ArtifactsDir, "session_recap.md"), "materialized output"); err != nil {
t.Fatalf("canonical output = %v", err)
}
}
@@ -468,6 +468,7 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes
Enabled: false,
OutputPath: "artifacts/player_handout.md",
}
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", playerHandoutPath)
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
@@ -481,6 +482,25 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes
}
}
func TestAnalyzeDoesNotResolveIncidentalConfiguredArtifactFile(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
writeAnalyzeFile(t, filepath.Join(paths.ArtifactsDir, "player_handout.md"), "handout\n")
sessionRecap := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
sessionRecap.Inputs["recap"] = config.ScriptoriumInputConfig{Source: "narratio.artifact.player_handout", Required: true}
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = sessionRecap
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: false, OutputPath: "artifacts/player_handout.md",
}
_, err := (analyzeStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), `"narratio.artifact.player_handout" is unavailable`) {
t.Fatalf("Run() error = %v, want incidental artifact unavailable", err)
}
}
func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
env, m, _ := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
@@ -498,6 +518,7 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
Enabled: false,
OutputPath: "artifacts/player_handout.md",
}
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", playerHandoutPath)
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
@@ -545,8 +566,8 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
if r0["path"] != playerHandoutPath {
t.Fatalf("reused[0].path = %#v, want %q", r0["path"], playerHandoutPath)
}
if r0["provenance"] != artifacts.ArtifactProvenanceDisabledFromDisk {
t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceDisabledFromDisk)
if r0["provenance"] != artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceCurrentAnalyzeManifest)
}
}
@@ -661,8 +682,8 @@ func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) {
if fake.RunRequests[0].PromptID != "dnd.player_handout" {
t.Fatalf("prompt id = %q, want dnd.player_handout", fake.RunRequests[0].PromptID)
}
if len(result.Outputs) != 1 || result.Outputs[0].Kind != "player_handout" {
t.Fatalf("outputs = %#v, want only player_handout", result.Outputs)
if result.AnalyzeState == nil || len(result.AnalyzeState.Invocation) != 1 || result.AnalyzeState.Invocation["player_handout"].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("analyze state = %#v, want only current player_handout", result.AnalyzeState)
}
}
@@ -1368,11 +1389,9 @@ func TestAnalyzeRecordsRefsAndMetadata(t *testing.T) {
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(result.Outputs) != 1 {
t.Fatalf("outputs len = %d, want 1", len(result.Outputs))
}
if result.Outputs[0].AbsolutePath != filepath.Join(paths.ArtifactsDir, "session_recap.md") {
t.Fatalf("output path = %q, want session recap path", result.Outputs[0].AbsolutePath)
record, ok := result.AnalyzeState.Invocation["session_recap"]
if !ok || record.Output == nil || record.Output.LocalPath != "artifacts/session_recap.md" {
t.Fatalf("analyze output record = %#v, want session recap evidence", record)
}
if len(result.Logs) != 2 {
t.Fatalf("logs = %#v, want two logs", result.Logs)

View File

@@ -66,8 +66,8 @@ func All() []Stage {
polishStage{},
normalizeStage{},
trimStage{},
extractStage{},
renderStage{},
extractStage{},
analyzeStage{},
publishStage{},
placeholderStage{name: "notify"},

View File

@@ -839,51 +839,11 @@ func buildPublishRuntimeArtifactCatalog(
if notariusCfg != nil && notariusCfg.Enabled {
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
}
for _, entry := range catalog.ListConfigured() {
if strings.TrimSpace(entry.CanonicalRelPath) == "" {
continue
}
localPath, err := resolveConfiguredArtifactLocalPath(paths, entry.CanonicalRelPath)
if err != nil {
continue
}
info, statErr := os.Stat(localPath)
if statErr != nil {
if os.IsNotExist(statErr) {
continue
}
return nil, fmt.Errorf("stat configured artifact %q: %w", entry.SourceID, statErr)
}
if info.IsDir() {
continue
}
if err := catalog.MarkAvailableFromDisk(entry.SourceID, localPath); err != nil {
return nil, err
}
}
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
return catalog, nil
}
func resolveConfiguredArtifactLocalPath(paths artifacts.SessionPaths, configured string) (string, error) {
outputPath := strings.TrimSpace(configured)
if outputPath == "" {
return "", fmt.Errorf("configured artifact output path is required")
}
if filepath.IsAbs(outputPath) {
return filepath.Clean(outputPath), nil
}
rel := filepath.Clean(outputPath)
if rel == "." || rel == "" {
return "", fmt.Errorf("relative output path is required")
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("relative output path escapes session root: %q", configured)
}
return filepath.Join(paths.Root, rel), nil
}
func collectPublishRunFiles(runRoot string, runManifest *manifest.RunManifest) ([]publishUploadFile, error) {
if runManifest == nil {
return nil, fmt.Errorf("run manifest is required")

View File

@@ -607,6 +607,54 @@ func TestPublishSelectedConfiguredOutputStillFailsWhenMissing(t *testing.T) {
}
}
func TestPublishDoesNotSelectIncidentalConfiguredArtifactFile(t *testing.T) {
env, m, _ := publishFixture(t)
m.Stages["analyze"].AnalyzeStateVersion = 0
m.Stages["analyze"].AnalyzeArtifacts = nil
env.SelectedArtifactKeys = []string{"session_recap"}
_, err := publishStage{}.Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), `required output source unavailable: "narratio.artifact.session_recap"`) {
t.Fatalf("Run() error = %v, want incidental configured artifact unavailable", err)
}
}
func TestPublishOmitsStaleConfiguredArtifactAndPublishesUnrelatedCurrentArtifact(t *testing.T) {
env, m, _ := publishFixture(t)
paths := publishSessionPaths(env, m)
handoutPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
writeStageTestFile(t, handoutPath, "# handout\n")
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
Enabled: true, OutputPath: "artifacts/player_handout.md",
}
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", handoutPath)
recap := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
recap.Status = manifest.AnalyzeArtifactStale
recap.Output = nil
recap.OutputSize = 0
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = recap
env.SelectedArtifactKeys = []string{"session_recap", "player_handout"}
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: "narratio.artifact.session_recap", Dest: "artifacts/session_recap.md", Required: boolPtr(false)},
{Source: "narratio.artifact.player_handout", Dest: "artifacts/player_handout.md", Required: boolPtr(true)},
}
result, err := publishStage{}.Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
fake := env.ObjectStore.(*storage.FakeBackend)
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "artifacts/session_recap.md")]; ok {
t.Fatal("stale configured artifact was uploaded")
}
if _, ok := fake.Objects[publishOutputRemoteKey(m.S3RunPrefix, "artifacts/player_handout.md")]; !ok {
t.Fatal("unrelated current configured artifact was not uploaded")
}
if got := result.Metadata["skipped_optional_outputs"].([]string); !reflect.DeepEqual(got, []string{"artifacts/session_recap.md"}) {
t.Fatalf("skipped_optional_outputs = %#v", got)
}
}
func TestPublishLockedSelectedOutputSkipsAsLocked(t *testing.T) {
env, m, _ := publishFixture(t)
env.SelectedArtifactKeys = []string{"session_recap"}
@@ -946,10 +994,12 @@ func TestPublishRejectsAmbiguousOrCollidingOutputMappings(t *testing.T) {
func TestPublishKeepsSameBasenameSourcesDistinct(t *testing.T) {
env, m, _ := publishFixture(t)
writeStageTestFile(t, filepath.Join(env.Config.Pipeline.Workspace.Root, "work", m.Campaign, m.SessionID, "reports", "session_recap.md"), "# other recap\n")
otherPath := filepath.Join(env.Config.Pipeline.Workspace.Root, "work", m.Campaign, m.SessionID, "reports", "session_recap.md")
writeStageTestFile(t, otherPath, "# other recap\n")
env.Config.Pipeline.Scriptorium.Artifacts["other_recap"] = config.ScriptoriumArtifactConfig{
Enabled: true, PromptID: "dnd.other_recap", OutputPath: "reports/session_recap.md",
}
setCurrentAnalyzeEvidence(t, m, "other_recap", "reports/session_recap.md", otherPath)
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
{Source: "narratio.artifact.session_recap", Dest: "published/first/session_recap.md", Required: boolPtr(true)},
{Source: "narratio.artifact.other_recap", Dest: "published/second/session_recap.md", Required: boolPtr(true)},
@@ -1024,6 +1074,7 @@ func publishFixture(t *testing.T) (*Env, *manifest.Manifest, string) {
for _, name := range publishPrerequisiteStages {
m.MarkStageSucceeded(name, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), nil)
}
setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", filepath.Join(sessionRoot, "artifacts", "session_recap.md"))
env := &Env{
Config: &config.Config{

View File

@@ -26,6 +26,7 @@ type Env struct {
ArtifactStore artifacts.Store
ManifestStore manifest.Store
Logger *slog.Logger
Force bool
WhisperX whisperx.Client
Seriatim seriatim.Runner
@@ -52,14 +53,35 @@ const maxResumeReasonLength = 512
type ResumeValidation struct {
Resumable bool
Reason string
Analyze *AnalyzeResumeSummary
}
// AnalyzeResumeSummary describes the artifact-level decision behind an
// aggregate analyze resume result.
type AnalyzeResumeSummary struct {
ExplicitTargets []string
PrerequisiteWork []AnalyzeResumeArtifact
ExecutionOrder []AnalyzeResumeArtifact
ReusedCurrent []AnalyzeResumeArtifact
}
// AnalyzeResumeArtifact is one deterministic artifact-level plan entry.
type AnalyzeResumeArtifact struct {
Key string
Role string
Reason string
Forced bool
}
// Normalized returns a result with a bounded reason and no reason on success.
func (r ResumeValidation) Normalized() ResumeValidation {
if r.Resumable {
return Resumable()
r.Reason = ""
return r
}
return NonResumable(r.Reason)
normalized := NonResumable(r.Reason)
normalized.Analyze = r.Analyze
return normalized
}
// ResumeValidator is implemented by stages that validate persisted success before reuse.
@@ -105,4 +127,12 @@ type StageResult struct {
Logs []string
GeneratedConfigs []string
Metadata map[string]any
AnalyzeState *AnalyzeStateProjection
}
// AnalyzeStateProjection carries analyze-owned reconciled session authority and
// the invocation subset evaluated by the current run.
type AnalyzeStateProjection struct {
Session map[string]manifest.AnalyzeArtifactRecord
Invocation map[string]manifest.AnalyzeArtifactRecord
}