Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b4b328c4e | |||
| ee2b8e63e6 | |||
| 51edd384c0 | |||
| b804d0f2c8 | |||
| fd5ccc668b | |||
| 0dc8ff9b52 | |||
| 8657a28bdb | |||
| a9c5e4ad4e | |||
| 2176b4371d | |||
| effc10d75b | |||
| 5887839aa1 | |||
| 3128bef20a | |||
| 4e4e2b7d96 | |||
| 99f4f9a0db | |||
| 6abdd67bb5 | |||
| c32e0c401f | |||
| ab5751459a | |||
| 62de6abdbf | |||
| 903dc70682 | |||
| 23c714da66 | |||
| 6639775d7d | |||
| 966b95b176 | |||
| 3bcf2c08dd | |||
| 700ab655ca | |||
| 85c5647385 | |||
| 2ef7c76d99 | |||
| 9bc1b0feda |
@@ -58,6 +58,16 @@ steps:
|
|||||||
build_binary windows amd64 ".exe"
|
build_binary windows amd64 ".exe"
|
||||||
build_binary windows arm64 ".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:
|
publish-release:
|
||||||
image: woodpeckerci/plugin-release
|
image: woodpeckerci/plugin-release
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
79
docs/cli.md
79
docs/cli.md
@@ -12,7 +12,9 @@ This runs the canonical full pipeline for session `2026-04-04`.
|
|||||||
|
|
||||||
Top-level commands:
|
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.
|
- `run-stage <stage> <session_id>`: run one stage.
|
||||||
- `analyze <session_id>`: force-run analyze.
|
- `analyze <session_id>`: force-run analyze.
|
||||||
- `publish <session_id>`: force-run publish.
|
- `publish <session_id>`: force-run publish.
|
||||||
@@ -70,22 +72,63 @@ Commands with additional positionals keep their command-specific order:
|
|||||||
|
|
||||||
## Command Reference
|
## 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`
|
### `run`
|
||||||
|
|
||||||
```bash
|
```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:
|
Behavior:
|
||||||
|
|
||||||
- evaluates full stage order;
|
- evaluates one inclusive contiguous range of the canonical stage order;
|
||||||
- runs `extract` between `trim` and `render`; an omitted or disabled Notarius
|
- 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;
|
configuration records an explicit `notarius_disabled` self-skip;
|
||||||
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
||||||
resume check finds its durable result obsolete;
|
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;
|
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- writes session and run manifests.
|
- 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`
|
### `run-stage`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -100,8 +143,8 @@ Valid stage names:
|
|||||||
- `polish`
|
- `polish`
|
||||||
- `normalize`
|
- `normalize`
|
||||||
- `trim`
|
- `trim`
|
||||||
- `extract`
|
|
||||||
- `render`
|
- `render`
|
||||||
|
- `extract`
|
||||||
- `analyze`
|
- `analyze`
|
||||||
- `publish`
|
- `publish`
|
||||||
- `notify`
|
- `notify`
|
||||||
@@ -153,10 +196,17 @@ post-publish cleanup behavior.
|
|||||||
### `session plan`
|
### `session plan`
|
||||||
|
|
||||||
```bash
|
```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`
|
### `session validate`
|
||||||
|
|
||||||
@@ -252,14 +302,17 @@ and precedence.
|
|||||||
|
|
||||||
## `--artifacts` Selection Rules
|
## `--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`;
|
- names must exist in `pipeline.scriptorium.artifacts`;
|
||||||
- empty entries are invalid;
|
- empty entries are invalid;
|
||||||
- repeated names are deduplicated.
|
- on `run-stage`, only `analyze` and `publish` accept the option.
|
||||||
|
|
||||||
Effects:
|
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>`;
|
- filters publish rules that source `narratio.artifact.<name>`;
|
||||||
- does not filter built-in transcript/bounds or explicitly configured
|
- does not filter built-in transcript/bounds or explicitly configured
|
||||||
`narratio.extraction.<name>` publish sources; and
|
`narratio.extraction.<name>` publish sources; and
|
||||||
@@ -291,6 +344,12 @@ Force publish only:
|
|||||||
narratio publish 2026-04-04
|
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
|
## Output And Exit Behavior
|
||||||
|
|
||||||
- Successful commands write their result or summary to standard output and
|
- Successful commands write their result or summary to standard output and
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ For each `pipeline.scriptorium.artifacts.<name>`:
|
|||||||
| Field | Type | Required | Rule |
|
| Field | Type | Required | Rule |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `enabled` | bool | No | `false` if omitted |
|
| `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 |
|
| `render_debug` | bool | No | per-artifact override |
|
||||||
| `prompt_id` | string | Conditional | required when artifact is enabled |
|
| `prompt_id` | string | Conditional | required when artifact is enabled |
|
||||||
| `profile_id` | string | No | empty |
|
| `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
|
Without `--artifacts`, analyze executes enabled configured artifacts. With an
|
||||||
explicit `--artifacts` list, the exact named configured artifacts are the
|
explicit `--artifacts` list, the exact named configured artifacts are the
|
||||||
one-invocation execution set even if their `enabled` values are false; the list
|
one-invocation targets even if their `enabled` values are false. Analyze closes
|
||||||
does not automatically include dependencies. Named artifacts must therefore be
|
those targets over `depends_on`: a current prerequisite is reused, while a
|
||||||
configured with valid executable fields, and their configured dependencies must
|
stale, missing, failed, or legacy prerequisite is rebuilt before its dependent.
|
||||||
already be available to analyze. This override affects analyze planning only;
|
Unrelated artifacts are not executed. Named targets and any prerequisite that
|
||||||
publish uses the list only to filter configured
|
may require rebuilding must therefore have valid executable fields. This
|
||||||
`narratio.artifact.<name>` output rules.
|
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>`:
|
For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_name>`:
|
||||||
|
|
||||||
|
|||||||
@@ -35,16 +35,29 @@ Adapters do not own:
|
|||||||
|
|
||||||
## Default Wiring
|
## 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.
|
- WhisperX HTTP client for `transcribe`.
|
||||||
- Seriatim subprocess runner.
|
- Seriatim subprocess runner for `merge`, `normalize`, `trim`, or `render`.
|
||||||
- Audita subprocess runner.
|
- Audita subprocess runner for `polish`.
|
||||||
- Scriptorium subprocess runner.
|
- Scriptorium subprocess runner for `trim` or `analyze`.
|
||||||
- Notarius subprocess runner when extraction is enabled.
|
- Notarius subprocess runner for `extract` when extraction is enabled.
|
||||||
- Noop notifier (`notify.NoopSender`).
|
- Noop notifier (`notify.NoopSender`) for `notify`.
|
||||||
- Object store only when required by selected stages/config.
|
- 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
|
Notarius is composed only when extraction is enabled; the extract stage owns
|
||||||
prepared reference resolution, receipt, bundle, and configured-lane policy.
|
prepared reference resolution, receipt, bundle, and configured-lane policy.
|
||||||
The adapter validates the ordered selector/absolute-path pairs and is the sole
|
The adapter validates the ordered selector/absolute-path pairs and is the sole
|
||||||
|
|||||||
@@ -47,22 +47,34 @@ kind and prepared filename vocabulary.
|
|||||||
|
|
||||||
- `planned`: source registered for run context;
|
- `planned`: source registered for run context;
|
||||||
- `executable`: included in the effective analyze artifact set;
|
- `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.
|
- `provenance`: availability source.
|
||||||
|
|
||||||
Configured definitions are always registered. Without an explicit selection,
|
Configured definitions are always registered. Without an explicit selection,
|
||||||
the effective analyze set contains enabled definitions. With `--artifacts`, the
|
the effective analyze set contains enabled definitions. With `--artifacts`, the
|
||||||
exact named configured definitions become the effective set for that invocation,
|
exact named configured definitions become the effective set for that invocation,
|
||||||
regardless of their `enabled` value; dependencies are not added implicitly.
|
regardless of their `enabled` value. The effective-set resolver itself does not
|
||||||
Availability is separate from executability: a non-executable configured output
|
expand dependencies; the analyze work planner closes those targets over their
|
||||||
may be reused from a canonical non-empty file, while an executable definition
|
configured prerequisite graph. Availability is separate from executability.
|
||||||
is generated by analyze. Extraction entries are registered from configuration
|
Configured outputs, including non-executable prerequisites, become available
|
||||||
and become available only after compatible extraction evidence is hydrated.
|
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:
|
Current provenance values:
|
||||||
|
|
||||||
- `generated.current_analyze_run`
|
- `generated.current_analyze_run`
|
||||||
- `filesystem.disabled_artifact_output`
|
- `manifest.current_analyze_artifact`
|
||||||
- `manifest.inputs.previous_cache`
|
- `manifest.inputs.previous_cache`
|
||||||
- `current_session.previous_cache`
|
- `current_session.previous_cache`
|
||||||
|
|
||||||
@@ -75,7 +87,17 @@ Built-ins:
|
|||||||
|
|
||||||
Configured sources (`narratio.artifact.*`):
|
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.*`):
|
Prepared stable sources (`narratio.input.*`):
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,79 @@ The model admits these stage states:
|
|||||||
- `stale`
|
- `stale`
|
||||||
- `interrupted`
|
- `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
|
## Run Manifest
|
||||||
|
|
||||||
`manifest.RunManifest` is created for each invocation and records:
|
`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
|
outputs, logs, generated configuration references, and metadata. Artifact
|
||||||
records may include optional contract and external provenance objects; old
|
records may include optional contract and external provenance objects; old
|
||||||
manifests remain compatible when those fields are absent. A successful forced
|
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,
|
Starting an execution clears the current session-stage record's prior outputs,
|
||||||
logs, generated configuration references, and metadata. Failed and skipped
|
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
|
before execution begins. Invocation run manifests remain immutable audit
|
||||||
records of their own outcomes.
|
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
|
A stage may explicitly return a skipped disposition and stable reason. The
|
||||||
runner persists that outcome in both manifests, clears older outputs for the
|
runner persists that outcome in both manifests, clears older outputs for the
|
||||||
session-stage record along with older logs, generated configuration references,
|
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
|
When an already-succeeded stage is skipped, the invocation run manifest records
|
||||||
the `skip` action and reason. The session manifest deliberately retains its
|
the `skip` action and reason. The session manifest deliberately retains its
|
||||||
existing succeeded record because it remains the cross-invocation progress
|
existing succeeded record because it remains the cross-invocation progress
|
||||||
authority. Stages with a resume validator, currently extraction, may reject an
|
authority. Extraction and analyze have resume validators and may reject an
|
||||||
otherwise eligible skip when the recorded durable result is obsolete; the
|
otherwise eligible skip when their selected durable evidence is obsolete; the
|
||||||
runner marks it stale and executes it.
|
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.
|
Session manifest is the authoritative stage-progress ledger across invocations.
|
||||||
Run manifest is invocation-scoped audit state.
|
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
|
After a publish commits remotely, any configured local cleanup is first recorded
|
||||||
as a session-manifest obligation before deletion begins. Each target becomes
|
as a session-manifest obligation before deletion begins. Each target becomes
|
||||||
complete only after its confined deletion (or safe absence check) and a
|
complete only after its confined deletion (or safe absence check) and a
|
||||||
successful manifest save. An incomplete obligation is retried on later
|
successful manifest save. An incomplete obligation is retried when publish
|
||||||
invocations independently of their selected stages and retains the committed
|
executes again and retains the committed run and remote identity that authorized
|
||||||
run and remote identity that authorized it.
|
it; an invocation that does not execute publish does not perform cleanup.
|
||||||
|
|
||||||
Each invocation derives campaign, session, run, local-path, and remote-prefix
|
Each invocation derives campaign, session, run, local-path, and remote-prefix
|
||||||
metadata from the validated resolved configuration as one projection. A persisted
|
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
|
- running, failed, and self-skipped stages do not retain result payloads from
|
||||||
an earlier success.
|
an earlier success.
|
||||||
- stale stages retain prior details until replacement execution starts.
|
- 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.
|
- run manifest does not replace session manifest as progress authority.
|
||||||
- remote commitment is established by a verified current pointer and remote
|
- remote commitment is established by a verified current pointer and remote
|
||||||
commit relationship, never by a mutable session-manifest boolean.
|
commit relationship, never by a mutable session-manifest boolean.
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ Narratio-level contracts; external transport and SDK details remain in
|
|||||||
adapters. The normative rules for these relationships remain in
|
adapters. The normative rules for these relationships remain in
|
||||||
[Architecture](../policy/architecture.md).
|
[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
|
## Pipeline Stage Set
|
||||||
|
|
||||||
The implemented canonical order is:
|
The implemented canonical order is:
|
||||||
@@ -53,8 +61,8 @@ The implemented canonical order is:
|
|||||||
4. [`polish`](stage-polish.md)
|
4. [`polish`](stage-polish.md)
|
||||||
5. [`normalize`](stage-normalize.md)
|
5. [`normalize`](stage-normalize.md)
|
||||||
6. [`trim`](stage-trim.md)
|
6. [`trim`](stage-trim.md)
|
||||||
7. [`extract`](stage-extract.md)
|
7. [`render`](stage-render.md)
|
||||||
8. [`render`](stage-render.md)
|
8. [`extract`](stage-extract.md)
|
||||||
9. [`analyze`](stage-analyze.md)
|
9. [`analyze`](stage-analyze.md)
|
||||||
10. [`publish`](stage-publish.md)
|
10. [`publish`](stage-publish.md)
|
||||||
11. `notify` (no-op)
|
11. `notify` (no-op)
|
||||||
@@ -65,6 +73,13 @@ mechanics. The
|
|||||||
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
|
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
|
||||||
and execution semantics.
|
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
|
## Focused Documentation
|
||||||
|
|
||||||
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
|
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
|
||||||
@@ -84,8 +99,8 @@ and execution semantics.
|
|||||||
- [`polish`](stage-polish.md)
|
- [`polish`](stage-polish.md)
|
||||||
- [`normalize`](stage-normalize.md)
|
- [`normalize`](stage-normalize.md)
|
||||||
- [`trim`](stage-trim.md)
|
- [`trim`](stage-trim.md)
|
||||||
- [`extract`](stage-extract.md)
|
|
||||||
- [`render`](stage-render.md)
|
- [`render`](stage-render.md)
|
||||||
|
- [`extract`](stage-extract.md)
|
||||||
- [`analyze`](stage-analyze.md)
|
- [`analyze`](stage-analyze.md)
|
||||||
- [`publish`](stage-publish.md)
|
- [`publish`](stage-publish.md)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
## Inputs
|
||||||
|
|
||||||
@@ -21,30 +22,104 @@ Supported source families:
|
|||||||
|
|
||||||
## Outputs
|
## 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
|
- stage metadata describing selected/generated/reused artifacts
|
||||||
|
|
||||||
## Key Behavior
|
## Key Behavior
|
||||||
|
|
||||||
- when Scriptorium is absent or no configured artifact is executable, completes
|
- when `pipeline.scriptorium` is absent or no configured artifact is
|
||||||
successfully with no outputs and records explanatory metadata. This is not an
|
executable, completes successfully with no outputs and records explanatory
|
||||||
explicit self-skip: both manifests record success, satisfy publish's
|
metadata. This is not an explicit self-skip: both manifests record success,
|
||||||
prerequisite, and an ordinary later run reuses the result until forced.
|
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,
|
- builds a runtime artifact catalog containing built-ins, configured artifacts,
|
||||||
and configured extraction lanes. Extraction availability is hydrated only
|
and configured extraction lanes. Extraction availability is hydrated only
|
||||||
from compatible successful extraction evidence.
|
from compatible successful extraction evidence.
|
||||||
- uses enabled configured artifacts by default. An explicit `--artifacts`
|
- uses enabled configured artifacts by default. An explicit `--artifacts`
|
||||||
selection is a one-invocation override: it makes exactly the named configured
|
selection is a one-invocation override that makes exactly the named
|
||||||
artifacts executable even when disabled, and does not automatically include
|
configured artifacts explicit targets even when disabled. The work planner
|
||||||
dependencies. A selected artifact's dependencies must instead already be
|
adds required configured prerequisites, reuses current ones, and schedules
|
||||||
available to the catalog.
|
stale, missing, or otherwise non-current prerequisites before dependents.
|
||||||
- marks non-executable configured artifacts as reusable when output files already exist.
|
- 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).
|
- validates selected artifact dependency order (cycle-safe topo ordering).
|
||||||
- resolves required/optional inputs per artifact source definition.
|
- resolves required/optional inputs per artifact source definition into an
|
||||||
- omits an unavailable optional input; an unavailable required input fails.
|
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
|
- resolves prepared stable input sources through the shared manifest-authoritative
|
||||||
identity resolver; it does not accept incidental files or fall back to
|
identity resolver; it does not accept incidental files or fall back to
|
||||||
campaign/session source paths.
|
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.
|
- resolves previous-session sources from local `previous/` cache only.
|
||||||
- runs optional render-debug, then artifact execution.
|
- runs optional render-debug, then artifact execution.
|
||||||
- validates non-empty output files and materializes canonical outputs.
|
- validates non-empty output files and materializes canonical outputs.
|
||||||
@@ -59,11 +134,33 @@ Supported source families:
|
|||||||
guidance.
|
guidance.
|
||||||
- dependency cycles or unavailable required dependencies fail.
|
- dependency cycles or unavailable required dependencies fail.
|
||||||
- adapter validation failures fail stage.
|
- 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
|
## Invariants
|
||||||
|
|
||||||
- `analyze` performs no remote storage calls for previous-session source resolution.
|
- `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.
|
- 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
|
## Related Contracts And Tests
|
||||||
|
|
||||||
@@ -72,4 +169,13 @@ Supported source families:
|
|||||||
- [CLI](../cli.md) owns user-visible artifact selection.
|
- [CLI](../cli.md) owns user-visible artifact selection.
|
||||||
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
|
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
|
||||||
- Implementation and tests: `internal/stage/analyze.go`,
|
- 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`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Responsibility
|
## 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.
|
`narratio.transcript.final_trimmed` JSON into configured Notarius lane artifacts.
|
||||||
An omitted or disabled Notarius section makes the stage explicitly self-skip
|
An omitted or disabled Notarius section makes the stage explicitly self-skip
|
||||||
with reason `notarius_disabled`, no outputs, and no Notarius runner.
|
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.
|
promotion, so a rejected result cannot expose a partial durable bundle.
|
||||||
|
|
||||||
Any executed extraction outcome that replaces a different effective outcome
|
Any executed extraction outcome that replaces a different effective outcome
|
||||||
marks succeeded downstream stages stale. Repeating the same disabled self-skip
|
marks succeeded analysis and delivery dependents stale. Render is an independent
|
||||||
with no outputs is stable and does not repeatedly invalidate downstream stages.
|
sibling and remains current. Repeating the same disabled self-skip with no
|
||||||
|
outputs is stable and does not repeatedly invalidate dependent stages.
|
||||||
|
|
||||||
## Resume Validation
|
## Resume Validation
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ Exact remote placement and the operator workflow belong in
|
|||||||
checks a declared checksum when present, then streams the opened descriptor.
|
checks a declared checksum when present, then streams the opened descriptor.
|
||||||
- derives the durable previous-cache archive from its validated manifest using
|
- derives the durable previous-cache archive from its validated manifest using
|
||||||
the same confinement and regular-file checks.
|
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;
|
- publishes extraction lanes only through explicit configured output rules;
|
||||||
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
||||||
- selected artifact filter applies to configured artifact sources only.
|
- selected artifact filter applies to configured artifact sources only.
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
|
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
|
## Inputs
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ Canonical stage order:
|
|||||||
4. `polish`
|
4. `polish`
|
||||||
5. `normalize`
|
5. `normalize`
|
||||||
6. `trim`
|
6. `trim`
|
||||||
7. `extract`
|
7. `render`
|
||||||
8. `render`
|
8. `extract`
|
||||||
9. `analyze`
|
9. `analyze`
|
||||||
10. `publish`
|
10. `publish`
|
||||||
11. `notify`
|
11. `notify`
|
||||||
@@ -90,12 +90,12 @@ Execution rules:
|
|||||||
|
|
||||||
- succeeded stages are skipped unless `--force` is set;
|
- succeeded stages are skipped unless `--force` is set;
|
||||||
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- forcing an upstream stage marks succeeded downstream stages as `stale` before
|
- forcing a stage marks succeeded transitive dependents as `stale` before the
|
||||||
the replacement runs; and
|
replacement runs; render and extract are independent siblings; and
|
||||||
- an executed failure, changed self-skip, or success that replaces a different
|
- 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
|
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
|
An explicit self-skip is a durable `skipped` stage outcome that later runs
|
||||||
reconsider. It differs from successful no-output execution: disabled `render`
|
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
|
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
|
## 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:
|
Selection behavior:
|
||||||
|
|
||||||
- validates names against `pipeline.scriptorium.artifacts`;
|
- 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;
|
- filters publish rules for `narratio.artifact.<name>` sources only;
|
||||||
- does not suppress built-in transcript, bounds, or explicitly configured
|
- does not suppress built-in transcript, bounds, or explicitly configured
|
||||||
`narratio.extraction.<name>` publish sources; and
|
`narratio.extraction.<name>` publish sources; and
|
||||||
@@ -436,7 +503,11 @@ Rules:
|
|||||||
- `pipeline.workspace.cleanup_after_publish=true`
|
- `pipeline.workspace.cleanup_after_publish=true`
|
||||||
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
|
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
|
||||||
reports incomplete, the remote committed snapshot remains current; rerun
|
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
|
## Operational Caveats
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ in the [integration documentation](../integrations/).
|
|||||||
The pipeline has one canonical ordered stage set. Configuration may enable,
|
The pipeline has one canonical ordered stage set. Configuration may enable,
|
||||||
disable, or parameterize supported behavior, but it must not turn that sequence
|
disable, or parameterize supported behavior, but it must not turn that sequence
|
||||||
into an arbitrary DAG or hide orchestration in generic workflow abstractions.
|
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
|
The implemented stage inventory belongs in the
|
||||||
[Internal Overview](../internal/overview.md).
|
[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
|
A failed or interrupted stage must not be presented as successful. Failure
|
||||||
should preserve enough local state and diagnostics for inspection, recovery,
|
should preserve enough local state and diagnostics for inspection, recovery,
|
||||||
and resume. Forcing an upstream stage invalidates succeeded downstream work
|
and resume. Forcing a stage invalidates succeeded transitive dependents
|
||||||
according to the canonical stage order.
|
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
|
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
|
outcome is persisted, clears older outputs owned by the stage, and is
|
||||||
|
|||||||
7
docs/releases/README.md
Normal file
7
docs/releases/README.md
Normal 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
47
docs/releases/v1.5.0.md
Normal 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.
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -117,6 +117,40 @@ Safe fix:
|
|||||||
|
|
||||||
Relevant reference: [CLI artifact selection](./cli.md).
|
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
|
## Notarius executable missing
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
@@ -321,6 +355,103 @@ successful stages are then marked stale normally.
|
|||||||
|
|
||||||
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
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
|
## Previous-session artifact input missing
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
// Package subprocess provides reusable process execution and generated-config helpers.
|
// Package subprocess provides reusable process execution and generated-config helpers.
|
||||||
package subprocess
|
package subprocess
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
|
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
|
||||||
@@ -43,8 +42,8 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
@@ -115,8 +114,8 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage() error = %v", err)
|
t.Fatalf("RunStage() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") {
|
if !strings.Contains(out.String(), "stage=analyze executed=1 skipped=0 force=false") {
|
||||||
t.Fatalf("output = %q, want analyze skip without force", out.String())
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=1 skipped=11") {
|
if !strings.Contains(out.String(), "executed=4 skipped=8") {
|
||||||
t.Fatalf("output = %q, want all stages skipped", out.String())
|
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() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
@@ -200,7 +199,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
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...)
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
|
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
|
||||||
}
|
}
|
||||||
@@ -293,8 +292,8 @@ func TestExecutePublishForceRunsPublish(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
|
|||||||
37
internal/app/analyze_evidence_test_helpers_test.go
Normal file
37
internal/app/analyze_evidence_test_helpers_test.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/app/analyze_projection.go
Normal file
134
internal/app/analyze_projection.go
Normal 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
|
||||||
|
}
|
||||||
346
internal/app/analyze_projection_test.go
Normal file
346
internal/app/analyze_projection_test.go
Normal 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)
|
||||||
|
}
|
||||||
295
internal/app/assembled_workflow_test.go
Normal file
295
internal/app/assembled_workflow_test.go
Normal 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")
|
||||||
|
}
|
||||||
62
internal/app/bounded_prerequisites.go
Normal file
62
internal/app/bounded_prerequisites.go
Normal 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)
|
||||||
|
}
|
||||||
345
internal/app/bounded_prerequisites_test.go
Normal file
345
internal/app/bounded_prerequisites_test.go
Normal 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
110
internal/app/bounded_run.go
Normal 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
|
||||||
|
}
|
||||||
197
internal/app/bounded_run_test.go
Normal file
197
internal/app/bounded_run_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"strings"
|
"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.
|
// Execute dispatches CLI commands and returns a process exit code.
|
||||||
func Execute(args []string, stdout, stderr io.Writer) int {
|
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
|
var err error
|
||||||
switch cmd {
|
switch cmd {
|
||||||
|
case "version":
|
||||||
|
err = Version(cmdArgs, stdout)
|
||||||
case "run":
|
case "run":
|
||||||
err = Run(ctx, cmdArgs, stdout)
|
err = runCommandFn(ctx, cmdArgs, stdout)
|
||||||
|
case "regenerate-artifacts":
|
||||||
|
err = RegenerateArtifacts(ctx, cmdArgs, stdout)
|
||||||
case "run-stage":
|
case "run-stage":
|
||||||
err = RunStage(ctx, cmdArgs, stdout)
|
err = RunStage(ctx, cmdArgs, stdout)
|
||||||
case "analyze":
|
case "analyze":
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestExecuteValidCommands(t *testing.T) {
|
|||||||
wantOut string
|
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: "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: "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="},
|
{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="},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,7 +256,10 @@ func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDo
|
|||||||
if after.Stages["extract"].Status != manifest.StatusSucceeded {
|
if after.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||||
t.Fatalf("extract status = %#v", after.Stages["extract"])
|
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 {
|
if after.Stages[name] == nil || after.Stages[name].Status != manifest.StatusStale {
|
||||||
t.Fatalf("%s status = %#v, want stale", name, after.Stages[name])
|
t.Fatalf("%s status = %#v, want stale", name, after.Stages[name])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func buildHelperArtifactCatalog(cfg *config.Config, m *manifest.Manifest) (*arti
|
|||||||
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
|
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
|
||||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||||
}
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
return catalog, nil
|
return catalog, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,11 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
|
|||||||
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
||||||
fmt.Fprintln(out, "Configured:")
|
fmt.Fprintln(out, "Configured:")
|
||||||
for _, entry := range catalog.ListConfigured() {
|
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:")
|
fmt.Fprintln(out, "Extraction:")
|
||||||
for _, entry := range catalog.ListExtraction() {
|
for _, entry := range catalog.ListExtraction() {
|
||||||
|
|||||||
54
internal/app/operator_artifact_rendering_test.go
Normal file
54
internal/app/operator_artifact_rendering_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,34 +2,30 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"strings"
|
||||||
"os"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"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/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 {
|
func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
|
request, err := parseBoundedRunRequest("plan", args, out)
|
||||||
fs.SetOutput(io.Discard)
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
var flags commonConfigFlags
|
return nil
|
||||||
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 {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if flags.sessionID == "" {
|
flags := request.Config
|
||||||
return fmt.Errorf("plan: session_id is required")
|
|
||||||
}
|
|
||||||
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
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 {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
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)
|
return fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: prepare workdir: %w", err)
|
return fmt.Errorf("plan: clone session state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stages := BuildFullPlan()
|
stages := request.Plan.Stages()
|
||||||
var m *manifest.Manifest
|
stageEnv := &stage.Env{
|
||||||
m, err = loadManifestIfPresent(ctx, cfg)
|
Config: cfg, SelectedArtifactKeys: append([]string(nil), request.SelectedArtifacts...),
|
||||||
if err != nil {
|
EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force,
|
||||||
return fmt.Errorf("plan: %w", err)
|
|
||||||
}
|
}
|
||||||
decisions := decideStageActions(stages, m, force)
|
|
||||||
|
|
||||||
runCount := 0
|
runCount := 0
|
||||||
skipCount := 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
|
return err
|
||||||
}
|
}
|
||||||
for _, d := range decisions {
|
for _, selectedStage := range stages {
|
||||||
if d.Action == stageActionRun {
|
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++
|
runCount++
|
||||||
} else {
|
} else {
|
||||||
skipCount++
|
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
|
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 {
|
if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -78,3 +111,102 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
return nil
|
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, ", ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"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/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
func TestPlanDoesNotCreateWorkdir(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
@@ -24,10 +29,10 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
|||||||
t.Fatalf("first Plan() error = %v", err)
|
t.Fatalf("first Plan() error = %v", err)
|
||||||
}
|
}
|
||||||
got := out.String()
|
got := out.String()
|
||||||
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
|
if !strings.Contains(got, "narratio session plan: read-only workdir at") {
|
||||||
t.Fatalf("first output = %q, want workdir prepared", got)
|
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") {
|
if !strings.Contains(got, name+": run") {
|
||||||
t.Fatalf("first output = %q, missing stage %q", got, name)
|
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")
|
sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
||||||
expectedDirs := []string{
|
if _, err := os.Stat(sessionWorkdir); !errors.Is(err, os.ErrNotExist) {
|
||||||
sessionWorkdir,
|
t.Fatalf("workdir stat error = %v, want absent", err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out.Reset()
|
out.Reset()
|
||||||
if err := Plan(context.Background(), args, &out); err != nil {
|
if err := Plan(context.Background(), args, &out); err != nil {
|
||||||
t.Fatalf("second Plan() error = %v", err)
|
t.Fatalf("second Plan() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "narratio session plan: workdir prepared at") {
|
if !strings.Contains(out.String(), "narratio session plan: read-only workdir at") {
|
||||||
t.Fatalf("second output = %q, want workdir prepared", out.String())
|
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()
|
workspaceRoot := t.TempDir()
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
pipelinePath := filepath.Join(configDir, "pipeline.yml")
|
pipelinePath := filepath.Join(configDir, "pipeline.yml")
|
||||||
@@ -130,21 +125,125 @@ inputs:
|
|||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatalf("Plan() error = %v, want missing runtime secrets ignored", err)
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "validate secrets env_dir") {
|
|
||||||
t.Fatalf("error = %q, want secrets validation error context", err.Error())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertDir(t *testing.T, path string) {
|
func TestPlanAndRunShareAnalyzeArtifactDecisionsWithoutPlanSideEffects(t *testing.T) {
|
||||||
t.Helper()
|
workspaceRoot := t.TempDir()
|
||||||
info, err := os.Stat(path)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Stat(%q) error = %v", path, err)
|
t.Fatalf("load config: %v", err)
|
||||||
}
|
}
|
||||||
if !info.IsDir() {
|
analyze, err := stage.Select("analyze")
|
||||||
t.Fatalf("%q is not a directory", path)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,134 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
"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.
|
// BuildFullPlan returns the canonical full stage list in deterministic order.
|
||||||
func BuildFullPlan() []stage.Stage {
|
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.
|
// 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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
func TestBuildFullPlanOrder(t *testing.T) {
|
func TestBuildFullPlanOrder(t *testing.T) {
|
||||||
got := BuildFullPlan()
|
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) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("len(plan) = %d, want %d", 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")
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,7 +146,12 @@ func TestPostPublishCleanupRetriesWhenInitialObligationSaveFails(t *testing.T) {
|
|||||||
|
|
||||||
store.fail = nil
|
store.fail = nil
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != 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)
|
assertMissing(t, seed.spoolAudioDir)
|
||||||
assertCleanupComplete(t, cfg)
|
assertCleanupComplete(t, cfg)
|
||||||
@@ -178,7 +183,12 @@ func TestPostPublishCleanupRetriesFailedDeletionWithoutTouchingOtherRuns(t *test
|
|||||||
|
|
||||||
removeRunScopedDirFn = originalRemove
|
removeRunScopedDirFn = originalRemove
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
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)
|
assertMissing(t, seed.runWorkDir)
|
||||||
assertExists(t, seed.otherRunDir)
|
assertExists(t, seed.otherRunDir)
|
||||||
@@ -218,12 +228,16 @@ func TestPostPublishCleanupRetriesWhenCompletionEvidenceSaveFails(t *testing.T)
|
|||||||
|
|
||||||
store.fail = nil
|
store.fail = nil
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != 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)
|
assertCleanupComplete(t, cfg)
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
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)
|
assertMissing(t, seed.spoolAudioDir)
|
||||||
}
|
}
|
||||||
@@ -328,7 +342,10 @@ func TestPostPublishCleanupFailsOnUnsafePath(t *testing.T) {
|
|||||||
t.Fatalf("Save() error = %v", err)
|
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") {
|
if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") {
|
||||||
t.Fatalf("executeStages() error = %v, want safe-path failure", err)
|
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"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
|
||||||
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
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.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
||||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
||||||
|
|||||||
43
internal/app/regenerate_artifacts.go
Normal file
43
internal/app/regenerate_artifacts.go
Normal 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.")
|
||||||
|
}
|
||||||
135
internal/app/regenerate_artifacts_test.go
Normal file
135
internal/app/regenerate_artifacts_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
|
func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
|
||||||
@@ -37,7 +36,7 @@ inputs:
|
|||||||
if storeInitCalls != 1 {
|
if storeInitCalls != 1 {
|
||||||
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
|
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())
|
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
||||||
}
|
}
|
||||||
if _, ok := fake.Objects[remoteKey]; !ok {
|
if _, ok := fake.Objects[remoteKey]; !ok {
|
||||||
@@ -85,7 +84,7 @@ inputs:
|
|||||||
`,
|
`,
|
||||||
command: []string{"run", "2026-05-03"},
|
command: []string{"run", "2026-05-03"},
|
||||||
configureRun: func() {
|
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")
|
return nil, errors.New("adapter failed")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -99,7 +98,7 @@ inputs:
|
|||||||
`,
|
`,
|
||||||
command: []string{"run", "2026-05-03"},
|
command: []string{"run", "2026-05-03"},
|
||||||
configureRun: func() {
|
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
|
return nil, context.Canceled
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
||||||
@@ -34,12 +33,12 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
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 {
|
if opts.Env == nil {
|
||||||
opts.Env = &Env{}
|
opts.Env = &Env{}
|
||||||
}
|
}
|
||||||
opts.Env.Scriptorium = &scriptorium.NoopRunner{}
|
opts.Env.Scriptorium = &scriptorium.NoopRunner{}
|
||||||
return executeStages(ctx, cfg, stages, opts)
|
return executePlan(ctx, cfg, plan, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
@@ -224,12 +223,12 @@ previous_session_id: 2026-04-26
|
|||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
newObjectStoreFromConfigFn = origObjectStoreFn
|
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 {
|
if opts.Env == nil {
|
||||||
opts.Env = &Env{}
|
opts.Env = &Env{}
|
||||||
}
|
}
|
||||||
opts.Env.Scriptorium = scriptoriumFake
|
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) {
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
||||||
objectStoreConstructed = true
|
objectStoreConstructed = true
|
||||||
@@ -282,6 +281,7 @@ func restoreWorkflowManifestJSON(t *testing.T, sessionID, campaign string) []byt
|
|||||||
for i, stageName := range stages {
|
for i, stageName := range stages {
|
||||||
m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil)
|
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")
|
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||||
if err := store.Save(context.Background(), path, m); err != nil {
|
if err := store.Save(context.Background(), path, m); err != nil {
|
||||||
t.Fatalf("save workflow manifest fixture: %v", err)
|
t.Fatalf("save workflow manifest fixture: %v", err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -11,22 +12,14 @@ import (
|
|||||||
|
|
||||||
// Run executes the pipeline plan and persists manifest state.
|
// Run executes the pipeline plan and persists manifest state.
|
||||||
func Run(ctx context.Context, args []string, out io.Writer) error {
|
func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
request, err := parseBoundedRunRequest("run", args, out)
|
||||||
fs.SetOutput(io.Discard)
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
var flags commonConfigFlags
|
return nil
|
||||||
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 {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if flags.sessionID == "" {
|
flags := request.Config
|
||||||
return fmt.Errorf("run: session_id is required")
|
|
||||||
}
|
|
||||||
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
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 {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("run: invalid --artifacts: %w", err)
|
|
||||||
}
|
|
||||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, normalizedArtifacts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
stages := BuildFullPlan()
|
summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{
|
||||||
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
|
Force: request.Force,
|
||||||
Force: force,
|
SelectedArtifacts: request.SelectedArtifacts,
|
||||||
SelectedArtifacts: normalizedArtifacts,
|
|
||||||
EffectiveArtifacts: effectiveArtifacts,
|
EffectiveArtifacts: effectiveArtifacts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ const (
|
|||||||
stageActionSkip stageAction = "skip"
|
stageActionSkip stageAction = "skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stageDecision struct {
|
|
||||||
Stage stage.Stage
|
|
||||||
Action stageAction
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
staleReasonForcedReplacement = "upstream stage was force-run"
|
staleReasonForcedReplacement = "upstream stage was force-run"
|
||||||
staleReasonChangedResult = "upstream stage result changed"
|
staleReasonChangedResult = "upstream stage result changed"
|
||||||
@@ -39,19 +34,9 @@ type priorStageOutcome struct {
|
|||||||
outputs int
|
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 {
|
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()) {
|
if !force && stageSucceeded(m, s.Name()) {
|
||||||
return stageActionSkip
|
return stageActionSkip
|
||||||
}
|
}
|
||||||
@@ -122,30 +107,153 @@ func canonicalStageNames() []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func downstreamStageNames(stageName string) []string {
|
type invalidationRelation struct {
|
||||||
names := canonicalStageNames()
|
canonical []string
|
||||||
for i, name := range names {
|
direct map[string][]string
|
||||||
if name != stageName {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return append([]string(nil), names[i+1:]...)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func invalidateDownstreamSucceededStagesWithReason(m *manifest.Manifest, upstreamStage string, at time.Time, reason string) []string {
|
var canonicalInvalidationEdges = map[string][]string{
|
||||||
if m == nil || m.Stages == nil {
|
"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
|
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)
|
invalidated := make([]string, 0)
|
||||||
for _, downstream := range downstreamStageNames(upstreamStage) {
|
for _, dependent := range dependents {
|
||||||
sr := m.Stages[downstream]
|
sr := m.Stages[dependent]
|
||||||
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.MarkStageStale(downstream, at, reason)
|
m.MarkStageStale(dependent, at, reason)
|
||||||
invalidated = append(invalidated, downstream)
|
invalidated = append(invalidated, dependent)
|
||||||
}
|
}
|
||||||
return invalidated
|
return invalidated, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,109 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"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]
|
stages := BuildFullPlan()[:2]
|
||||||
m := manifest.New("2026-05-03", time.Now().UTC())
|
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||||
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
||||||
|
|
||||||
got := decideStageActions(stages, m, false)
|
if got := decideStageAction(stages[0], m, false); got != stageActionSkip {
|
||||||
if len(got) != 2 {
|
t.Fatalf("prepare action = %q, want %q", got, stageActionSkip)
|
||||||
t.Fatalf("len(decisions) = %d, want 2", len(got))
|
|
||||||
}
|
}
|
||||||
if got[0].Action != stageActionSkip {
|
if got := decideStageAction(stages[1], m, false); got != stageActionRun {
|
||||||
t.Fatalf("prepare action = %q, want %q", got[0].Action, stageActionSkip)
|
t.Fatalf("transcribe action = %q, want %q", got, stageActionRun)
|
||||||
}
|
|
||||||
if got[1].Action != stageActionRun {
|
|
||||||
t.Fatalf("transcribe action = %q, want %q", got[1].Action, stageActionRun)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
forced := decideStageActions(stages, m, true)
|
if got := decideStageAction(stages[0], m, true); got != stageActionRun {
|
||||||
if forced[0].Action != stageActionRun {
|
t.Fatalf("forced prepare action = %q, want %q", got, stageActionRun)
|
||||||
t.Fatalf("forced prepare action = %q, want %q", forced[0].Action, stageActionRun)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDownstreamStageNames(t *testing.T) {
|
func TestInvalidationDependents(t *testing.T) {
|
||||||
got := downstreamStageNames("polish")
|
tests := []struct {
|
||||||
want := []string{"normalize", "trim", "extract", "render", "analyze", "publish", "notify"}
|
stage string
|
||||||
if !reflect.DeepEqual(got, want) {
|
want []string
|
||||||
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
}{
|
||||||
|
{"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{}},
|
||||||
}
|
}
|
||||||
|
for _, test := range tests {
|
||||||
missing := downstreamStageNames("unknown")
|
t.Run(test.stage, func(t *testing.T) {
|
||||||
if len(missing) != 0 {
|
got, err := dependentStageNames(test.stage)
|
||||||
t.Fatalf("downstreamStageNames(unknown) = %#v, want empty", missing)
|
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()
|
now := time.Now().UTC()
|
||||||
m := manifest.New("2026-05-03", now)
|
m := manifest.New("2026-05-03", now)
|
||||||
m.MarkStageSucceeded("prepare", now, nil)
|
for _, name := range canonicalStageNames() {
|
||||||
m.MarkStageSucceeded("transcribe", now, nil)
|
m.MarkStageSucceeded(name, 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)
|
|
||||||
}
|
}
|
||||||
|
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 {
|
for _, stageName := range want {
|
||||||
if m.Stages[stageName].Status != manifest.StatusStale {
|
if m.Stages[stageName].Status != manifest.StatusStale {
|
||||||
t.Fatalf("%s status = %q, want stale", stageName, m.Stages[stageName].Status)
|
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 {
|
if m.Stages["analyze"].Status != manifest.StatusFailed {
|
||||||
t.Fatalf("analyze status = %q, want failed", m.Stages["analyze"].Status)
|
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()
|
now := time.Now().UTC()
|
||||||
tests := []struct {
|
for _, upstream := range []string{"render", "extract"} {
|
||||||
upstream string
|
t.Run(upstream, func(t *testing.T) {
|
||||||
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) {
|
|
||||||
m := manifest.New("2026-05-03", now)
|
m := manifest.New("2026-05-03", now)
|
||||||
for _, name := range canonicalStageNames() {
|
for _, name := range canonicalStageNames() {
|
||||||
m.MarkStageSucceeded(name, now, nil)
|
m.MarkStageSucceeded(name, now, nil)
|
||||||
}
|
}
|
||||||
got := invalidateDownstreamSucceededStagesWithReason(m, test.upstream, now.Add(time.Second), staleReasonForcedReplacement)
|
got, err := invalidateDependentSucceededStagesWithReason(m, upstream, now.Add(time.Second), staleReasonForcedReplacement)
|
||||||
if !reflect.DeepEqual(got, test.want) {
|
if err != nil {
|
||||||
t.Fatalf("invalidated = %#v, want %#v", got, test.want)
|
t.Fatalf("invalidate dependents: %v", err)
|
||||||
}
|
}
|
||||||
if test.upstream == "render" && m.Stages["extract"].Status != manifest.StatusSucceeded {
|
want := []string{"analyze", "publish", "notify"}
|
||||||
t.Fatalf("forcing render changed extract: %#v", m.Stages["extract"])
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ type singleStageCommand struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSummary, error) {
|
func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSummary, error) {
|
||||||
stages, err := BuildSingleStagePlan(req.StageName)
|
plan, err := buildSingleStageExecutionPlan(req.StageName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
}
|
}
|
||||||
@@ -220,7 +220,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
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,
|
Force: req.Force,
|
||||||
SelectedArtifacts: req.SelectedArtifacts,
|
SelectedArtifacts: req.SelectedArtifacts,
|
||||||
EffectiveArtifacts: effectiveArtifacts,
|
EffectiveArtifacts: effectiveArtifacts,
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load manifest after force: %v", err)
|
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 {
|
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
||||||
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,17 @@ type RunSummary struct {
|
|||||||
Skipped []string
|
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
|
effectiveArtifacts := opts.EffectiveArtifacts
|
||||||
if !effectiveArtifacts.Resolved() && cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
|
if !effectiveArtifacts.Resolved() && cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
|
||||||
var err error
|
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)
|
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)
|
identity.applyToSessionManifest(m)
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
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),
|
fmt.Errorf("load secrets from files: %w", err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if env.WhisperX == nil {
|
if env.WhisperX == nil && stagesContainAny(stages, "transcribe") {
|
||||||
client, err := buildDefaultWhisperXClient(env.Config)
|
client, err := buildDefaultWhisperXClient(env.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -179,7 +194,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
env.WhisperX = client
|
env.WhisperX = client
|
||||||
}
|
}
|
||||||
if env.Seriatim == nil {
|
if env.Seriatim == nil && stagesContainAny(stages, "merge", "normalize", "trim", "render") {
|
||||||
runner, err := buildDefaultSeriatimRunner(env.Config)
|
runner, err := buildDefaultSeriatimRunner(env.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -189,7 +204,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
env.Seriatim = runner
|
env.Seriatim = runner
|
||||||
}
|
}
|
||||||
if env.Audita == nil {
|
if env.Audita == nil && stagesContainAny(stages, "polish") {
|
||||||
runner, err := buildDefaultAuditaRunner(env.Config)
|
runner, err := buildDefaultAuditaRunner(env.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, persistTerminalFailure(
|
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) {
|
if env.Notarius == nil && needsNotariusForRun(env.Config, stages) {
|
||||||
env.Notarius = notarius.NewSubprocessRunner()
|
env.Notarius = notarius.NewSubprocessRunner()
|
||||||
}
|
}
|
||||||
if env.Scriptorium == nil {
|
if env.Scriptorium == nil && stagesContainAny(stages, "trim", "analyze") {
|
||||||
env.Scriptorium = scriptorium.NewSubprocessRunner()
|
env.Scriptorium = scriptorium.NewSubprocessRunner()
|
||||||
}
|
}
|
||||||
if env.ObjectStore == nil && needsObjectStoreForRun(env.Config, stages, effectiveArtifacts) {
|
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
|
return config.MergePublishLockRules(staticLocks, remote.Locks), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if env.Notifier == nil {
|
if env.Notifier == nil && stagesContainAny(stages, "notify") {
|
||||||
env.Notifier = ¬ify.NoopSender{}
|
env.Notifier = ¬ify.NoopSender{}
|
||||||
}
|
}
|
||||||
|
|
||||||
stageEnv := env
|
stageEnv := env
|
||||||
|
|
||||||
decisions := decideStageActions(stages, m, opts.Force)
|
runNames := make([]string, 0, len(stages))
|
||||||
|
executed := make([]string, 0, len(stages))
|
||||||
runNames := make([]string, 0, len(decisions))
|
skipped := make([]string, 0, len(stages))
|
||||||
executed := make([]string, 0, len(decisions))
|
for _, s := range stages {
|
||||||
skipped := make([]string, 0, len(decisions))
|
stageEnv.Force = opts.Force
|
||||||
for _, d := range decisions {
|
|
||||||
s := d.Stage
|
|
||||||
runNames = append(runNames, s.Name())
|
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 {
|
if validator, ok := s.(stage.ResumeValidator); ok {
|
||||||
validation, err := validator.ValidateResume(ctx, stageEnv, m)
|
validation, err := validator.ValidateResume(ctx, stageEnv, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,9 +274,14 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
if !validation.Resumable {
|
if !validation.Resumable {
|
||||||
staleAt := nowUTC()
|
staleAt := nowUTC()
|
||||||
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
|
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
|
||||||
invalidateDownstreamSucceededStagesWithReason(
|
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||||
m, s.Name(), staleAt, staleReasonNotResumable,
|
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 {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
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)
|
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())
|
skipped = append(skipped, s.Name())
|
||||||
skipAt := nowUTC()
|
skipAt := nowUTC()
|
||||||
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
|
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())
|
executed = append(executed, s.Name())
|
||||||
priorOutcome := capturePriorStageOutcome(m, s.Name())
|
priorOutcome := capturePriorStageOutcome(m, s.Name())
|
||||||
|
priorAnalyzeState := captureAnalyzeState(m, s.Name())
|
||||||
|
|
||||||
now := nowUTC()
|
now := nowUTC()
|
||||||
runManifest.SetStageAction(s.Name(), manifest.RunStageActionRun, now)
|
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)
|
m.MarkStageRunning(s.Name(), now)
|
||||||
if opts.Force {
|
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())
|
env.Logger.Info("starting stage", "stage", s.Name())
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
operationErr := fmt.Errorf("save manifest before stage %q: %w", s.Name(), err)
|
operationErr := fmt.Errorf("save manifest before stage %q: %w", s.Name(), err)
|
||||||
m.MarkStageFailed(s.Name(), nowUTC(), operationErr.Error())
|
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(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest, operationErr,
|
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)
|
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "running", "path", manifestPath)
|
||||||
|
|
||||||
result, err := s.Run(ctx, stageEnv, m)
|
result, err := s.Run(ctx, stageEnv, m)
|
||||||
|
var analyzeProjection *validatedAnalyzeProjection
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = validateStageResult(result)
|
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 err != nil {
|
||||||
|
if analyzeProjection != nil {
|
||||||
|
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
||||||
|
}
|
||||||
failedAt := nowUTC()
|
failedAt := nowUTC()
|
||||||
m.MarkStageFailed(s.Name(), failedAt, err.Error())
|
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())
|
runManifest.MarkStageFailed(s.Name(), failedAt, err.Error())
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
env.Logger.Info("stage failed", "stage", s.Name(), "error", err)
|
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)
|
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||||
applyStageResultToManifest(m, s.Name(), result)
|
applyStageResultToManifest(m, s.Name(), result)
|
||||||
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
|
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 {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -362,21 +408,42 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
continue
|
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()
|
succeededAt := nowUTC()
|
||||||
m.MarkStageSucceeded(s.Name(), succeededAt, outputs)
|
m.MarkStageSucceeded(s.Name(), succeededAt, sessionOutputs)
|
||||||
|
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
||||||
applyStageResultToManifest(m, s.Name(), result)
|
applyStageResultToManifest(m, s.Name(), result)
|
||||||
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
|
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 {
|
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(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
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)
|
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
@@ -401,12 +468,14 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
// The run record lives inside the run work directory, which cleanup may
|
// The run record lives inside the run work directory, which cleanup may
|
||||||
// remove. Persist its completed publishing result before cleanup starts so a
|
// remove. Persist its completed publishing result before cleanup starts so a
|
||||||
// successful deletion cannot be undone by a later diagnostic write.
|
// successful deletion cannot be undone by a later diagnostic write.
|
||||||
|
if containsStage(executed, "publish") {
|
||||||
if err := runPostPublishCleanup(ctx, env, manifestPath, m, executed); err != nil {
|
if err := runPostPublishCleanup(ctx, env, manifestPath, m, executed); err != nil {
|
||||||
return nil, persistPostPublishCleanupFailure(
|
return nil, persistPostPublishCleanupFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m,
|
ctx, env.ManifestStore, manifestPath, m,
|
||||||
fmt.Errorf("post-publish cleanup incomplete: %w", err),
|
fmt.Errorf("post-publish cleanup incomplete: %w", err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &RunSummary{
|
return &RunSummary{
|
||||||
SessionID: cfg.Session.SessionID,
|
SessionID: cfg.Session.SessionID,
|
||||||
@@ -419,6 +488,22 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}, nil
|
}, 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(
|
func persistPostPublishCleanupFailure(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionStore manifest.Store,
|
sessionStore manifest.Store,
|
||||||
|
|||||||
@@ -564,12 +564,12 @@ func TestExecuteStagesUsesOptionalResumeValidation(t *testing.T) {
|
|||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
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 {
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
t.Fatalf("Save() error = %v", err)
|
t.Fatalf("Save() error = %v", err)
|
||||||
}
|
}
|
||||||
runs := 0
|
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{})
|
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
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)
|
cfg := testConfig(t)
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
@@ -599,7 +599,7 @@ func TestExecuteStagesNonResumableResultRerunsSucceededDownstream(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
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)
|
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{}
|
store := &manifest.LocalStore{}
|
||||||
manifestPath := manifestPathFor(cfg)
|
manifestPath := manifestPathFor(cfg)
|
||||||
seed := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
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",
|
Kind: "old_output",
|
||||||
SourceID: "narratio.example.old",
|
SourceID: "narratio.example.old",
|
||||||
LocalPath: "artifacts/old.json",
|
LocalPath: "artifacts/old.json",
|
||||||
}})
|
}})
|
||||||
seed.Stages["optional"].Logs = []string{"old.log"}
|
seed.Stages["extract"].Logs = []string{"old.log"}
|
||||||
seed.Stages["optional"].GeneratedConfigs = []string{"old.yml"}
|
seed.Stages["extract"].GeneratedConfigs = []string{"old.yml"}
|
||||||
seed.Stages["optional"].Metadata = map[string]any{"old": true}
|
seed.Stages["extract"].Metadata = map[string]any{"old": true}
|
||||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1038,7 +1038,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
optionalRuns := 0
|
optionalRuns := 0
|
||||||
stages := []stage.Stage{
|
stages := []stage.Stage{
|
||||||
resultStage{
|
resultStage{
|
||||||
name: "optional",
|
name: "extract",
|
||||||
runs: &optionalRuns,
|
runs: &optionalRuns,
|
||||||
order: &order,
|
order: &order,
|
||||||
result: &stage.StageResult{
|
result: &stage.StageResult{
|
||||||
@@ -1049,16 +1049,16 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
Metadata: map[string]any{"enabled": false},
|
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})
|
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{Force: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
if strings.Join(order, ",") != "optional,later" {
|
if strings.Join(order, ",") != "extract,analyze" {
|
||||||
t.Fatalf("execution order = %v, want optional then later", order)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Load() session manifest error = %v", err)
|
t.Fatalf("Load() session manifest error = %v", err)
|
||||||
}
|
}
|
||||||
selfSkipped := sessionManifest.Stages["optional"]
|
selfSkipped := sessionManifest.Stages["extract"]
|
||||||
if selfSkipped == nil || selfSkipped.Status != manifest.StatusSkipped {
|
if selfSkipped == nil || selfSkipped.Status != manifest.StatusSkipped {
|
||||||
t.Fatalf("optional stage = %#v, want skipped", selfSkipped)
|
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 {
|
selfSkipped.Metadata["enabled"] != false || selfSkipped.Metadata["old"] != nil {
|
||||||
t.Fatalf("optional result details = %#v, want current bounded diagnostics and metadata", selfSkipped)
|
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)
|
t.Fatalf("later stage = %#v, want succeeded", later)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,7 +1089,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadRun() error = %v", err)
|
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 {
|
if runStage == nil || runStage.Action != manifest.RunStageActionRun || runStage.Status != manifest.StatusSkipped {
|
||||||
t.Fatalf("run optional stage = %#v, want run action with skipped status", runStage)
|
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) {
|
func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
invalid := resultStage{name: "optional", result: &stage.StageResult{
|
invalid := resultStage{name: "extract", result: &stage.StageResult{
|
||||||
Disposition: stage.StageDispositionSkipped,
|
Disposition: stage.StageDispositionSkipped,
|
||||||
SkipReason: "integration_disabled",
|
SkipReason: "integration_disabled",
|
||||||
Outputs: []artifacts.Ref{{Kind: "unexpected"}},
|
Outputs: []artifacts.Ref{{Kind: "unexpected"}},
|
||||||
@@ -1129,7 +1129,7 @@ func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
|||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
t.Fatalf("Load() session manifest error = %v", loadErr)
|
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)
|
t.Fatalf("optional stage = %#v, want failed", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
internal/app/runner_test_helpers_test.go
Normal file
15
internal/app/runner_test_helpers_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
||||||
@@ -21,7 +20,7 @@ func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
|||||||
var capturedSessionID string
|
var capturedSessionID string
|
||||||
origExecuteStagesFn := executeStagesFn
|
origExecuteStagesFn := executeStagesFn
|
||||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
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
|
capturedSessionID = cfg.Session.SessionID
|
||||||
return &RunSummary{
|
return &RunSummary{
|
||||||
SessionID: cfg.Session.SessionID,
|
SessionID: cfg.Session.SessionID,
|
||||||
@@ -117,7 +116,7 @@ inputs:
|
|||||||
`)
|
`)
|
||||||
origExecuteStagesFn := executeStagesFn
|
origExecuteStagesFn := executeStagesFn
|
||||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
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{
|
return &RunSummary{
|
||||||
SessionID: cfg.Session.SessionID,
|
SessionID: cfg.Session.SessionID,
|
||||||
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
|
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
|
||||||
@@ -194,8 +193,8 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
|
|||||||
var capturedArtifacts []string
|
var capturedArtifacts []string
|
||||||
origExecuteStagesFn := executeStagesFn
|
origExecuteStagesFn := executeStagesFn
|
||||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
@@ -257,7 +256,7 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "plan",
|
name: "plan",
|
||||||
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
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",
|
name: "artifacts",
|
||||||
|
|||||||
17
internal/app/version.go
Normal file
17
internal/app/version.go
Normal 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
|
||||||
|
}
|
||||||
38
internal/app/version_test.go
Normal file
38
internal/app/version_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
144
internal/artifacts/analyze_evidence.go
Normal file
144
internal/artifacts/analyze_evidence.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
234
internal/artifacts/analyze_evidence_test.go
Normal file
234
internal/artifacts/analyze_evidence_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -107,6 +107,9 @@ type ResolvedSessionArtifact struct {
|
|||||||
OutputKind string
|
OutputKind string
|
||||||
ProducerRunID string
|
ProducerRunID string
|
||||||
Provenance string
|
Provenance string
|
||||||
|
Contract *artifactmodel.ContractMetadata
|
||||||
|
Checksum string
|
||||||
|
Size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionArtifactNotFoundError includes context when a known artifact cannot be read.
|
// SessionArtifactNotFoundError includes context when a known artifact cannot be read.
|
||||||
@@ -259,6 +262,9 @@ func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest,
|
|||||||
OutputKind: entry.OutputKind,
|
OutputKind: entry.OutputKind,
|
||||||
ProducerRunID: entry.ProducerRunID,
|
ProducerRunID: entry.ProducerRunID,
|
||||||
Provenance: entry.Provenance,
|
Provenance: entry.Provenance,
|
||||||
|
Contract: cloneArtifactContract(entry.Contract),
|
||||||
|
Checksum: entry.Checksum,
|
||||||
|
Size: entry.Size,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing.T) {
|
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromManifest(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
||||||
@@ -385,16 +385,17 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing
|
|||||||
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
||||||
}
|
}
|
||||||
sourceID := ConfiguredArtifactSourceID("player_handout")
|
sourceID := ConfiguredArtifactSourceID("player_handout")
|
||||||
if err := catalog.MarkAvailableFromDisk(sourceID, outputPath); err != nil {
|
m := analyzeEvidenceFixture(t, paths, "player_handout", "artifacts/player_handout.md", []byte("handout\n"))
|
||||||
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||||
}
|
}
|
||||||
if resolved.Provenance != ArtifactProvenanceDisabledFromDisk {
|
if resolved.Provenance != ArtifactProvenanceCurrentAnalyzeManifest {
|
||||||
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceDisabledFromDisk)
|
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceCurrentAnalyzeManifest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
|
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
|
||||||
ArtifactProvenanceDisabledFromDisk = "filesystem.disabled_artifact_output"
|
ArtifactProvenanceCurrentAnalyzeManifest = "manifest.current_analyze_artifact"
|
||||||
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
|
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +65,9 @@ type CatalogEntry struct {
|
|||||||
Path string
|
Path string
|
||||||
Provenance string
|
Provenance string
|
||||||
ProducerRunID string
|
ProducerRunID string
|
||||||
|
Contract *artifactmodel.ContractMetadata
|
||||||
|
Checksum string
|
||||||
|
Size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactCatalog tracks built-in, configured, and extraction artifact definitions and runtime state.
|
// 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 {
|
if _, exists := c.extractionIndex[trimmed]; exists {
|
||||||
return fmt.Errorf("duplicate extraction artifact key %q", trimmed)
|
return fmt.Errorf("duplicate extraction artifact key %q", trimmed)
|
||||||
}
|
}
|
||||||
|
def := configured[key]
|
||||||
sourceID := ExtractionArtifactSourceID(trimmed)
|
sourceID := ExtractionArtifactSourceID(trimmed)
|
||||||
if err := c.addEntry(CatalogEntry{
|
if err := c.addEntry(CatalogEntry{
|
||||||
SourceID: sourceID,
|
SourceID: sourceID,
|
||||||
@@ -105,6 +110,10 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
|
|||||||
ProducerStage: "extract",
|
ProducerStage: "extract",
|
||||||
OutputKind: "notarius_lane",
|
OutputKind: "notarius_lane",
|
||||||
Planned: true,
|
Planned: true,
|
||||||
|
Contract: &artifactmodel.ContractMetadata{
|
||||||
|
MediaType: def.MediaType, SchemaID: def.SchemaID,
|
||||||
|
SchemaVersion: def.SchemaVersion, ModuleKey: def.ModuleKey,
|
||||||
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("register extraction artifact %q: %w", trimmed, err)
|
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
|
return CatalogEntry{}, false
|
||||||
}
|
}
|
||||||
entry, ok := c.entries[strings.TrimSpace(sourceID)]
|
entry, ok := c.entries[strings.TrimSpace(sourceID)]
|
||||||
return entry, ok
|
return cloneCatalogEntry(entry), ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceIDForConfiguredKey returns canonical source ID for one configured key.
|
// SourceIDForConfiguredKey returns canonical source ID for one configured key.
|
||||||
@@ -255,7 +264,7 @@ func (c *ArtifactCatalog) ListConfigured() []CatalogEntry {
|
|||||||
out := make([]CatalogEntry, 0, len(keys))
|
out := make([]CatalogEntry, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
sourceID := c.configuredIndex[key]
|
sourceID := c.configuredIndex[key]
|
||||||
out = append(out, c.entries[sourceID])
|
out = append(out, cloneCatalogEntry(c.entries[sourceID]))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -272,7 +281,7 @@ func (c *ArtifactCatalog) ListExtraction() []CatalogEntry {
|
|||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
out := make([]CatalogEntry, 0, len(keys))
|
out := make([]CatalogEntry, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
out = append(out, c.entries[c.extractionIndex[key]])
|
out = append(out, cloneCatalogEntry(c.entries[c.extractionIndex[key]]))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -282,17 +291,71 @@ func (c *ArtifactCatalog) MarkAvailableGenerated(sourceID, path string) error {
|
|||||||
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
|
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkAvailableFromDisk marks one source as available from disabled artifact on disk.
|
// MarkAvailableGeneratedEvidence marks one source as available from the
|
||||||
func (c *ArtifactCatalog) MarkAvailableFromDisk(sourceID, path string) error {
|
// current analyze invocation and retains the semantic output identity needed
|
||||||
return c.markAvailable(sourceID, path, ArtifactProvenanceDisabledFromDisk)
|
// 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 {
|
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
entry := c.entries[strings.TrimSpace(sourceID)]
|
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||||
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
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
|
c.entries[entry.SourceID] = entry
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -313,10 +376,29 @@ func (c *ArtifactCatalog) markAvailable(sourceID, path, provenance string) error
|
|||||||
entry.Available = true
|
entry.Available = true
|
||||||
entry.Path = trimmedPath
|
entry.Path = trimmedPath
|
||||||
entry.Provenance = provenance
|
entry.Provenance = provenance
|
||||||
|
entry.ProducerRunID = ""
|
||||||
|
entry.Checksum = ""
|
||||||
|
entry.Size = 0
|
||||||
|
if provenance == ArtifactProvenanceGeneratedCurrentAnalyzeRun {
|
||||||
|
entry.Contract = nil
|
||||||
|
}
|
||||||
c.entries[normalizedID] = entry
|
c.entries[normalizedID] = entry
|
||||||
return nil
|
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 {
|
func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("artifact catalog is nil")
|
return fmt.Errorf("artifact catalog is nil")
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package artifacts
|
package artifacts
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
)
|
||||||
|
|
||||||
func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
|
func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
|
||||||
catalog := NewArtifactCatalog()
|
catalog := NewArtifactCatalog()
|
||||||
@@ -179,27 +184,27 @@ func TestArtifactCatalogMarkAvailableGenerated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestArtifactCatalogMarkAvailableFromDisk(t *testing.T) {
|
func TestArtifactCatalogMarkAvailableGeneratedEvidence(t *testing.T) {
|
||||||
catalog := NewArtifactCatalog()
|
catalog := NewArtifactCatalog()
|
||||||
if err := catalog.RegisterConfiguredArtifacts(
|
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{
|
||||||
map[string]ConfiguredArtifactDefinition{
|
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
|
||||||
"session_recap": {Enabled: false, OutputPath: "artifacts/session_recap.md"},
|
}); err != nil {
|
||||||
},
|
t.Fatal(err)
|
||||||
nil,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceID, _ := catalog.SourceIDForConfiguredKey("session_recap")
|
sourceID, _ := catalog.SourceIDForConfiguredKey("session_recap")
|
||||||
if err := catalog.MarkAvailableFromDisk(sourceID, "/tmp/session_recap.md"); err != nil {
|
contract := &artifactmodel.ContractMetadata{
|
||||||
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
|
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)
|
entry, _ := catalog.Lookup(sourceID)
|
||||||
if !entry.Available {
|
if !entry.Available || entry.ProducerRunID != "run-1" || entry.Checksum != checksum || entry.Size != 42 ||
|
||||||
t.Fatalf("entry.Available = false, want true")
|
entry.Contract == nil || *entry.Contract != *contract {
|
||||||
}
|
t.Fatalf("generated evidence entry = %#v", entry)
|
||||||
if entry.Provenance != ArtifactProvenanceDisabledFromDisk {
|
|
||||||
t.Fatalf("entry.Provenance = %q, want %q", entry.Provenance, ArtifactProvenanceDisabledFromDisk)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,11 @@ func (c *ArtifactCatalog) HydrateExtractionArtifacts(
|
|||||||
if proof.State != ExtractionEvidenceValid {
|
if proof.State != ExtractionEvidenceValid {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for sourceID, path := range proof.Outputs {
|
for sourceID, output := range proof.Outputs {
|
||||||
_ = c.markAvailableFromExtractManifest(sourceID, path, proof.ProducerRunID)
|
_ = c.markAvailableFromExtractManifest(
|
||||||
|
sourceID, output.Path, proof.ProducerRunID,
|
||||||
|
output.Checksum, output.Size, output.Contract,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ func TestHydrateExtractionArtifactsAcceptsOnlyCompleteCurrentBundle(t *testing.T
|
|||||||
if entry.Provenance != ArtifactProvenanceCurrentExtractManifest || entry.ProducerRunID != "extract-run-1" {
|
if entry.Provenance != ArtifactProvenanceCurrentExtractManifest || entry.ProducerRunID != "extract-run-1" {
|
||||||
t.Fatalf("hydrated provenance = %#v", entry)
|
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)
|
resolved, err := ResolveSessionArtifactWithCatalog(paths, currentManifest, entry.SourceID, catalog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
)
|
)
|
||||||
@@ -23,7 +24,15 @@ const (
|
|||||||
type ExtractionEvidence struct {
|
type ExtractionEvidence struct {
|
||||||
State ExtractionEvidenceState
|
State ExtractionEvidenceState
|
||||||
Reason, ProducerRunID string
|
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.
|
// InspectExtractionEvidence verifies structure, confinement, identities, contracts, and payload bytes.
|
||||||
@@ -72,7 +81,7 @@ func InspectExtractionEvidence(
|
|||||||
for key, d := range configured {
|
for key, d := range configured {
|
||||||
expected[ExtractionArtifactSourceID(key)] = d
|
expected[ExtractionArtifactSourceID(key)] = d
|
||||||
}
|
}
|
||||||
seen, outputs := map[string]struct{}{}, map[string]string{}
|
seen, outputs := map[string]struct{}{}, map[string]ExtractionEvidenceOutput{}
|
||||||
indexSeen := false
|
indexSeen := false
|
||||||
for _, out := range r.Outputs {
|
for _, out := range r.Outputs {
|
||||||
if strings.TrimSpace(out.ProducerRunID) != runID {
|
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") {
|
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"}
|
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}
|
return ExtractionEvidence{State: state, Reason: reason}
|
||||||
}
|
}
|
||||||
indexSeen = true
|
indexSeen = true
|
||||||
@@ -98,11 +107,15 @@ func InspectExtractionEvidence(
|
|||||||
if !compatibleCatalogExtractionContract(out.Contract, d) || !compatibleCatalogExtractionProvenance(out.ExternalProvenance, receiptRunID, receiptPipelineID, d) {
|
if !compatibleCatalogExtractionContract(out.Contract, d) || !compatibleCatalogExtractionProvenance(out.ExternalProvenance, receiptRunID, receiptPipelineID, d) {
|
||||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract output contract or provenance is incompatible"}
|
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}
|
return ExtractionEvidence{State: state, Reason: reason}
|
||||||
}
|
}
|
||||||
seen[out.SourceID] = struct{}{}
|
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 {
|
if !indexSeen || len(seen) != len(expected) || len(r.Outputs) != len(expected)+1 {
|
||||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result is incomplete"}
|
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result is incomplete"}
|
||||||
@@ -110,30 +123,30 @@ func InspectExtractionEvidence(
|
|||||||
return ExtractionEvidence{State: ExtractionEvidenceValid, ProducerRunID: runID, Outputs: outputs}
|
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) == "" {
|
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)
|
info, err := os.Lstat(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return ExtractionEvidenceObsolete, "extract output is missing"
|
return ExtractionEvidenceObsolete, "extract output is missing", 0
|
||||||
}
|
}
|
||||||
if !safeExtractionComponents(root, path) {
|
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 {
|
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)
|
actual, err := SHA256File(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read"
|
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read", 0
|
||||||
}
|
}
|
||||||
if actual != checksum {
|
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)
|
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
|
||||||
if err != nil || !json.Valid(body) {
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
6
internal/buildinfo/buildinfo.go
Normal file
6
internal/buildinfo/buildinfo.go
Normal 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"
|
||||||
@@ -451,7 +451,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.depends_on must not include itself",
|
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:
|
scriptoriumYAML: `scriptorium:
|
||||||
binary: scriptorium
|
binary: scriptorium
|
||||||
artifacts:
|
artifacts:
|
||||||
@@ -476,7 +476,25 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
source: narratio.artifact.artifact_a
|
source: narratio.artifact.artifact_a
|
||||||
required: true
|
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",
|
name: "artifact source typo fails validation",
|
||||||
|
|||||||
@@ -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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,38 +983,47 @@ func validatePathWithinRoot(fieldName, value, root string) error {
|
|||||||
return fmt.Errorf("%s must be under %s/", fieldName, normalizedRoot)
|
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 {
|
if len(artifacts) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
enabled := make(map[string]struct{}, len(artifacts))
|
|
||||||
graph := make(map[string][]string, len(artifacts))
|
graph := make(map[string][]string, len(artifacts))
|
||||||
for name, cfg := range artifacts {
|
for name, cfg := range artifacts {
|
||||||
if !cfg.Enabled {
|
if !artifactpolicy.IsConfiguredKey(name) {
|
||||||
continue
|
return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$")
|
||||||
}
|
|
||||||
enabled[name] = struct{}{}
|
|
||||||
}
|
|
||||||
for name, cfg := range artifacts {
|
|
||||||
if !cfg.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
seen := make(map[string]struct{}, len(cfg.DependsOn))
|
||||||
for _, dep := range cfg.DependsOn {
|
for _, dep := range cfg.DependsOn {
|
||||||
trimmedDep := strings.TrimSpace(dep)
|
trimmedDep := strings.TrimSpace(dep)
|
||||||
if _, ok := enabled[trimmedDep]; ok {
|
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)
|
graph[name] = append(graph[name], trimmedDep)
|
||||||
}
|
}
|
||||||
}
|
sort.Strings(graph[name])
|
||||||
}
|
}
|
||||||
|
|
||||||
visiting := make(map[string]bool, len(enabled))
|
visiting := make(map[string]bool, len(artifacts))
|
||||||
visited := make(map[string]bool, len(enabled))
|
visited := make(map[string]bool, len(artifacts))
|
||||||
|
|
||||||
var visit func(node string) error
|
var visit func(node string) error
|
||||||
visit = func(node string) error {
|
visit = func(node string) error {
|
||||||
if visiting[node] {
|
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] {
|
if visited[node] {
|
||||||
return nil
|
return nil
|
||||||
@@ -1030,7 +1039,12 @@ func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArt
|
|||||||
return nil
|
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 {
|
if err := visit(node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
374
internal/manifest/analyze_state.go
Normal file
374
internal/manifest/analyze_state.go
Normal 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
|
||||||
|
}
|
||||||
309
internal/manifest/analyze_state_test.go
Normal file
309
internal/manifest/analyze_state_test.go
Normal 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"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,8 @@ type StageRecord struct {
|
|||||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||||
Error *ErrorRecord `json:"error,omitempty"`
|
Error *ErrorRecord `json:"error,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,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
|
// CleanupTarget records one root-confined local deletion requested by a
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type RunStageRecord struct {
|
|||||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||||
Error *ErrorRecord `json:"error,omitempty"`
|
Error *ErrorRecord `json:"error,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,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.
|
// RunManifest is the invocation-scoped execution record under runs/{run_id}/manifest.json.
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
|
|||||||
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
|
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
|
||||||
return fmt.Errorf("save manifest: %w", err)
|
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()
|
m.UpdatedAt = time.Now().UTC()
|
||||||
if m.Stages == nil {
|
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 {
|
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
||||||
return fmt.Errorf("save run manifest: %w", err)
|
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()
|
m.UpdatedAt = time.Now().UTC()
|
||||||
if m.Stages == nil {
|
if m.Stages == nil {
|
||||||
@@ -250,6 +268,14 @@ func validateLoadedManifest(m *Manifest) error {
|
|||||||
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
|
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -266,6 +292,7 @@ func normalizeManifest(m *Manifest) {
|
|||||||
if stage.Name == "" {
|
if stage.Name == "" {
|
||||||
stage.Name = 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 {
|
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -358,6 +393,7 @@ func normalizeRunManifest(m *RunManifest) {
|
|||||||
if stage.Name == "" {
|
if stage.Name == "" {
|
||||||
stage.Name = name
|
stage.Name = name
|
||||||
}
|
}
|
||||||
|
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package stage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
"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/artifactpolicy"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
@@ -28,6 +30,8 @@ type analyzeArtifactExecutionPlan struct {
|
|||||||
|
|
||||||
type analyzeArtifactExecutionResult struct {
|
type analyzeArtifactExecutionResult struct {
|
||||||
Output artifacts.Ref
|
Output artifacts.Ref
|
||||||
|
OutputSize int64
|
||||||
|
Scriptorium manifest.AnalyzeArtifactProvenance
|
||||||
Logs []string
|
Logs []string
|
||||||
GeneratedConfigs []string
|
GeneratedConfigs []string
|
||||||
Metadata map[string]any
|
Metadata map[string]any
|
||||||
@@ -44,21 +48,6 @@ type analyzeExecutionContext struct {
|
|||||||
Catalog *artifacts.ArtifactCatalog
|
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) {
|
func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
|
||||||
if env == nil || env.Config == nil {
|
if env == nil || env.Config == nil {
|
||||||
return nil, fmt.Errorf("analyze: stage environment config is required")
|
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 {
|
if env.Config.Pipeline == nil || env.Config.Session == nil {
|
||||||
return nil, fmt.Errorf("analyze: resolved config must include pipeline and session")
|
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
|
var sessionID string
|
||||||
if m != nil {
|
if m != nil {
|
||||||
sessionID = strings.TrimSpace(m.SessionID)
|
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",
|
"reason": "pipeline.scriptorium is not configured",
|
||||||
}}, nil
|
}}, 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
|
effective := env.EffectiveArtifacts
|
||||||
if !effective.Resolved() {
|
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)
|
return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
plans, skipReason, err := buildAnalyzeExecutionPlans(env.Config.Pipeline.Scriptorium, effective, runtimeCatalog)
|
if len(effective.Keys()) == 0 {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("analyze: %w", err)
|
|
||||||
}
|
|
||||||
if skipReason != "" {
|
|
||||||
return &StageResult{Metadata: map[string]any{
|
return &StageResult{Metadata: map[string]any{
|
||||||
"stage": "analyze",
|
"stage": "analyze",
|
||||||
"skipped": true,
|
"skipped": true,
|
||||||
"reason": skipReason,
|
"reason": "no selected scriptorium artifacts to execute",
|
||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,21 +128,81 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths),
|
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths),
|
||||||
Catalog: runtimeCatalog,
|
Catalog: runtimeCatalog,
|
||||||
}
|
}
|
||||||
|
reconciliation, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, execution)
|
||||||
outputs := make([]artifacts.Ref, 0, len(plans))
|
|
||||||
logs := []string{}
|
|
||||||
generatedConfigs := []string{}
|
|
||||||
artifactMetadata := make([]map[string]any, 0, len(plans))
|
|
||||||
reusedArtifacts := []map[string]any{}
|
|
||||||
reusedSeen := map[string]struct{}{}
|
|
||||||
|
|
||||||
for _, plan := range plans {
|
|
||||||
artifactResult, err := executeAnalyzeArtifact(ctx, execution, plan)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := []string{}
|
||||||
|
generatedConfigs := []string{}
|
||||||
|
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 _, 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 failedAnalyzeResult(
|
||||||
|
execution,
|
||||||
|
item,
|
||||||
|
artifactCfg,
|
||||||
|
fingerprint,
|
||||||
|
err,
|
||||||
|
sessionRecords,
|
||||||
|
invocationRecords,
|
||||||
|
), err
|
||||||
}
|
}
|
||||||
|
|
||||||
outputs = append(outputs, artifactResult.Output)
|
|
||||||
logs = append(logs, artifactResult.Logs...)
|
logs = append(logs, artifactResult.Logs...)
|
||||||
generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...)
|
generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...)
|
||||||
artifactMetadata = append(artifactMetadata, artifactResult.Metadata)
|
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)
|
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)
|
sourceID, ok := runtimeCatalog.SourceIDForConfiguredKey(plan.Name)
|
||||||
if !ok {
|
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 {
|
if err := runtimeCatalog.MarkAvailableGeneratedEvidence(
|
||||||
return nil, fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err)
|
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{
|
metadata := map[string]any{
|
||||||
"stage": "analyze",
|
"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,
|
"generated_artifacts": artifactMetadata,
|
||||||
"reused_artifacts": reusedArtifacts,
|
"reused_artifacts": reusedArtifacts,
|
||||||
"artifact_count": len(artifactMetadata),
|
"artifact_count": len(artifactMetadata),
|
||||||
@@ -193,133 +287,200 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &StageResult{
|
return &StageResult{
|
||||||
Outputs: outputs,
|
|
||||||
Logs: dedupeAndSortPaths(logs),
|
Logs: dedupeAndSortPaths(logs),
|
||||||
GeneratedConfigs: dedupeAndSortPaths(generatedConfigs),
|
GeneratedConfigs: dedupeAndSortPaths(generatedConfigs),
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
|
AnalyzeState: &AnalyzeStateProjection{
|
||||||
|
Session: sessionRecords,
|
||||||
|
Invocation: manifest.CloneAnalyzeArtifactCollection(invocationRecords),
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAnalyzeExecutionPlans(
|
func failedAnalyzeResult(
|
||||||
scriptoriumCfg *config.ScriptoriumConfig,
|
execution analyzeExecutionContext,
|
||||||
effective artifacts.EffectiveArtifactSet,
|
item analyzePlanItem,
|
||||||
catalog *artifacts.ArtifactCatalog,
|
artifactCfg config.ScriptoriumArtifactConfig,
|
||||||
) ([]analyzeArtifactExecutionPlan, string, error) {
|
fingerprint string,
|
||||||
if scriptoriumCfg == nil {
|
cause error,
|
||||||
return nil, "pipeline.scriptorium is not configured", nil
|
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 {
|
if fingerprint != "" {
|
||||||
return nil, "no scriptorium artifacts configured", nil
|
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
|
||||||
|
record.Fingerprint = fingerprint
|
||||||
}
|
}
|
||||||
|
if artifactCfg.PromptID != "" || artifactCfg.ProfileID != "" {
|
||||||
if len(effective.Keys()) == 0 {
|
record.Scriptorium = &manifest.AnalyzeArtifactProvenance{
|
||||||
return nil, "no selected scriptorium artifacts to execute", nil
|
PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID,
|
||||||
}
|
}
|
||||||
|
|
||||||
ordered, err := orderSelectedScriptoriumArtifacts(scriptoriumCfg.Artifacts, effective, catalog)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
}
|
||||||
|
sessionRecords[item.Key] = record
|
||||||
plans := make([]analyzeArtifactExecutionPlan, 0, len(ordered))
|
invocationRecords[item.Key] = record
|
||||||
for _, name := range ordered {
|
staleAnalyzeDependents(artifactCfgMap(execution), item.Key, sessionRecords)
|
||||||
artifactCfg, ok := scriptoriumCfg.Artifacts[name]
|
return &StageResult{AnalyzeState: &AnalyzeStateProjection{
|
||||||
if !ok {
|
Session: manifest.CloneAnalyzeArtifactCollection(sessionRecords),
|
||||||
return nil, "", fmt.Errorf("selected artifact %q is not configured", name)
|
Invocation: manifest.CloneAnalyzeArtifactCollection(invocationRecords),
|
||||||
}
|
}}
|
||||||
plans = append(plans, analyzeArtifactExecutionPlan{Name: name, Cfg: artifactCfg})
|
|
||||||
}
|
|
||||||
return plans, "", nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func orderSelectedScriptoriumArtifacts(
|
func artifactCfgMap(execution analyzeExecutionContext) map[string]config.ScriptoriumArtifactConfig {
|
||||||
artifactsCfg map[string]config.ScriptoriumArtifactConfig,
|
if execution.Env == nil || execution.Env.Config == nil || execution.Env.Config.Pipeline == nil ||
|
||||||
effective artifacts.EffectiveArtifactSet,
|
execution.Env.Config.Pipeline.Scriptorium == nil {
|
||||||
catalog *artifacts.ArtifactCatalog,
|
return nil
|
||||||
) ([]string, error) {
|
|
||||||
selectedSet := map[string]struct{}{}
|
|
||||||
selected := effective.Keys()
|
|
||||||
for _, key := range selected {
|
|
||||||
selectedSet[key] = struct{}{}
|
|
||||||
}
|
}
|
||||||
|
return execution.Env.Config.Pipeline.Scriptorium.Artifacts
|
||||||
|
}
|
||||||
|
|
||||||
dependencyErrors := []string{}
|
func staleAnalyzeDependents(
|
||||||
for _, selectedKey := range selected {
|
configured map[string]config.ScriptoriumArtifactConfig,
|
||||||
cfg, ok := artifactsCfg[selectedKey]
|
changed string,
|
||||||
if !ok {
|
records map[string]manifest.AnalyzeArtifactRecord,
|
||||||
dependencyErrors = append(dependencyErrors, fmt.Sprintf("selected artifact %q is not configured", selectedKey))
|
) {
|
||||||
|
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
|
continue
|
||||||
}
|
}
|
||||||
for _, dep := range cfg.DependsOn {
|
seen[key] = struct{}{}
|
||||||
trimmedDep := strings.TrimSpace(dep)
|
if eligible != nil && !eligible(key) {
|
||||||
if trimmedDep == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, ok := selectedSet[trimmedDep]; ok {
|
staleProjectedAnalyzeRecord(records, key)
|
||||||
continue
|
queue = append(queue, reverse[key]...)
|
||||||
}
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(dependencyErrors) > 0 {
|
|
||||||
return nil, errors.New(strings.Join(dependencyErrors, "; "))
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
indegree := map[string]int{}
|
func analyzePlanKeysForMetadata(items []analyzePlanItem) []string {
|
||||||
edges := map[string][]string{}
|
if len(items) == 0 {
|
||||||
for _, key := range selected {
|
return nil
|
||||||
indegree[key] = 0
|
|
||||||
}
|
|
||||||
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 {
|
func cloneAnalyzeOutputContract(value *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
|
||||||
sort.Strings(edges[key])
|
if value == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
cloned := *value
|
||||||
ready := make([]string, 0, len(indegree))
|
return &cloned
|
||||||
for key, degree := range indegree {
|
|
||||||
if degree == 0 {
|
|
||||||
ready = append(ready, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(ready)
|
|
||||||
|
|
||||||
order := make([]string, 0, len(selectedSet))
|
|
||||||
for len(ready) > 0 {
|
|
||||||
node := ready[0]
|
|
||||||
ready = ready[1:]
|
|
||||||
order = append(order, node)
|
|
||||||
for _, dep := range edges[node] {
|
|
||||||
indegree[dep]--
|
|
||||||
if indegree[dep] == 0 {
|
|
||||||
ready = append(ready, dep)
|
|
||||||
sort.Strings(ready)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(order) != len(selectedSet) {
|
|
||||||
return nil, fmt.Errorf("selected scriptorium artifacts contain a dependency cycle")
|
|
||||||
}
|
|
||||||
return order, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeAnalyzeArtifact(
|
func executeAnalyzeArtifact(
|
||||||
@@ -335,34 +496,25 @@ func executeAnalyzeArtifact(
|
|||||||
artifactName := plan.Name
|
artifactName := plan.Name
|
||||||
artifactCfg := plan.Cfg
|
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{}
|
omittedOptionalInputs := []string{}
|
||||||
reusedArtifacts := []map[string]any{}
|
reusedArtifacts := []map[string]any{}
|
||||||
|
for _, identity := range resolvedInputs.Ordered {
|
||||||
inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs)
|
if !identity.Present {
|
||||||
for _, inputName := range inputNames {
|
omittedOptionalInputs = append(omittedOptionalInputs, identity.Name)
|
||||||
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)
|
|
||||||
continue
|
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{
|
reusedArtifacts = append(reusedArtifacts, map[string]any{
|
||||||
"name": configuredArtifactNameFromSourceID(resolution.Artifact.ID),
|
"name": configuredArtifactNameFromSourceID(resolvedArtifact.ID),
|
||||||
"source_id": resolution.Artifact.ID,
|
"source_id": resolvedArtifact.ID,
|
||||||
"path": resolution.Artifact.Path,
|
"path": resolvedArtifact.Path,
|
||||||
"provenance": resolution.Artifact.Provenance,
|
"provenance": resolvedArtifact.Provenance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,14 +680,32 @@ func executeAnalyzeArtifact(
|
|||||||
if err := requireNonEmptyFile(finalOutputPath, artifactName+" output"); err != nil {
|
if err := requireNonEmptyFile(finalOutputPath, artifactName+" output"); err != nil {
|
||||||
return nil, fmt.Errorf("analyze: %w", err)
|
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{
|
materializedArtifact, err := materializeRunLocalOutput(env.ArtifactStore, finalOutputPath, canonicalOutputPath, artifacts.Ref{
|
||||||
Kind: artifactName,
|
Kind: artifactName,
|
||||||
|
SourceID: artifacts.ConfiguredArtifactSourceID(artifactName),
|
||||||
Category: "artifacts",
|
Category: "artifacts",
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
|
RelativePath: relativeOutputPath,
|
||||||
|
Contract: contract,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("analyze: materialize artifact output for %q: %w", artifactName, err)
|
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)
|
logPaths = append(logPaths, stdoutLogPath, stderrLogPath)
|
||||||
generatedConfigs = append(generatedConfigs, generatedConfigPath)
|
generatedConfigs = append(generatedConfigs, generatedConfigPath)
|
||||||
@@ -562,6 +732,10 @@ func executeAnalyzeArtifact(
|
|||||||
|
|
||||||
return &analyzeArtifactExecutionResult{
|
return &analyzeArtifactExecutionResult{
|
||||||
Output: materializedArtifact,
|
Output: materializedArtifact,
|
||||||
|
OutputSize: int64(len(validatedOutput)),
|
||||||
|
Scriptorium: manifest.AnalyzeArtifactProvenance{
|
||||||
|
PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, CommandMode: res.CommandMode,
|
||||||
|
},
|
||||||
Logs: logPaths,
|
Logs: logPaths,
|
||||||
GeneratedConfigs: generatedConfigs,
|
GeneratedConfigs: generatedConfigs,
|
||||||
Metadata: meta,
|
Metadata: meta,
|
||||||
@@ -569,17 +743,6 @@ func executeAnalyzeArtifact(
|
|||||||
}, nil
|
}, 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 {
|
func configuredArtifactNameFromSourceID(sourceID string) string {
|
||||||
name, _ := artifactpolicy.ParseConfiguredSource(sourceID)
|
name, _ := artifactpolicy.ParseConfiguredSource(sourceID)
|
||||||
return name
|
return name
|
||||||
@@ -620,92 +783,6 @@ func discoverAnalyzeArtifactRef(m *manifest.Manifest, paths artifacts.SessionPat
|
|||||||
return resolved.Path, resolved.Provenance
|
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, ©)
|
|
||||||
}
|
|
||||||
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, ©)
|
|
||||||
}
|
|
||||||
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 {
|
func requiredBuiltInInputError(source string, execution analyzeExecutionContext) error {
|
||||||
entry, ok := execution.Catalog.Lookup(source)
|
entry, ok := execution.Catalog.Lookup(source)
|
||||||
if !ok || strings.TrimSpace(entry.ProducerStage) == "" {
|
if !ok || strings.TrimSpace(entry.ProducerStage) == "" {
|
||||||
@@ -738,25 +815,7 @@ func buildAnalyzeRuntimeArtifactCatalog(
|
|||||||
if notariusCfg != nil && notariusCfg.Enabled {
|
if notariusCfg != nil && notariusCfg.Enabled {
|
||||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||||
}
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return catalog, nil
|
return catalog, nil
|
||||||
}
|
}
|
||||||
|
|||||||
45
internal/stage/analyze_evidence_test_helpers_test.go
Normal file
45
internal/stage/analyze_evidence_test_helpers_test.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
217
internal/stage/analyze_failure_test.go
Normal file
217
internal/stage/analyze_failure_test.go
Normal 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"},
|
||||||
|
}
|
||||||
|
}
|
||||||
322
internal/stage/analyze_fingerprint.go
Normal file
322
internal/stage/analyze_fingerprint.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
306
internal/stage/analyze_fingerprint_test.go
Normal file
306
internal/stage/analyze_fingerprint_test.go
Normal 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
|
||||||
|
}
|
||||||
249
internal/stage/analyze_incremental_execution_test.go
Normal file
249
internal/stage/analyze_incremental_execution_test.go
Normal 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)
|
||||||
|
}
|
||||||
294
internal/stage/analyze_input_identity.go
Normal file
294
internal/stage/analyze_input_identity.go
Normal 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 ©
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ©, 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
|
||||||
|
}
|
||||||
365
internal/stage/analyze_input_identity_test.go
Normal file
365
internal/stage/analyze_input_identity_test.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
231
internal/stage/analyze_plan.go
Normal file
231
internal/stage/analyze_plan.go
Normal 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
|
||||||
|
}
|
||||||
341
internal/stage/analyze_plan_test.go
Normal file
341
internal/stage/analyze_plan_test.go
Normal 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
|
||||||
|
}
|
||||||
201
internal/stage/analyze_reconciliation.go
Normal file
201
internal/stage/analyze_reconciliation.go
Normal 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 ©
|
||||||
|
}
|
||||||
242
internal/stage/analyze_reconciliation_test.go
Normal file
242
internal/stage/analyze_reconciliation_test.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
94
internal/stage/analyze_resume.go
Normal file
94
internal/stage/analyze_resume.go
Normal 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
|
||||||
|
}
|
||||||
160
internal/stage/analyze_resume_test.go
Normal file
160
internal/stage/analyze_resume_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -62,8 +62,8 @@ func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) {
|
|||||||
t.Fatalf("session_id var = %q, want sticky narratio session id", req.Vars["session_id"])
|
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" {
|
if result.AnalyzeState == nil || result.AnalyzeState.Invocation["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
|
||||||
t.Fatalf("outputs = %#v, want one session_recap output", result.Outputs)
|
t.Fatalf("analyze state = %#v, want current session_recap", result.AnalyzeState)
|
||||||
}
|
}
|
||||||
if len(result.Logs) != 2 {
|
if len(result.Logs) != 2 {
|
||||||
t.Fatalf("logs = %#v, want stdout+stderr logs", result.Logs)
|
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")) {
|
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)
|
t.Fatalf("run output path = %q, want run-local path", fake.RunRequests[0].OutputPath)
|
||||||
}
|
}
|
||||||
if len(result.Outputs) != 1 {
|
if result.AnalyzeState == nil || result.AnalyzeState.Invocation["session_recap"].Output == nil {
|
||||||
t.Fatalf("outputs len = %d, want 1", len(result.Outputs))
|
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)) {
|
if err := requireNonEmptyFile(filepath.Join(paths.ArtifactsDir, "session_recap.md"), "materialized output"); err != nil {
|
||||||
t.Fatalf("materialized output path = %q, want canonical session path", result.Outputs[0].AbsolutePath)
|
t.Fatalf("canonical output = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +468,7 @@ func TestAnalyzeResolvesConfiguredArtifactInputFromDisabledArtifactOutput(t *tes
|
|||||||
Enabled: false,
|
Enabled: false,
|
||||||
OutputPath: "artifacts/player_handout.md",
|
OutputPath: "artifacts/player_handout.md",
|
||||||
}
|
}
|
||||||
|
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", playerHandoutPath)
|
||||||
|
|
||||||
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
if err != nil {
|
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) {
|
func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
|
||||||
env, m, _ := setupAnalyzeEnv(t)
|
env, m, _ := setupAnalyzeEnv(t)
|
||||||
paths := sessionPathsForEnv(env, m.SessionID)
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
@@ -498,6 +518,7 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
|
|||||||
Enabled: false,
|
Enabled: false,
|
||||||
OutputPath: "artifacts/player_handout.md",
|
OutputPath: "artifacts/player_handout.md",
|
||||||
}
|
}
|
||||||
|
setCurrentAnalyzeEvidence(t, m, "player_handout", "artifacts/player_handout.md", playerHandoutPath)
|
||||||
|
|
||||||
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -545,8 +566,8 @@ func TestAnalyzeMetadataIncludesGeneratedAndReusedArtifacts(t *testing.T) {
|
|||||||
if r0["path"] != playerHandoutPath {
|
if r0["path"] != playerHandoutPath {
|
||||||
t.Fatalf("reused[0].path = %#v, want %q", r0["path"], playerHandoutPath)
|
t.Fatalf("reused[0].path = %#v, want %q", r0["path"], playerHandoutPath)
|
||||||
}
|
}
|
||||||
if r0["provenance"] != artifacts.ArtifactProvenanceDisabledFromDisk {
|
if r0["provenance"] != artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
|
||||||
t.Fatalf("reused[0].provenance = %#v, want %q", r0["provenance"], artifacts.ArtifactProvenanceDisabledFromDisk)
|
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" {
|
if fake.RunRequests[0].PromptID != "dnd.player_handout" {
|
||||||
t.Fatalf("prompt id = %q, want dnd.player_handout", fake.RunRequests[0].PromptID)
|
t.Fatalf("prompt id = %q, want dnd.player_handout", fake.RunRequests[0].PromptID)
|
||||||
}
|
}
|
||||||
if len(result.Outputs) != 1 || result.Outputs[0].Kind != "player_handout" {
|
if result.AnalyzeState == nil || len(result.AnalyzeState.Invocation) != 1 || result.AnalyzeState.Invocation["player_handout"].Status != manifest.AnalyzeArtifactCurrent {
|
||||||
t.Fatalf("outputs = %#v, want only player_handout", result.Outputs)
|
t.Fatalf("analyze state = %#v, want only current player_handout", result.AnalyzeState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1368,11 +1389,9 @@ func TestAnalyzeRecordsRefsAndMetadata(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(result.Outputs) != 1 {
|
record, ok := result.AnalyzeState.Invocation["session_recap"]
|
||||||
t.Fatalf("outputs len = %d, want 1", len(result.Outputs))
|
if !ok || record.Output == nil || record.Output.LocalPath != "artifacts/session_recap.md" {
|
||||||
}
|
t.Fatalf("analyze output record = %#v, want session recap evidence", record)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
if len(result.Logs) != 2 {
|
if len(result.Logs) != 2 {
|
||||||
t.Fatalf("logs = %#v, want two logs", result.Logs)
|
t.Fatalf("logs = %#v, want two logs", result.Logs)
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ func All() []Stage {
|
|||||||
polishStage{},
|
polishStage{},
|
||||||
normalizeStage{},
|
normalizeStage{},
|
||||||
trimStage{},
|
trimStage{},
|
||||||
extractStage{},
|
|
||||||
renderStage{},
|
renderStage{},
|
||||||
|
extractStage{},
|
||||||
analyzeStage{},
|
analyzeStage{},
|
||||||
publishStage{},
|
publishStage{},
|
||||||
placeholderStage{name: "notify"},
|
placeholderStage{name: "notify"},
|
||||||
|
|||||||
@@ -839,51 +839,11 @@ func buildPublishRuntimeArtifactCatalog(
|
|||||||
if notariusCfg != nil && notariusCfg.Enabled {
|
if notariusCfg != nil && notariusCfg.Enabled {
|
||||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||||
}
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
for _, entry := range catalog.ListConfigured() {
|
|
||||||
if strings.TrimSpace(entry.CanonicalRelPath) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
localPath, err := resolveConfiguredArtifactLocalPath(paths, entry.CanonicalRelPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
info, statErr := os.Stat(localPath)
|
|
||||||
if statErr != nil {
|
|
||||||
if os.IsNotExist(statErr) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("stat configured artifact %q: %w", entry.SourceID, statErr)
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := catalog.MarkAvailableFromDisk(entry.SourceID, localPath); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return catalog, nil
|
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) {
|
func collectPublishRunFiles(runRoot string, runManifest *manifest.RunManifest) ([]publishUploadFile, error) {
|
||||||
if runManifest == nil {
|
if runManifest == nil {
|
||||||
return nil, fmt.Errorf("run manifest is required")
|
return nil, fmt.Errorf("run manifest is required")
|
||||||
|
|||||||
@@ -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) {
|
func TestPublishLockedSelectedOutputSkipsAsLocked(t *testing.T) {
|
||||||
env, m, _ := publishFixture(t)
|
env, m, _ := publishFixture(t)
|
||||||
env.SelectedArtifactKeys = []string{"session_recap"}
|
env.SelectedArtifactKeys = []string{"session_recap"}
|
||||||
@@ -946,10 +994,12 @@ func TestPublishRejectsAmbiguousOrCollidingOutputMappings(t *testing.T) {
|
|||||||
|
|
||||||
func TestPublishKeepsSameBasenameSourcesDistinct(t *testing.T) {
|
func TestPublishKeepsSameBasenameSourcesDistinct(t *testing.T) {
|
||||||
env, m, _ := publishFixture(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{
|
env.Config.Pipeline.Scriptorium.Artifacts["other_recap"] = config.ScriptoriumArtifactConfig{
|
||||||
Enabled: true, PromptID: "dnd.other_recap", OutputPath: "reports/session_recap.md",
|
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{
|
env.Config.Pipeline.Publish.Outputs = []config.PublishOutputRule{
|
||||||
{Source: "narratio.artifact.session_recap", Dest: "published/first/session_recap.md", Required: boolPtr(true)},
|
{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)},
|
{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 {
|
for _, name := range publishPrerequisiteStages {
|
||||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 16, 1, 2, 3, 0, time.UTC), nil)
|
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{
|
env := &Env{
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Env struct {
|
|||||||
ArtifactStore artifacts.Store
|
ArtifactStore artifacts.Store
|
||||||
ManifestStore manifest.Store
|
ManifestStore manifest.Store
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
Force bool
|
||||||
|
|
||||||
WhisperX whisperx.Client
|
WhisperX whisperx.Client
|
||||||
Seriatim seriatim.Runner
|
Seriatim seriatim.Runner
|
||||||
@@ -52,14 +53,35 @@ const maxResumeReasonLength = 512
|
|||||||
type ResumeValidation struct {
|
type ResumeValidation struct {
|
||||||
Resumable bool
|
Resumable bool
|
||||||
Reason string
|
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.
|
// Normalized returns a result with a bounded reason and no reason on success.
|
||||||
func (r ResumeValidation) Normalized() ResumeValidation {
|
func (r ResumeValidation) Normalized() ResumeValidation {
|
||||||
if r.Resumable {
|
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.
|
// ResumeValidator is implemented by stages that validate persisted success before reuse.
|
||||||
@@ -105,4 +127,12 @@ type StageResult struct {
|
|||||||
Logs []string
|
Logs []string
|
||||||
GeneratedConfigs []string
|
GeneratedConfigs []string
|
||||||
Metadata map[string]any
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user