Compare commits
39 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 | |||
| 5cec84a4a7 | |||
| abfbe42d61 | |||
| e7e3bef1e4 | |||
| a68e8e31a4 | |||
| 3a9e60cda9 | |||
| 905ff03ccc | |||
| 495f7bcde4 | |||
| 51e0e8c5d0 | |||
| a2409a1fd1 | |||
| b3363f87d6 | |||
| 5831c0c9e6 | |||
| 42ed81cbe1 |
@@ -58,6 +58,16 @@ steps:
|
||||
build_binary windows amd64 ".exe"
|
||||
build_binary windows arm64 ".exe"
|
||||
|
||||
smoke_binary="$dist/narratio-version-smoke"
|
||||
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=$version" \
|
||||
-o "$smoke_binary" "$pkg"
|
||||
reported_version="$("$smoke_binary" version)"
|
||||
rm -f "$smoke_binary"
|
||||
if [ "$reported_version" != "narratio $version" ]; then
|
||||
echo "release binary reported unexpected version: $reported_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish-release:
|
||||
image: woodpeckerci/plugin-release
|
||||
depends_on:
|
||||
|
||||
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:
|
||||
|
||||
- `run <session_id>`: run full stage order.
|
||||
- `version`: print the Narratio build version.
|
||||
- `run <session_id>`: run all or one contiguous range of the canonical stage order.
|
||||
- `regenerate-artifacts <session_id>`: force-run extraction through analysis.
|
||||
- `run-stage <stage> <session_id>`: run one stage.
|
||||
- `analyze <session_id>`: force-run analyze.
|
||||
- `publish <session_id>`: force-run publish.
|
||||
@@ -70,22 +72,63 @@ Commands with additional positionals keep their command-specific order:
|
||||
|
||||
## Command Reference
|
||||
|
||||
### `version`
|
||||
|
||||
```bash
|
||||
narratio version
|
||||
```
|
||||
|
||||
Official release binaries report their exact Git tag. Binaries built directly
|
||||
from source without release linker metadata report `dev`.
|
||||
|
||||
### `run`
|
||||
|
||||
```bash
|
||||
narratio run <session_id> [--force] [--artifacts <name[,name...]>] [...common config flags]
|
||||
narratio run <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- evaluates full stage order;
|
||||
- runs `extract` between `trim` and `render`; an omitted or disabled Notarius
|
||||
- evaluates one inclusive contiguous range of the canonical stage order;
|
||||
- defaults an omitted `--from` to `prepare` and an omitted `--through` to
|
||||
`notify`, so omitting both retains full-pipeline behavior;
|
||||
- rejects unknown endpoints and a `--from` endpoint after `--through`;
|
||||
- runs `render` before `extract`; an omitted or disabled Notarius
|
||||
configuration records an explicit `notarius_disabled` self-skip;
|
||||
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
||||
resume check finds its durable result obsolete;
|
||||
- applies `--force` only to stages in the selected range;
|
||||
- rejects repeated `--from`, `--through`, or `--force` options, including
|
||||
`--name=value` spellings;
|
||||
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||
- writes session and run manifests.
|
||||
|
||||
When `--artifacts` is present, the selected range must contain `analyze` or
|
||||
`publish`. Either consumer is sufficient, including a one-stage range.
|
||||
|
||||
### `regenerate-artifacts`
|
||||
|
||||
```bash
|
||||
narratio regenerate-artifacts <session_id> [--artifacts <name[,name...]>] [...common config flags]
|
||||
```
|
||||
|
||||
Exactly equivalent to:
|
||||
|
||||
```bash
|
||||
narratio run <session_id> --force --from extract --through analyze [caller options]
|
||||
```
|
||||
|
||||
The command always reruns extraction. Analysis rebuilds the selected configured
|
||||
artifacts and any prerequisites required by those targets; without
|
||||
`--artifacts`, it uses the normal default analysis selection. Publish and notify
|
||||
never run. Common session/configuration options and repeatable artifact values
|
||||
pass through unchanged.
|
||||
|
||||
Because the expansion owns `--force`, `--from`, and `--through`, callers cannot
|
||||
supply those options. The shared `run` parser reports them as duplicate
|
||||
singleton flags. The alias has no private execution options or behavior, and
|
||||
runtime diagnostics may identify the operation as `run`.
|
||||
|
||||
### `run-stage`
|
||||
|
||||
```bash
|
||||
@@ -100,8 +143,8 @@ Valid stage names:
|
||||
- `polish`
|
||||
- `normalize`
|
||||
- `trim`
|
||||
- `extract`
|
||||
- `render`
|
||||
- `extract`
|
||||
- `analyze`
|
||||
- `publish`
|
||||
- `notify`
|
||||
@@ -153,10 +196,17 @@ post-publish cleanup behavior.
|
||||
### `session plan`
|
||||
|
||||
```bash
|
||||
narratio session plan <session_id> [--force] [...common config flags]
|
||||
narratio session plan <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
|
||||
```
|
||||
|
||||
Validates config, prepares local workdir layout, and prints run/skip decisions for each stage.
|
||||
Uses the same inclusive bounds, endpoint validation, force scope, and artifact
|
||||
selection contract as `run`. It validates config and prints run/skip decisions
|
||||
for selected stages only without creating the local workdir or changing the
|
||||
manifest. Resume-capable selected stages are checked against durable evidence.
|
||||
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
|
||||
execution order, and reusable current artifacts with concise reasons. These
|
||||
artifact decisions come from the same reconciliation and work planner used by
|
||||
execution; the preview does not predict output identities.
|
||||
|
||||
### `session validate`
|
||||
|
||||
@@ -252,14 +302,17 @@ and precedence.
|
||||
|
||||
## `--artifacts` Selection Rules
|
||||
|
||||
- accepted on `run`, `run-stage`, `analyze`, and `publish`;
|
||||
- accepted on `run`, `session plan`, `run-stage`, `analyze`, and `publish`;
|
||||
- repeatable and comma-separated values are combined, surrounding whitespace
|
||||
is removed, and duplicate names are collapsed;
|
||||
- names must exist in `pipeline.scriptorium.artifacts`;
|
||||
- empty entries are invalid;
|
||||
- repeated names are deduplicated.
|
||||
- on `run-stage`, only `analyze` and `publish` accept the option.
|
||||
|
||||
Effects:
|
||||
|
||||
- filters analyze execution to selected configured artifacts;
|
||||
- selects explicit analyze targets; required configured prerequisites may be
|
||||
reused or rebuilt before them;
|
||||
- filters publish rules that source `narratio.artifact.<name>`;
|
||||
- does not filter built-in transcript/bounds or explicitly configured
|
||||
`narratio.extraction.<name>` publish sources; and
|
||||
@@ -291,6 +344,12 @@ Force publish only:
|
||||
narratio publish 2026-04-04
|
||||
```
|
||||
|
||||
Regenerate post-transcript artifacts without publishing:
|
||||
|
||||
```bash
|
||||
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
|
||||
```
|
||||
|
||||
## Output And Exit Behavior
|
||||
|
||||
- Successful commands write their result or summary to standard output and
|
||||
|
||||
@@ -60,7 +60,10 @@ remote state with an unsafe legacy identity must be migrated before use.
|
||||
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
|
||||
- Pipeline defaults are applied before validation.
|
||||
- Campaign and session identities must agree.
|
||||
- Stable files (`speakers_file`, `autocorrect_file`, `glossary_file`, `players_file`, `party_file`) resolve from session overrides when provided, otherwise from campaign defaults.
|
||||
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
||||
`players_file`, `party_file`) and the optional `spell_catalog_file` resolve
|
||||
from session overrides when provided, otherwise from campaign defaults. An
|
||||
empty or omitted session spell-catalog value inherits the campaign value.
|
||||
- Exactly one audio mode must be configured in session input:
|
||||
- local (`audio_dir` or `audio_files`), or
|
||||
- S3 (`audio_s3.prefix`).
|
||||
@@ -227,6 +230,7 @@ Rules:
|
||||
| `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled |
|
||||
| `pipeline.notarius.timeout` | duration | No | `3h`; must be positive |
|
||||
| `pipeline.notarius.working_directory` | string | No | directory containing resolved `config_path`; relative paths resolve from the pipeline file directory |
|
||||
| `pipeline.notarius.references` | map[string]string | No | empty; maps normalized Notarius selectors to supported prepared Narratio source IDs; maximum 256 entries |
|
||||
| `pipeline.notarius.outputs` | map | Conditional | at least one entry when enabled |
|
||||
| `pipeline.render.enabled` | bool | No | `true` |
|
||||
| `pipeline.render.format` | string | No | `markdown` (only supported value) |
|
||||
@@ -241,6 +245,44 @@ Rules:
|
||||
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
||||
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
|
||||
|
||||
### Notarius Reference Bindings
|
||||
|
||||
`pipeline.notarius.references` maps a Notarius CLI selector to a prepared
|
||||
Narratio source, not to a filesystem path:
|
||||
|
||||
```yaml
|
||||
notarius:
|
||||
references:
|
||||
glossary: narratio.input.glossary
|
||||
party: narratio.input.party
|
||||
players: narratio.input.players
|
||||
spell_catalog: narratio.input.spell_catalog
|
||||
```
|
||||
|
||||
Supported sources are `narratio.input.party`, `narratio.input.players`,
|
||||
`narratio.input.glossary`, and `narratio.input.spell_catalog`. Each map entry is
|
||||
required by its presence: omit a binding when the selected Notarius pipeline
|
||||
does not need it. A spell-catalog binding additionally requires an effective
|
||||
campaign or session `spell_catalog_file`.
|
||||
|
||||
Selectors accept Notarius's `slot`, `chunk.slot`, `lane.slot`,
|
||||
`lane.extract.slot`, `lane.merge.slot`, and `lane.normalize.slot` forms.
|
||||
Narratio trims whitespace around
|
||||
selectors and their dot-separated components, rejects empty components and
|
||||
`=`, rejects duplicate normalized selectors, and limits the map to 256 entries.
|
||||
It validates only selector structure and the prepared source vocabulary;
|
||||
Notarius owns target-slot declarations and media compatibility.
|
||||
|
||||
Before extraction, Narratio resolves every binding from the current prepared
|
||||
session manifest and streams it into a verified invocation-local snapshot whose
|
||||
absolute path is passed to Notarius. Missing, unsafe, empty,
|
||||
changed-during-copy, or checksum-inconsistent prepared evidence fails with
|
||||
guidance to force `prepare`. Bindings are sorted by normalized selector and are
|
||||
part of extraction fingerprint and resume identity. See the
|
||||
[Notarius integration contract](./integrations/notarius.md) for the subprocess
|
||||
boundary and the [complete example](../examples/pipeline.full.annotated.yml)
|
||||
for a copyable configuration.
|
||||
|
||||
### Notarius Output Entries
|
||||
|
||||
For each `pipeline.notarius.outputs.<name>`:
|
||||
@@ -268,7 +310,7 @@ For each `pipeline.scriptorium.artifacts.<name>`:
|
||||
| Field | Type | Required | Rule |
|
||||
| --- | --- | --- | --- |
|
||||
| `enabled` | bool | No | `false` if omitted |
|
||||
| `depends_on[]` | list[string] | No | must reference configured artifact keys; no self-reference; enabled graph must be acyclic |
|
||||
| `depends_on[]` | list[string] | No | must reference configured artifact keys; no self-reference; configured graph must be acyclic |
|
||||
| `render_debug` | bool | No | per-artifact override |
|
||||
| `prompt_id` | string | Conditional | required when artifact is enabled |
|
||||
| `profile_id` | string | No | empty |
|
||||
@@ -281,12 +323,13 @@ Narratio adds `session_id=narratio-session-<session_id>` to every Scriptorium re
|
||||
|
||||
Without `--artifacts`, analyze executes enabled configured artifacts. With an
|
||||
explicit `--artifacts` list, the exact named configured artifacts are the
|
||||
one-invocation execution set even if their `enabled` values are false; the list
|
||||
does not automatically include dependencies. Named artifacts must therefore be
|
||||
configured with valid executable fields, and their configured dependencies must
|
||||
already be available to analyze. This override affects analyze planning only;
|
||||
publish uses the list only to filter configured
|
||||
`narratio.artifact.<name>` output rules.
|
||||
one-invocation targets even if their `enabled` values are false. Analyze closes
|
||||
those targets over `depends_on`: a current prerequisite is reused, while a
|
||||
stale, missing, failed, or legacy prerequisite is rebuilt before its dependent.
|
||||
Unrelated artifacts are not executed. Named targets and any prerequisite that
|
||||
may require rebuilding must therefore have valid executable fields. This
|
||||
override affects analyze planning only; publish uses the list only to filter
|
||||
configured `narratio.artifact.<name>` output rules.
|
||||
|
||||
For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_name>`:
|
||||
|
||||
@@ -318,6 +361,7 @@ integration.
|
||||
| `inputs.glossary_file` | string | Yes | stable input default |
|
||||
| `inputs.players_file` | string | Yes | stable input default |
|
||||
| `inputs.party_file` | string | Yes | stable input default |
|
||||
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
||||
|
||||
### Session
|
||||
|
||||
@@ -333,6 +377,7 @@ integration.
|
||||
| `inputs.glossary_file` | string | No | overrides campaign stable input |
|
||||
| `inputs.players_file` | string | No | overrides campaign stable input |
|
||||
| `inputs.party_file` | string | No | overrides campaign stable input |
|
||||
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
||||
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
||||
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
||||
| `inputs.audio_s3.prefix` | string | Conditional | S3 audio mode |
|
||||
|
||||
@@ -7,12 +7,14 @@ lanes from the final trimmed Seriatim transcript. Narratio owns invocation,
|
||||
safe bundle discovery, lane selection, and its own artifact metadata. Notarius
|
||||
owns pipeline definitions, lane schemas, the receipt, and bundle formats.
|
||||
|
||||
Canonical Notarius references:
|
||||
Canonical Notarius v0.6.0 references:
|
||||
|
||||
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/consumers/subprocess.md)
|
||||
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/consumers/dnd-pipeline.md)
|
||||
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/integrations/run-result.md)
|
||||
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/integrations/json-output.md)
|
||||
- [CLI reference](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/cli.md)
|
||||
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/consumers/subprocess.md)
|
||||
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/consumers/dnd-pipeline.md)
|
||||
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/run-result.md)
|
||||
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/json-output.md)
|
||||
- [D&D spell-catalog overlay](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/integrations/dnd-spell-catalog-overlays.md)
|
||||
|
||||
The [complete Narratio example](../../examples/pipeline.full.annotated.yml)
|
||||
records the exact current constraints for all ten D&D lanes. Treat the linked
|
||||
@@ -23,12 +25,43 @@ duplicate the complete schemas.
|
||||
|
||||
When `pipeline.notarius.enabled` is true, Narratio resolves the executable,
|
||||
configuration path, input path, output directory, and working directory to
|
||||
absolute paths and invokes:
|
||||
absolute paths. Narratio requires the Notarius v0.6.0 CLI contract when
|
||||
references are configured and invokes each binding as a separate argument
|
||||
before `--json`:
|
||||
|
||||
```text
|
||||
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> --json
|
||||
notarius run <pipeline_id> --config <config_path> --input <trimmed_json> --output-dir <staging_dir> [--reference <selector>=<verified_snapshot_path>]... --json
|
||||
```
|
||||
|
||||
Reference paths are absolute invocation-local snapshots streamed from the
|
||||
manifest-verified canonical files prepared inside the current Narratio session
|
||||
workspace. Narratio verifies snapshot checksum and size before and after the
|
||||
subprocess, and passes only configured bindings, ordered lexically by normalized
|
||||
selector, as direct argument-vector entries without shell interpretation. A CLI
|
||||
binding takes precedence over a matching external path in Notarius
|
||||
configuration. Narratio never emits `--without-reference`.
|
||||
|
||||
The maintained D&D boundary binds only the four campaign-owned external slots:
|
||||
|
||||
```text
|
||||
notarius run dnd-session \
|
||||
--config <absolute config path> \
|
||||
--input <absolute trimmed transcript path> \
|
||||
--output-dir <absolute staging directory> \
|
||||
--reference glossary=<absolute verified glossary snapshot> \
|
||||
--reference party=<absolute verified party snapshot> \
|
||||
--reference players=<absolute verified players snapshot> \
|
||||
--reference spell_catalog=<absolute verified spell catalog snapshot> \
|
||||
--json
|
||||
```
|
||||
|
||||
The spell-catalog binding is omitted when the campaign does not maintain that
|
||||
optional overlay. Registry, scene-description, combat-turn, and occurrence
|
||||
handoffs generated during the same Notarius run remain in Notarius pipeline
|
||||
composition and must not be emitted as CLI references. The linked CLI and D&D
|
||||
consumer documents own selector targeting, declared slots, media compatibility,
|
||||
and generated-handoff collision rules.
|
||||
|
||||
Standard output is reserved for the JSON receipt. Standard error is captured
|
||||
separately as diagnostic output. Narratio applies the configured timeout and
|
||||
does not interpret stdout as a receipt unless the subprocess exits successfully.
|
||||
@@ -38,24 +71,31 @@ environment apply to the subprocess.
|
||||
|
||||
## Accepted Result
|
||||
|
||||
Narratio currently accepts receipt schema `notarius.run-result.v1`. The receipt
|
||||
Narratio's supported invocation baseline is Notarius v0.6.0. The accepted
|
||||
receipt remains `notarius.run-result.v2`; reference flags do not change the
|
||||
receipt or ten-lane output contract. The receipt
|
||||
must identify the configured pipeline, and its `index_file` must be exactly
|
||||
`index.json` beneath the reported bundle root. The production index must name
|
||||
the management files exactly as `manifest.json`, `rejected.json`, and
|
||||
`warnings.json`. All receipt, index, and lane paths must stay inside that
|
||||
bundle; symlinks and non-regular lane payloads are rejected.
|
||||
the management files exactly as `manifest.json`, `rejected.json`,
|
||||
`warnings.json`, and `diagnostics.json`. All receipt, index, and lane paths must
|
||||
stay inside that bundle; symlinks and non-regular lane payloads are rejected.
|
||||
|
||||
Supported receipt and index shapes tolerate unknown fields for forward
|
||||
compatibility, while required identity, validation, count, manifest,
|
||||
rejection, warning, and lane-list fields remain mandatory. Narratio applies
|
||||
bounded reads to the receipt, index, rejection, and warning documents. Optional
|
||||
chunk-map and evidence-context descriptors must carry their complete generic
|
||||
contract metadata when present.
|
||||
rejection, warning, diagnostic, and lane-list fields remain mandatory.
|
||||
Narratio applies bounded reads to the receipt, index, rejection, warning, and
|
||||
diagnostic documents. Warning and diagnostic envelopes, group counts,
|
||||
occurrence counts, truncation state, framework-owned origins, and
|
||||
receipt-to-bundle counts must be internally consistent. Optional chunk-map and
|
||||
evidence-context descriptors must carry their complete generic contract
|
||||
metadata when present.
|
||||
|
||||
For every entry in `pipeline.notarius.outputs`, Narratio requires exactly one
|
||||
index descriptor with the configured lane ID, media type, schema ID, schema
|
||||
version, and, when configured, module key. Missing, duplicate, rejected, or
|
||||
incompatible required lanes fail extraction even if Notarius exited zero.
|
||||
incompatible required lanes fail extraction even if Notarius exited zero. A
|
||||
configured lane whose v2 validation summary is `rejected` or `incomplete` also
|
||||
fails extraction.
|
||||
Unconfigured lanes may remain in the preserved bundle but do not become
|
||||
selectable Narratio sources.
|
||||
|
||||
@@ -72,10 +112,13 @@ only explicitly named lane sources; `--artifacts` never selects Notarius lanes.
|
||||
staged bundle is promoted to durable storage.
|
||||
- Contract and external provenance metadata are preserved on lane artifact
|
||||
records and through explicit publication.
|
||||
- Undeclared selectors, incompatible reference files, and external/generated
|
||||
reference collisions are Notarius errors and fail extraction normally.
|
||||
|
||||
Rejection and warning summaries retain structured stage, scope, lane, and
|
||||
reason-code fields for diagnostics without exposing free-form external messages
|
||||
or reading lane payload bodies.
|
||||
Rejection, validation, warning, and diagnostic summaries retain bounded stable
|
||||
identity, category, origin, reason-code, status, and occurrence fields without
|
||||
copying free-form external messages into Narratio manifest metadata or reading
|
||||
lane payload bodies.
|
||||
|
||||
Configuration fields and defaults are in [Configuration](../config.md).
|
||||
Operator paths, rerun procedures, and bundle retention are in
|
||||
|
||||
@@ -35,18 +35,33 @@ Adapters do not own:
|
||||
|
||||
## Default Wiring
|
||||
|
||||
`internal/app/runner.go` initializes default adapters when not injected:
|
||||
`internal/app/runner.go` initializes default adapters when not injected and
|
||||
only when the selected execution plan needs them:
|
||||
|
||||
- WhisperX HTTP client from pipeline config.
|
||||
- Seriatim subprocess runner.
|
||||
- Audita subprocess runner.
|
||||
- Scriptorium subprocess runner.
|
||||
- Notarius subprocess runner when extraction is enabled.
|
||||
- Noop notifier (`notify.NoopSender`).
|
||||
- WhisperX HTTP client for `transcribe`.
|
||||
- Seriatim subprocess runner for `merge`, `normalize`, `trim`, or `render`.
|
||||
- Audita subprocess runner for `polish`.
|
||||
- Scriptorium subprocess runner for `trim` or `analyze`.
|
||||
- Notarius subprocess runner for `extract` when extraction is enabled.
|
||||
- Noop notifier (`notify.NoopSender`) for `notify`.
|
||||
- Object store only when required by selected stages/config.
|
||||
|
||||
Remote publish locks are loaded only for a selected, enabled publish that
|
||||
uploads a run. Shared session lifecycle setup still applies to every selected
|
||||
range, but an unselected integration is neither initialized nor validated by
|
||||
runner composition. Each selected stage retains its own fail-fast configuration
|
||||
and input validation.
|
||||
|
||||
`session plan` is outside production adapter composition. It performs
|
||||
resume validation and models selected transitions against cloned manifest
|
||||
state without constructing or invoking stage-execution adapters. The shared
|
||||
command configuration loader may still use object storage to retrieve a missing
|
||||
remote session file before planning begins.
|
||||
|
||||
Notarius is composed only when extraction is enabled; the extract stage owns
|
||||
receipt, bundle, and configured-lane policy rather than the adapter.
|
||||
prepared reference resolution, receipt, bundle, and configured-lane policy.
|
||||
The adapter validates the ordered selector/absolute-path pairs and is the sole
|
||||
owner of serializing them as repeated `--reference` arguments before `--json`.
|
||||
|
||||
Object-store construction goes through `newCommandObjectStore`, which loads
|
||||
configured filesystem secrets before adapter initialization.
|
||||
|
||||
@@ -36,28 +36,45 @@ unrecognized token into a valid source. Extraction sources are registered only
|
||||
from `pipeline.notarius.outputs`; the Notarius index has no selectable source
|
||||
ID.
|
||||
|
||||
Prepared stable source IDs are `narratio.input.players`,
|
||||
`narratio.input.party`, `narratio.input.glossary`, and
|
||||
`narratio.input.spell_catalog`. Artifact policy owns their canonical manifest
|
||||
kind and prepared filename vocabulary.
|
||||
|
||||
## Runtime Catalog
|
||||
|
||||
`ArtifactCatalog` tracks:
|
||||
|
||||
- `planned`: source registered for run context;
|
||||
- `executable`: included in the effective analyze artifact set;
|
||||
- `available`: local file exists and validates;
|
||||
- `available`: the source's canonical evidence owner validates its current
|
||||
manifest record and durable bytes;
|
||||
- `provenance`: availability source.
|
||||
|
||||
Configured definitions are always registered. Without an explicit selection,
|
||||
the effective analyze set contains enabled definitions. With `--artifacts`, the
|
||||
exact named configured definitions become the effective set for that invocation,
|
||||
regardless of their `enabled` value; dependencies are not added implicitly.
|
||||
Availability is separate from executability: a non-executable configured output
|
||||
may be reused from a canonical non-empty file, while an executable definition
|
||||
is generated by analyze. Extraction entries are registered from configuration
|
||||
and become available only after compatible extraction evidence is hydrated.
|
||||
regardless of their `enabled` value. The effective-set resolver itself does not
|
||||
expand dependencies; the analyze work planner closes those targets over their
|
||||
configured prerequisite graph. Availability is separate from executability.
|
||||
Configured outputs, including non-executable prerequisites, become available
|
||||
only when the versioned analyze state identifies a current result whose source,
|
||||
contract, canonical configured path, size, and checksum match a confined
|
||||
no-follow regular file. An incidental canonical file and a legacy aggregate
|
||||
analyze output are unavailable.
|
||||
Extraction entries are registered from configuration and become available only
|
||||
after compatible extraction evidence is hydrated.
|
||||
|
||||
During an analyze invocation, a newly validated and atomically materialized
|
||||
configured output is marked available with its producer run ID, contract,
|
||||
checksum, and size. Later scheduled dependents therefore observe the same
|
||||
semantic identity whether their prerequisite was reused from current manifest
|
||||
evidence or produced earlier in the invocation.
|
||||
|
||||
Current provenance values:
|
||||
|
||||
- `generated.current_analyze_run`
|
||||
- `filesystem.disabled_artifact_output`
|
||||
- `manifest.current_analyze_artifact`
|
||||
- `manifest.inputs.previous_cache`
|
||||
- `current_session.previous_cache`
|
||||
|
||||
@@ -70,7 +87,26 @@ Built-ins:
|
||||
|
||||
Configured sources (`narratio.artifact.*`):
|
||||
|
||||
- resolve only through runtime catalog availability.
|
||||
- resolve only through runtime catalog availability;
|
||||
- use the shared typed analyze-evidence inspection in
|
||||
`analyze_evidence.go` for prior current-session results;
|
||||
- require the supported analyze-state and fingerprint versions, a `current`
|
||||
record for the exact configured key and source ID, a complete contract, the
|
||||
configured canonical relative path, positive stored size, and stored
|
||||
checksum matching bytes read from a confined no-follow regular file; and
|
||||
- treat non-current statuses, legacy or malformed records, removed keys,
|
||||
unsafe or missing files, and size/checksum mismatches as unavailable without
|
||||
rewriting manifest state. Catalog construction iterates current
|
||||
configuration, so removed or renamed records are not advertised.
|
||||
|
||||
Prepared stable sources (`narratio.input.*`):
|
||||
|
||||
- resolve only from the current manifest's exact prepared-input record;
|
||||
- require the policy-owned canonical path below the session root, a confined
|
||||
non-symlink regular file, a non-empty payload, and a matching SHA-256
|
||||
checksum; and
|
||||
- return an immutable source/path/checksum/size identity shared by extract and
|
||||
analyze rather than falling back to campaign/session source paths.
|
||||
|
||||
Extraction sources (`narratio.extraction.*`):
|
||||
|
||||
@@ -199,7 +235,8 @@ physical layout.
|
||||
`internal/artifacts/catalog.go`, `internal/artifacts/transcripts.go`,
|
||||
`internal/artifacts/extraction_catalog.go`,
|
||||
`internal/artifacts/extraction_evidence.go`,
|
||||
`internal/artifacts/extraction_input.go`
|
||||
`internal/artifacts/extraction_input.go`,
|
||||
`internal/artifacts/prepared_input.go`
|
||||
- Current state: `internal/artifacts/current_state.go`,
|
||||
`internal/artifacts/current_state_commit.go`,
|
||||
`internal/artifacts/current_state_legacy.go`
|
||||
|
||||
@@ -35,6 +35,79 @@ The model admits these stage states:
|
||||
- `stale`
|
||||
- `interrupted`
|
||||
|
||||
### Analyze-owned artifact state
|
||||
|
||||
The `analyze` stage record may carry `analyze_state_version: 1` and an
|
||||
`analyze_artifacts` map keyed by normalized configured artifact key. The
|
||||
version is the authority marker: version 1 with no entries is a valid evaluated
|
||||
empty set, while an absent version is legacy aggregate-only state and provides
|
||||
no current configured-artifact evidence.
|
||||
|
||||
Each analyze artifact record has one disposition:
|
||||
|
||||
- `current`: the configured artifact is available and carries a versioned
|
||||
fingerprint plus a complete output record and separate output size;
|
||||
- `stale`: the recorded semantic identity is no longer current;
|
||||
- `missing`: no validated current result exists;
|
||||
- `failed`: the attempted work failed and carries a bounded diagnostic; or
|
||||
- `unselected`: the artifact was intentionally outside the evaluated set.
|
||||
|
||||
Records bind their normalized key and dependencies, fingerprint contract when
|
||||
evaluated, canonical session-relative output identity when current, producing
|
||||
Narratio run, update time, and bounded non-secret Scriptorium provenance and
|
||||
diagnostic paths. A current output includes its configured source ID, contract,
|
||||
checksum, and positive byte size. Non-current records cannot carry an output,
|
||||
so an older file is not advertised through stale, missing, failed, or
|
||||
unselected state.
|
||||
|
||||
The session-stage collection is the reconciled authority across invocations.
|
||||
The corresponding collection on an invocation's `analyze` stage record is an
|
||||
audit of only the artifacts evaluated or attempted by that run. These records
|
||||
remain analyze-owned data inside the fixed stage; they are not dynamic stages
|
||||
or generic subtasks.
|
||||
|
||||
The stage result contract has one analyze-specific projection boundary. On
|
||||
success, the runner validates and deep-copies the complete reconciled session
|
||||
collection and the invocation subset. Aggregate session outputs are rebuilt in
|
||||
configured-key order from current session records only; invocation outputs are
|
||||
limited to current records produced by that invocation's run ID. Ordinary
|
||||
stage outputs cannot accompany this projection, so there is one source of
|
||||
artifact authority.
|
||||
|
||||
Successful incremental execution replaces only evaluated artifact records and
|
||||
preserves valid unrelated current records. Rebuilt outputs are compared by
|
||||
bytes and contract: an unchanged identity permits an unselected dependent with
|
||||
the same recomputed fingerprint to remain current, while a changed identity
|
||||
removes output authority from every unselected transitive dependent by marking
|
||||
it stale. A partial analyze invocation can therefore succeed while unrelated
|
||||
configured records remain stale. Existing canonical files never create current
|
||||
records without validated execution and projection.
|
||||
|
||||
Aggregate analyze status is deliberately coarser than this collection. Resume
|
||||
validation may skip a succeeded aggregate record when the selected artifact
|
||||
closure is current even if unrelated records are stale. Conversely, a stale
|
||||
aggregate record may cross the ordinary runner boundary and perform zero
|
||||
Scriptorium calls when reconciliation proves every selected artifact current;
|
||||
the successful projection then restores the aggregate status.
|
||||
|
||||
Analyze may return a projection together with an error. That restricted result
|
||||
cannot carry ordinary outputs, skip state, aggregate logs, generated configs,
|
||||
or metadata. The runner persists only the validated per-artifact collections,
|
||||
then marks the aggregate analyze and run state failed and invalidates delivery
|
||||
dependents conservatively. Unrelated current records survive because the
|
||||
session projection is complete. A malformed projection is not applied, and a
|
||||
failed session projection save restores the prior per-artifact authority before
|
||||
terminal failure persistence.
|
||||
|
||||
The incremental executor constructs this restricted projection at each
|
||||
scheduled artifact boundary. The active record is failed without output,
|
||||
current transitive dependents are stale, unrelated current records survive, and
|
||||
only earlier validated and materialized completions remain current in the
|
||||
invocation subset. Session failure state is persisted before invocation failure
|
||||
state. If either terminal save fails, its persistence error is joined with the
|
||||
original adapter, validation, or filesystem cause; a failed projection save
|
||||
does not turn incidental canonical bytes into manifest authority.
|
||||
|
||||
## Run Manifest
|
||||
|
||||
`manifest.RunManifest` is created for each invocation and records:
|
||||
@@ -86,7 +159,10 @@ failed in both manifests, persisting each transition. On success it records
|
||||
outputs, logs, generated configuration references, and metadata. Artifact
|
||||
records may include optional contract and external provenance objects; old
|
||||
manifests remain compatible when those fields are absent. A successful forced
|
||||
rerun marks only succeeded downstream session-stage records stale.
|
||||
rerun marks only succeeded transitive dependent session-stage records stale.
|
||||
The application owns a fixed dependency relation distinct from execution order;
|
||||
dependents are returned in canonical order. Render and extract therefore never
|
||||
stale one another, while either can stale analyze, publish, and notify.
|
||||
|
||||
Starting an execution clears the current session-stage record's prior outputs,
|
||||
logs, generated configuration references, and metadata. Failed and skipped
|
||||
@@ -96,6 +172,12 @@ those details because resume validation and diagnosis may still require them
|
||||
before execution begins. Invocation run manifests remain immutable audit
|
||||
records of their own outcomes.
|
||||
|
||||
Aggregate lifecycle clearing deliberately preserves the analyze-owned
|
||||
per-artifact collection. This lets later reconciliation replace only evaluated
|
||||
entries without erasing unrelated current results. Other stages retain their
|
||||
existing aggregate-only lifecycle behavior and are forbidden from carrying the
|
||||
analyze-specific fields.
|
||||
|
||||
A stage may explicitly return a skipped disposition and stable reason. The
|
||||
runner persists that outcome in both manifests, clears older outputs for the
|
||||
session-stage record along with older logs, generated configuration references,
|
||||
@@ -107,19 +189,29 @@ cannot contain outputs.
|
||||
When an already-succeeded stage is skipped, the invocation run manifest records
|
||||
the `skip` action and reason. The session manifest deliberately retains its
|
||||
existing succeeded record because it remains the cross-invocation progress
|
||||
authority. Stages with a resume validator, currently extraction, may reject an
|
||||
otherwise eligible skip when the recorded durable result is obsolete; the
|
||||
runner marks it stale and executes it.
|
||||
authority. Extraction and analyze have resume validators and may reject an
|
||||
otherwise eligible skip when their selected durable evidence is obsolete; the
|
||||
runner marks the aggregate record stale and executes it. Analyze's validator
|
||||
can still accept a partial selection when only unrelated artifact records are
|
||||
stale.
|
||||
|
||||
Session manifest is the authoritative stage-progress ledger across invocations.
|
||||
Run manifest is invocation-scoped audit state.
|
||||
|
||||
Before an explicitly bounded execution starts after `prepare`, the application
|
||||
reads the session manifest and accepts only `succeeded` or `skipped` for every
|
||||
excluded canonical prefix stage. The first other status or absent record fails
|
||||
the request before layout mutation, adapter initialization, session-manifest
|
||||
writes, or run-manifest creation. Excluded prefix records are not passed to
|
||||
resume validators. Records after the selected end are not prerequisites and
|
||||
may be made stale by selected work without being scheduled.
|
||||
|
||||
After a publish commits remotely, any configured local cleanup is first recorded
|
||||
as a session-manifest obligation before deletion begins. Each target becomes
|
||||
complete only after its confined deletion (or safe absence check) and a
|
||||
successful manifest save. An incomplete obligation is retried on later
|
||||
invocations independently of their selected stages and retains the committed
|
||||
run and remote identity that authorized it.
|
||||
successful manifest save. An incomplete obligation is retried when publish
|
||||
executes again and retains the committed run and remote identity that authorized
|
||||
it; an invocation that does not execute publish does not perform cleanup.
|
||||
|
||||
Each invocation derives campaign, session, run, local-path, and remote-prefix
|
||||
metadata from the validated resolved configuration as one projection. A persisted
|
||||
@@ -139,7 +231,7 @@ where a durable running record can require operator interpretation.
|
||||
- running, failed, and self-skipped stages do not retain result payloads from
|
||||
an earlier success.
|
||||
- stale stages retain prior details until replacement execution starts.
|
||||
- force reruns stale downstream succeeded stages.
|
||||
- force reruns stale succeeded stages in the fixed dependency relation.
|
||||
- run manifest does not replace session manifest as progress authority.
|
||||
- remote commitment is established by a verified current pointer and remote
|
||||
commit relationship, never by a mutable session-manifest boolean.
|
||||
|
||||
@@ -43,6 +43,14 @@ Narratio-level contracts; external transport and SDK details remain in
|
||||
adapters. The normative rules for these relationships remain in
|
||||
[Architecture](../policy/architecture.md).
|
||||
|
||||
Pipeline execution and `session plan` share the same inclusive contiguous-range
|
||||
model. Planning clones session state and applies selected-stage transitions and
|
||||
resume validation in memory; it does not create invocation state or initialize
|
||||
stage-execution adapters. Command configuration loading can still retrieve a
|
||||
missing session file through configured remote storage. Analyze planning
|
||||
additionally exposes the artifact closure's targets, prerequisite rebuilds,
|
||||
execution order, and current reuse.
|
||||
|
||||
## Pipeline Stage Set
|
||||
|
||||
The implemented canonical order is:
|
||||
@@ -53,8 +61,8 @@ The implemented canonical order is:
|
||||
4. [`polish`](stage-polish.md)
|
||||
5. [`normalize`](stage-normalize.md)
|
||||
6. [`trim`](stage-trim.md)
|
||||
7. [`extract`](stage-extract.md)
|
||||
8. [`render`](stage-render.md)
|
||||
7. [`render`](stage-render.md)
|
||||
8. [`extract`](stage-extract.md)
|
||||
9. [`analyze`](stage-analyze.md)
|
||||
10. [`publish`](stage-publish.md)
|
||||
11. `notify` (no-op)
|
||||
@@ -65,6 +73,13 @@ mechanics. The
|
||||
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
|
||||
and execution semantics.
|
||||
|
||||
Execution order and invalidation are separate application contracts. The stage
|
||||
registry owns the flat execution sequence. The application orchestration owner
|
||||
uses a fixed, validated dependency relation to find transitive dependents in
|
||||
canonical order. In particular, `render` and `extract` are sibling consumers of
|
||||
trimmed transcript state: neither invalidates the other, while either can stale
|
||||
`analyze`, `publish`, and `notify`.
|
||||
|
||||
## Focused Documentation
|
||||
|
||||
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
|
||||
@@ -84,8 +99,8 @@ and execution semantics.
|
||||
- [`polish`](stage-polish.md)
|
||||
- [`normalize`](stage-normalize.md)
|
||||
- [`trim`](stage-trim.md)
|
||||
- [`extract`](stage-extract.md)
|
||||
- [`render`](stage-render.md)
|
||||
- [`extract`](stage-extract.md)
|
||||
- [`analyze`](stage-analyze.md)
|
||||
- [`publish`](stage-publish.md)
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Execute selected configured Scriptorium artifacts in dependency order and materialize outputs.
|
||||
Reconcile configured Scriptorium artifacts, execute only required work in
|
||||
dependency order, and safely materialize validated outputs.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -14,35 +15,111 @@ Execute selected configured Scriptorium artifacts in dependency order and materi
|
||||
Supported source families:
|
||||
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`
|
||||
- prepared stable inputs: `narratio.input.players`, `narratio.input.party`,
|
||||
`narratio.input.glossary`
|
||||
`narratio.input.glossary`, `narratio.input.spell_catalog`
|
||||
- configured artifacts: `narratio.artifact.<key>`
|
||||
- extraction lanes: `narratio.extraction.<key>`
|
||||
- previous-session cache: `narratio.previous_session.artifact.<key>`
|
||||
|
||||
## Outputs
|
||||
|
||||
- one materialized output per executed configured artifact (`output_path`)
|
||||
- one current per-artifact manifest record per validated materialized output
|
||||
- stage metadata describing selected/generated/reused artifacts
|
||||
|
||||
## Key Behavior
|
||||
|
||||
- when Scriptorium is absent or no configured artifact is executable, completes
|
||||
successfully with no outputs and records explanatory metadata. This is not an
|
||||
explicit self-skip: both manifests record success, satisfy publish's
|
||||
prerequisite, and an ordinary later run reuses the result until forced.
|
||||
- when `pipeline.scriptorium` is absent or no configured artifact is
|
||||
executable, completes successfully with no outputs and records explanatory
|
||||
metadata. This is not an explicit self-skip: both manifests record success,
|
||||
satisfy publish's prerequisite, and an ordinary later run reuses the result
|
||||
while the effective set remains empty. Enabling or selecting an artifact
|
||||
later makes missing versioned evidence non-resumable and schedules it without
|
||||
requiring force.
|
||||
- builds a runtime artifact catalog containing built-ins, configured artifacts,
|
||||
and configured extraction lanes. Extraction availability is hydrated only
|
||||
from compatible successful extraction evidence.
|
||||
- uses enabled configured artifacts by default. An explicit `--artifacts`
|
||||
selection is a one-invocation override: it makes exactly the named configured
|
||||
artifacts executable even when disabled, and does not automatically include
|
||||
dependencies. A selected artifact's dependencies must instead already be
|
||||
available to the catalog.
|
||||
- marks non-executable configured artifacts as reusable when output files already exist.
|
||||
selection is a one-invocation override that makes exactly the named
|
||||
configured artifacts explicit targets even when disabled. The work planner
|
||||
adds required configured prerequisites, reuses current ones, and schedules
|
||||
stale, missing, or otherwise non-current prerequisites before dependents.
|
||||
- makes a non-executable configured artifact reusable only when its current
|
||||
manifest record and durable output pass the configured-artifact evidence
|
||||
contract; an incidental or stale canonical file is unavailable.
|
||||
- validates selected artifact dependency order (cycle-safe topo ordering).
|
||||
- resolves required/optional inputs per artifact source definition.
|
||||
- omits an unavailable optional input; an unavailable required input fails.
|
||||
- resolves prepared stable input sources from `inputs/*.yml` materialized by `prepare`.
|
||||
- resolves required/optional inputs per artifact source definition into an
|
||||
ordered semantic identity. Each identity records the configured input name,
|
||||
canonical source ID, required policy, explicit presence, source contract,
|
||||
checksum, size, and a source-based logical identity. Workspace paths and
|
||||
producer run IDs are excluded.
|
||||
- orders input identities by configured input name independently of Go map
|
||||
iteration. Runtime adapter paths remain a separate execution-only map.
|
||||
- omits an unavailable optional input from the adapter request while retaining
|
||||
explicit absence in its semantic identity; an unavailable required input
|
||||
fails.
|
||||
- resolves prepared stable input sources through the shared manifest-authoritative
|
||||
identity resolver; it does not accept incidental files or fall back to
|
||||
campaign/session source paths.
|
||||
- reuses checksums and sizes from validated prepared, extraction, and current
|
||||
configured-artifact evidence. Other resolved inputs are hashed as confined
|
||||
regular files with streaming reads and the central resolved-artifact size
|
||||
limit.
|
||||
- owns a versioned SHA-256 fingerprint contract with one fixed-field canonical
|
||||
JSON payload and no map serialization. Configured artifacts are fingerprinted
|
||||
in deterministic dependency order.
|
||||
- fingerprints the normalized artifact key, prompt and profile identifiers,
|
||||
effective render-debug behavior, session-relative output identity, sorted
|
||||
dependency keys, ordered input declarations and semantic identities,
|
||||
validated current dependency-output identities, and sorted effective
|
||||
Scriptorium variables (including Narratio's sticky session variable).
|
||||
- provides read-only reconciliation that classifies each configured record as
|
||||
current, stale, missing, failed, legacy, or otherwise non-resumable, and
|
||||
separately identifies manifest records removed from current configuration.
|
||||
A record is current only when its fingerprint version and value match and its
|
||||
configured output still passes manifest-authoritative evidence validation.
|
||||
- owns a read-only typed work planner. Its explicit targets are enabled
|
||||
artifacts by default or the exact normalized `--artifacts` selection when
|
||||
supplied. It closes targets over configured prerequisites, orders the closure
|
||||
topologically, reuses current members, and schedules every non-current member
|
||||
before its dependents.
|
||||
- force applies only to explicit targets. A current prerequisite is reused
|
||||
unless it is itself an explicit forced target; disabled prerequisites may be
|
||||
rebuilt when required, while unrelated disabled artifacts are excluded.
|
||||
- the work plan carries explicit targets, prerequisite-only work, deterministic
|
||||
execution and reuse lists, invalidated and removed records, and a cloned
|
||||
projected record collection. Valid unrelated configured records survive the
|
||||
projection, removed records are omitted, and legacy files never become
|
||||
current without regeneration.
|
||||
- implements aggregate resume validation by running the same read-only catalog,
|
||||
fingerprint reconciliation, and work planner used by execution. A succeeded
|
||||
aggregate record is reusable exactly when the selected closure schedules no
|
||||
artifact work; stale unrelated records do not block a partial selection.
|
||||
- exposes the typed artifact decision to `session plan`. Planning applies it to
|
||||
a cloned manifest after modeling earlier selected stage transitions, so
|
||||
aggregate run/skip and artifact execute/reuse decisions match the ordinary
|
||||
runner without creating durable state or invoking Scriptorium.
|
||||
- executes only the work plan's scheduled entries. Manifest-validated current
|
||||
prerequisites remain available through the runtime catalog without invoking
|
||||
Scriptorium; newly produced prerequisites enter that catalog with the same
|
||||
contract, checksum, and size identity used for persisted current evidence.
|
||||
- keeps adapter output in the invocation's run-local analyze directory until
|
||||
it is a safe, non-empty, bounded regular file with a calculated checksum and
|
||||
complete output contract. Canonical replacement uses the shared atomic file
|
||||
operation boundary and verifies that the installed checksum matches the
|
||||
validated run-local bytes.
|
||||
- records each successful artifact's freshly computed fingerprint, canonical
|
||||
relative output path, contract, checksum, size, producer run ID, bounded
|
||||
Scriptorium provenance, logs, and generated configuration references in the
|
||||
analyze-owned projection.
|
||||
- preserves valid unrelated current records during partial execution. If a
|
||||
rebuilt output's bytes and contract are unchanged, unselected dependents may
|
||||
remain current. If that semantic identity changes, unselected transitive
|
||||
dependents become stale without being executed; dependents included in the
|
||||
invocation are evaluated in dependency order instead.
|
||||
- reports all evaluated targets and prerequisites in invocation state. The
|
||||
runner reconstructs aggregate session outputs from every current session
|
||||
record and invocation outputs from only records produced by the current run.
|
||||
Unrelated stale records do not make an otherwise successful partial
|
||||
invocation fail.
|
||||
- resolves previous-session sources from local `previous/` cache only.
|
||||
- runs optional render-debug, then artifact execution.
|
||||
- validates non-empty output files and materializes canonical outputs.
|
||||
@@ -57,11 +134,33 @@ Supported source families:
|
||||
guidance.
|
||||
- dependency cycles or unavailable required dependencies fail.
|
||||
- adapter validation failures fail stage.
|
||||
- a scheduled artifact failure returns the restricted analyze-state projection
|
||||
with the active artifact marked `failed`, a bounded error, and no output
|
||||
authority. Current transitive dependents become stale without execution.
|
||||
- earlier artifacts from the invocation remain current only after their
|
||||
run-local output passed validation and canonical materialization. They remain
|
||||
in invocation history; unattempted later artifacts do not appear there.
|
||||
- unrelated current records survive a partial failure. Old canonical bytes for
|
||||
the failed artifact and newly materialized bytes whose projection cannot be
|
||||
persisted are incidental, not current evidence.
|
||||
- the runner persists a valid partial projection before it marks aggregate
|
||||
analyze failed and invalidates publish and notify through the application
|
||||
dependency relation. Projection-persistence errors retain the last durable
|
||||
per-artifact authority and are joined with the original failure context.
|
||||
|
||||
## Invariants
|
||||
|
||||
- `analyze` performs no remote storage calls for previous-session source resolution.
|
||||
- input-identity resolution is read-only: it does not invoke adapters,
|
||||
materialize outputs, update status, or create run records.
|
||||
- fingerprints exclude timeouts, retries, timestamps, producer and Narratio run
|
||||
IDs, executable and config paths, workspace roots, diagnostic locations, and
|
||||
executable or private transitive configuration contents. A change that is
|
||||
visible only inside Scriptorium—such as a file privately loaded by its config
|
||||
path—requires an explicit forced regeneration.
|
||||
- output provenance and metadata are deterministic per execution.
|
||||
- a canonical file without current per-artifact manifest evidence is never
|
||||
promoted to current state.
|
||||
|
||||
## Related Contracts And Tests
|
||||
|
||||
@@ -70,4 +169,13 @@ Supported source families:
|
||||
- [CLI](../cli.md) owns user-visible artifact selection.
|
||||
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
|
||||
- Implementation and tests: `internal/stage/analyze.go`,
|
||||
`internal/stage/analyze_test.go`
|
||||
`internal/stage/analyze_input_identity.go`, `internal/stage/analyze_test.go`,
|
||||
`internal/stage/analyze_input_identity_test.go`,
|
||||
`internal/stage/analyze_fingerprint.go`,
|
||||
`internal/stage/analyze_fingerprint_test.go`,
|
||||
`internal/stage/analyze_reconciliation.go`, and
|
||||
`internal/stage/analyze_reconciliation_test.go`,
|
||||
`internal/stage/analyze_plan.go`, `internal/stage/analyze_plan_test.go`, and
|
||||
`internal/stage/analyze_incremental_execution_test.go`, and
|
||||
`internal/stage/analyze_failure_test.go`,
|
||||
`internal/stage/analyze_resume.go`, and `internal/stage/analyze_resume_test.go`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Responsibility
|
||||
|
||||
`extract` runs after `trim` and before `render`. It converts the canonical
|
||||
`extract` runs after `render` and before `analyze`. It converts the canonical
|
||||
`narratio.transcript.final_trimmed` JSON into configured Notarius lane artifacts.
|
||||
An omitted or disabled Notarius section makes the stage explicitly self-skip
|
||||
with reason `notarius_disabled`, no outputs, and no Notarius runner.
|
||||
@@ -17,15 +17,21 @@ procedures belong in [Operations](../operations.md).
|
||||
`internal/stage/extract.go`:
|
||||
|
||||
1. resolves the final trimmed transcript from the shared artifact catalog;
|
||||
2. resolves and fingerprints the Notarius invocation contract;
|
||||
3. creates a run-local staging directory and invokes the injected
|
||||
2. resolves every configured prepared reference through the shared
|
||||
manifest-authoritative identity resolver before creating run-local output;
|
||||
3. streams each verified reference into an invocation-local snapshot and
|
||||
rejects any source change observed while copying;
|
||||
4. fingerprints the Notarius invocation contract, including sorted reference
|
||||
identities;
|
||||
5. creates a run-local staging directory and invokes the injected
|
||||
`notarius.Runner`;
|
||||
4. validates the successful receipt, confined index, configured required lane
|
||||
descriptors, and regular payload files;
|
||||
5. atomically promotes the complete bundle to its immutable durable location;
|
||||
6. records one non-selectable `notarius_index` output and one selectable
|
||||
6. revalidates the reference snapshots, then validates the v2 successful
|
||||
receipt, confined index, management documents, configured required lane
|
||||
descriptors, validation summaries, and regular payload files;
|
||||
7. atomically promotes the complete bundle to its immutable durable location;
|
||||
8. records one non-selectable `notarius_index` output and one selectable
|
||||
`notarius_lane` output per configured lane; and
|
||||
7. registers each lane as `narratio.extraction.<output_key>` for downstream
|
||||
9. registers each lane as `narratio.extraction.<output_key>` for downstream
|
||||
Scriptorium and publish resolution.
|
||||
|
||||
Lane records retain checksum, contract, producer run ID, and Notarius system,
|
||||
@@ -34,23 +40,35 @@ root, receipt, diagnostic paths, rejection/warning summaries, producing
|
||||
Narratio run ID, the resolved trimmed-input identity, and invocation
|
||||
fingerprint. The input identity binds the exact transcript bytes, canonical
|
||||
source ID, producer stage/output/run identity, and resolution provenance.
|
||||
Reference metadata contains only selector, source ID, canonical session-relative
|
||||
path, checksum, and size; adapter requests receive selector and absolute
|
||||
invocation-local snapshot path, never payload contents. Snapshot bytes must
|
||||
match the prepared identity both before and after Notarius runs, so a concurrent
|
||||
prepared-file replacement cannot make recorded provenance describe different
|
||||
bytes from those supplied to Notarius.
|
||||
Validation completes before
|
||||
promotion, so a rejected result cannot expose a partial durable bundle.
|
||||
|
||||
Any executed extraction outcome that replaces a different effective outcome
|
||||
marks succeeded downstream stages stale. Repeating the same disabled self-skip
|
||||
with no outputs is stable and does not repeatedly invalidate downstream stages.
|
||||
marks succeeded analysis and delivery dependents stale. Render is an independent
|
||||
sibling and remains current. Repeating the same disabled self-skip with no
|
||||
outputs is stable and does not repeatedly invalidate dependent stages.
|
||||
|
||||
## Resume Validation
|
||||
|
||||
`internal/stage/extract_resume.go` permits a skip only when the existing stage
|
||||
record succeeded and still matches the current invocation fingerprint. The
|
||||
fingerprint covers the resolved executable and config paths, pipeline ID,
|
||||
timeout, working directory, sorted configured output contracts, and the current
|
||||
direct trimmed-transcript identity. The same identity is resolved again for
|
||||
timeout, working directory, sorted configured output contracts, the current
|
||||
direct trimmed-transcript identity, and sorted prepared-reference identities.
|
||||
The same reference helper and transcript identity are resolved again for
|
||||
artifact evidence, so changing the current transcript bytes or producer
|
||||
identity makes the prior extraction obsolete.
|
||||
|
||||
A valid prepared-reference change makes extraction non-resumable. Missing,
|
||||
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
|
||||
with prepare-force guidance because an immediate extract rerun cannot succeed.
|
||||
|
||||
The validator then checks the producing run identity, canonical immutable
|
||||
bundle root, path confinement and absence of symlink components, receipt
|
||||
identity, exactly one canonical index, the exact configured source set,
|
||||
@@ -65,8 +83,10 @@ Operators must force extraction after changing any such input.
|
||||
## Failure Behavior
|
||||
|
||||
Adapter startup, timeout, nonzero exit, receipt decoding, path confinement,
|
||||
index compatibility, required-lane rejection, payload inspection, checksum, or
|
||||
promotion errors fail the stage through ordinary manifest transition handling.
|
||||
index compatibility, inconsistent warning or diagnostic envelopes,
|
||||
required-lane rejection or incomplete validation, payload inspection,
|
||||
checksum, or promotion errors fail the stage through ordinary manifest
|
||||
transition handling.
|
||||
Stdout receipt and stderr diagnostics remain separate. Downstream stages are
|
||||
not given selectable extraction sources unless the complete configured result
|
||||
has passed validation and promotion.
|
||||
|
||||
@@ -8,6 +8,7 @@ Materialize canonical current-session inputs before processing stages.
|
||||
|
||||
- resolved campaign, session, and pipeline configuration
|
||||
- stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
|
||||
- optional spell-catalog overlay
|
||||
- one resolved local or S3 audio source
|
||||
- enabled configured artifact input requirements for previous-session sources
|
||||
|
||||
@@ -21,6 +22,7 @@ Materialize canonical current-session inputs before processing stages.
|
||||
- `inputs/glossary.yml`
|
||||
- `inputs/players.yml`
|
||||
- `inputs/party.yml`
|
||||
- optional `inputs/spell_catalog.json`
|
||||
- `audio/*.flac`
|
||||
- optional `previous/manifest.json`
|
||||
- optional `previous/artifacts/**`
|
||||
@@ -34,6 +36,9 @@ Materialize canonical current-session inputs before processing stages.
|
||||
- gives distinct local source paths with the same basename deterministic unique
|
||||
prepared filenames so neither source is overwritten.
|
||||
- materializes S3 audio through spool/cache-aware logic.
|
||||
- materializes a configured spell catalog with checksum and provenance, or
|
||||
safely removes an obsolete canonical spell catalog and its manifest record
|
||||
when the effective input is omitted.
|
||||
- scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
|
||||
- clears managed `previous/` state on every invocation, then, when requirements exist:
|
||||
- resolves the pointer-selected previous source through the shared resolver;
|
||||
|
||||
@@ -38,7 +38,13 @@ Exact remote placement and the operator workflow belong in
|
||||
checks a declared checksum when present, then streams the opened descriptor.
|
||||
- derives the durable previous-cache archive from its validated manifest using
|
||||
the same confinement and regular-file checks.
|
||||
- resolves publish output sources through runtime artifact catalog and manifest-aware resolution.
|
||||
- resolves publish output sources through runtime artifact catalog and
|
||||
manifest-aware resolution. Configured Scriptorium outputs are publishable
|
||||
only from validated `current` per-artifact analyze evidence; an incidental
|
||||
canonical file, legacy aggregate output, stale/failed/unselected record, or
|
||||
mismatched path, size, or checksum remains unavailable. This does not change
|
||||
the explicit compatibility policies owned by built-in, extraction, or
|
||||
previous-session sources.
|
||||
- publishes extraction lanes only through explicit configured output rules;
|
||||
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
||||
- selected artifact filter applies to configured artifact sources only.
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
## Purpose
|
||||
|
||||
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
|
||||
It runs after `trim` and before `extract` in the canonical sequence. Render and
|
||||
extract are independent sibling consumers: replacing render output does not
|
||||
invalidate extraction, but it does invalidate succeeded analysis and delivery
|
||||
records that may consume rendered transcripts.
|
||||
|
||||
## Inputs
|
||||
|
||||
|
||||
@@ -36,7 +36,12 @@ narratio session init 2026-04-04 --remote --force
|
||||
|
||||
If `campaign.yml` sets `session_template_file`, `session init` renders it. Template variables must resolve to concrete values.
|
||||
|
||||
Campaigns must provide stable input files for speakers, autocorrect, glossary, players, and party. Session files may override those paths for one session. The `prepare` stage materializes them under `inputs/`; configured Scriptorium artifacts can reference prepared `players`, `party`, and `glossary` files with `narratio.input.players`, `narratio.input.party`, and `narratio.input.glossary`.
|
||||
Campaigns must provide stable input files for speakers, autocorrect, glossary,
|
||||
players, and party, and may provide an optional spell-catalog overlay. Session
|
||||
files may override those paths for one session. The `prepare` stage
|
||||
materializes them under `inputs/`; configured consumers use the prepared files,
|
||||
never the original campaign or session source paths. Field definitions and
|
||||
source IDs are in [Configuration](./config.md#notarius-reference-bindings).
|
||||
|
||||
## Standard Session Workflow
|
||||
|
||||
@@ -75,8 +80,8 @@ Canonical stage order:
|
||||
4. `polish`
|
||||
5. `normalize`
|
||||
6. `trim`
|
||||
7. `extract`
|
||||
8. `render`
|
||||
7. `render`
|
||||
8. `extract`
|
||||
9. `analyze`
|
||||
10. `publish`
|
||||
11. `notify`
|
||||
@@ -85,12 +90,12 @@ Execution rules:
|
||||
|
||||
- succeeded stages are skipped unless `--force` is set;
|
||||
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||
- forcing an upstream stage marks succeeded downstream stages as `stale` before
|
||||
the replacement runs; and
|
||||
- forcing a stage marks succeeded transitive dependents as `stale` before the
|
||||
replacement runs; render and extract are independent siblings; and
|
||||
- an executed failure, changed self-skip, or success that replaces a different
|
||||
effective upstream outcome also marks succeeded downstream stages stale. A
|
||||
effective outcome uses the same fixed dependency relation. A
|
||||
repeated self-skip with the same reason and no outputs is stable and does not
|
||||
perpetually rerun downstream work.
|
||||
perpetually rerun dependent work.
|
||||
|
||||
An explicit self-skip is a durable `skipped` stage outcome that later runs
|
||||
reconsider. It differs from successful no-output execution: disabled `render`
|
||||
@@ -106,14 +111,81 @@ Single-stage execution:
|
||||
narratio run-stage normalize 2026-04-04 --force
|
||||
```
|
||||
|
||||
Contiguous bounded execution uses inclusive canonical endpoints:
|
||||
|
||||
```bash
|
||||
narratio session plan 2026-04-04 --from extract --through analyze --force
|
||||
narratio run 2026-04-04 --from extract --through analyze --force
|
||||
```
|
||||
|
||||
Omitting `--from` selects from `prepare`; omitting `--through` selects through
|
||||
`notify`. Force applies only within the selected range. Repeating `--from`,
|
||||
`--through`, or `--force` is rejected instead of resolving by argument order.
|
||||
The plan command uses the same selection contract and prints only the selected
|
||||
range. Planning is read-only: it clones the loaded manifest, models selected
|
||||
stage transitions and invalidation in memory, and invokes resume validation
|
||||
without writing the manifest, creating run directories, materializing files,
|
||||
or invoking pipeline adapters. Analyze detail separates explicit targets,
|
||||
prerequisite rebuilds, scheduled execution, and current reuse. This lets a
|
||||
coarsely stale aggregate analyze stage show zero artifact executions when its
|
||||
selected artifact evidence is still semantically current.
|
||||
|
||||
Before a bounded run or plan whose range starts after `prepare`, every excluded
|
||||
prefix stage must already have a session-manifest status of `succeeded` or
|
||||
`skipped`. Narratio reports the first absent, pending, running, failed, stale,
|
||||
or interrupted prerequisite without creating a run record or changing session
|
||||
state. Widen `--from` to include that stage, or recover it explicitly before
|
||||
retrying. Excluded prefix stages are not resume-validated or repaired as part
|
||||
of the bounded invocation; selected stages still reject missing, unsafe, or
|
||||
manifest-inconsistent inputs at their owning boundary.
|
||||
|
||||
Stages after `--through` are not prerequisites and are never scheduled by the
|
||||
bounded invocation. A selected forced stage can mark one of those succeeded
|
||||
dependents stale through the fixed invalidation relation, but the dependent
|
||||
does not execute until a later invocation selects it. Production composition
|
||||
likewise initializes only collaborators needed by the selected range and
|
||||
shared session lifecycle. In particular, render does not require Notarius or
|
||||
Scriptorium, extract does not require Scriptorium, and analyze does not require
|
||||
the transcription, Seriatim, Audita, or Notarius adapters.
|
||||
|
||||
For the common post-transcript development loop, use:
|
||||
|
||||
```bash
|
||||
narratio regenerate-artifacts 2026-04-04
|
||||
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
|
||||
```
|
||||
|
||||
This command is a transparent expansion to a forced bounded `run` from
|
||||
`extract` through `analyze`. Extraction always rebuilds its complete configured
|
||||
bundle. Analysis rebuilds the selected targets and their required analysis
|
||||
prerequisites, or uses the normal default selection when no artifact names are
|
||||
given. The command does not run publish or notify; delivery remains a separate
|
||||
operator action.
|
||||
|
||||
Inspect current artifact evidence, then publish explicitly when the regenerated
|
||||
set is ready:
|
||||
|
||||
```bash
|
||||
narratio session artifacts 2026-04-04
|
||||
narratio publish 2026-04-04
|
||||
```
|
||||
|
||||
If planning or execution reports stale, missing, failed, legacy, or tampered
|
||||
analysis evidence, regenerate the affected target instead of copying an older
|
||||
canonical file into place or editing the manifest. See
|
||||
[Troubleshooting: Analysis artifact evidence is not current](./troubleshooting.md#analysis-artifact-evidence-is-not-current).
|
||||
|
||||
## Artifact Selection
|
||||
|
||||
`--artifacts` can be used on `run`, `run-stage`, `analyze`, and `publish`.
|
||||
`--artifacts` can be used on `run`, `session plan`, `run-stage`, `analyze`, and
|
||||
`publish`. For a bounded run or plan, the selected range must contain `analyze`
|
||||
or `publish`.
|
||||
|
||||
Selection behavior:
|
||||
|
||||
- validates names against `pipeline.scriptorium.artifacts`;
|
||||
- filters analyze execution to selected configured artifacts;
|
||||
- selects explicit analyze targets and permits their required configured
|
||||
prerequisites to be reused or rebuilt first;
|
||||
- filters publish rules for `narratio.artifact.<name>` sources only;
|
||||
- does not suppress built-in transcript, bounds, or explicitly configured
|
||||
`narratio.extraction.<name>` publish sources; and
|
||||
@@ -135,6 +207,31 @@ The directory is immutable once promoted. Configured lanes become
|
||||
the bundle and `index.json` are retained for audit and resume validation but
|
||||
are not selectable or published implicitly.
|
||||
|
||||
Configured Notarius references resolve only from the current manifest-backed
|
||||
prepared inputs. Their canonical locations are `inputs/party.yml`,
|
||||
`inputs/players.yml`, `inputs/glossary.yml`, and, when configured,
|
||||
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies
|
||||
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
|
||||
prepared files cannot change the bytes consumed by an in-flight invocation.
|
||||
Inspect the effective stable-input inventory and
|
||||
prepared-file readiness with:
|
||||
|
||||
```bash
|
||||
narratio session status 2026-04-04
|
||||
narratio session validate 2026-04-04
|
||||
```
|
||||
|
||||
Reference metadata records selector, source ID, session-relative path,
|
||||
checksum, and byte size, but never payload contents. Changing a prepared
|
||||
reference changes extraction identity: ordinary continuation rejects the old
|
||||
result, reruns Notarius, and marks successful downstream stages stale. If the
|
||||
prepared file is missing or inconsistent with its manifest checksum, repair
|
||||
the source configuration and refresh prepared state first:
|
||||
|
||||
```bash
|
||||
narratio run-stage prepare 2026-04-04 --force
|
||||
```
|
||||
|
||||
Starting a replacement clears the previous extraction payload from the current
|
||||
session-stage record. If that replacement fails or self-skips, the current
|
||||
record does not fall back to the earlier outputs. The earlier run manifest and
|
||||
@@ -181,11 +278,12 @@ To intentionally replace the current extraction result, run:
|
||||
narratio run-stage extract 2026-04-04 --force
|
||||
```
|
||||
|
||||
Narratio automatically reruns extraction when its recorded invocation contract
|
||||
or durable output validation changes. It cannot fingerprint configuration
|
||||
files, profiles, prompts, modules, or references loaded transitively by
|
||||
Notarius. Force extraction after changing any of those inputs, even when the
|
||||
top-level Narratio and Notarius config paths remain the same. A forced extract
|
||||
Narratio automatically reruns extraction when its recorded invocation contract,
|
||||
prepared Narratio reference identities, or durable output validation changes.
|
||||
It cannot fingerprint configuration files, profiles, prompts, modules, or
|
||||
other references loaded transitively by Notarius itself. Force extraction after
|
||||
changing any of those inputs, even when the top-level Narratio and Notarius
|
||||
config paths remain the same. A forced extract
|
||||
marks successful downstream stages stale. Ordinary extraction failures or
|
||||
outcome changes also stale affected downstream stages, while an identical
|
||||
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
|
||||
@@ -405,7 +503,11 @@ Rules:
|
||||
- `pipeline.workspace.cleanup_after_publish=true`
|
||||
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
|
||||
reports incomplete, the remote committed snapshot remains current; rerun
|
||||
Narratio to retry only the outstanding confined local cleanup.
|
||||
publish to retry only the outstanding confined local cleanup.
|
||||
|
||||
Post-publish cleanup is evaluated only when `publish` actually executes in the
|
||||
current invocation. A bounded range that excludes publish does not replay a
|
||||
cleanup obligation as an unrelated side effect.
|
||||
|
||||
## Operational Caveats
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ in the [integration documentation](../integrations/).
|
||||
The pipeline has one canonical ordered stage set. Configuration may enable,
|
||||
disable, or parameterize supported behavior, but it must not turn that sequence
|
||||
into an arbitrary DAG or hide orchestration in generic workflow abstractions.
|
||||
An invocation selects either the full sequence or one inclusive contiguous
|
||||
range of it. Execution remains flat and canonical even though invalidation is
|
||||
dependency-aware: the application owns a separate fixed relation used only to
|
||||
stale transitive dependents, including dependents outside a selected range.
|
||||
The implemented stage inventory belongs in the
|
||||
[Internal Overview](../internal/overview.md).
|
||||
|
||||
@@ -87,8 +91,10 @@ merely on incidental files existing on disk.
|
||||
|
||||
A failed or interrupted stage must not be presented as successful. Failure
|
||||
should preserve enough local state and diagnostics for inspection, recovery,
|
||||
and resume. Forcing an upstream stage invalidates succeeded downstream work
|
||||
according to the canonical stage order.
|
||||
and resume. Forcing a stage invalidates succeeded transitive dependents
|
||||
according to a fixed application-owned relation that is separate from canonical
|
||||
execution order. The relation is validated against the stage inventory and is
|
||||
not configurable.
|
||||
|
||||
A stage may explicitly self-skip with a stable reason and no outputs. That
|
||||
outcome is persisted, clears older outputs owned by the stage, and is
|
||||
|
||||
7
docs/releases/README.md
Normal file
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.
|
||||
@@ -117,6 +117,40 @@ Safe fix:
|
||||
|
||||
Relevant reference: [CLI artifact selection](./cli.md).
|
||||
|
||||
## Bounded run prerequisite is unusable
|
||||
|
||||
Symptom:
|
||||
|
||||
- `run` or `session plan` reports that a prerequisite stage is absent or has a
|
||||
pending, running, failed, stale, or interrupted status before the selected
|
||||
start.
|
||||
|
||||
Likely cause:
|
||||
|
||||
- `--from` excludes upstream work that has not reached the terminal
|
||||
`succeeded` or `skipped` state in the session manifest.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session status 2026-04-04
|
||||
narratio session plan 2026-04-04 --from render --through analyze
|
||||
```
|
||||
|
||||
Safe fix:
|
||||
|
||||
- widen the bounded range to include the first reported stage, or recover that
|
||||
stage explicitly with `run-stage` before retrying. The failed check does not
|
||||
create a run record or modify the manifest. Narratio does not resume-validate
|
||||
excluded prefix stages, and stages after `--through` are not prerequisites.
|
||||
|
||||
If prerequisite statuses are terminal but a selected stage reports a missing,
|
||||
unsafe, or checksum-inconsistent artifact, repair the artifact at the stage
|
||||
that owns it; do not edit the manifest to bypass the selected stage's concrete
|
||||
input validation.
|
||||
|
||||
Relevant reference: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior).
|
||||
|
||||
## Notarius executable missing
|
||||
|
||||
Symptom:
|
||||
@@ -158,6 +192,66 @@ is expected audit state, not a signal to relink the old bundle manually.
|
||||
|
||||
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
||||
|
||||
## Prepared Notarius reference missing or inconsistent
|
||||
|
||||
Symptom:
|
||||
|
||||
- extraction or resume validation reports that a configured reference source is
|
||||
unavailable, unsafe, empty, or checksum-inconsistent and recommends
|
||||
`prepare --force`.
|
||||
|
||||
Likely causes:
|
||||
|
||||
- `prepare` has not run since the campaign/session stable input changed;
|
||||
- the configured source file is missing;
|
||||
- a prepared `inputs/` file or its manifest record was modified independently;
|
||||
- a spell-catalog binding exists without an effective `spell_catalog_file`.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session status 2026-04-04
|
||||
narratio session validate 2026-04-04
|
||||
```
|
||||
|
||||
Safe fix:
|
||||
|
||||
- correct the campaign/session input path, then refresh canonical prepared
|
||||
evidence before extraction:
|
||||
|
||||
```bash
|
||||
narratio run-stage prepare 2026-04-04 --force
|
||||
```
|
||||
|
||||
Do not point Notarius directly at the original source path or edit the manifest
|
||||
checksum. Relevant references: [Notarius reference configuration](./config.md#notarius-reference-bindings)
|
||||
and [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
||||
|
||||
## Notarius reference selector or generated-handoff collision
|
||||
|
||||
Symptom:
|
||||
|
||||
- Notarius exits nonzero with an undeclared reference-slot, incompatible media,
|
||||
or external/generated reference collision error.
|
||||
|
||||
Likely causes:
|
||||
|
||||
- a selector does not identify a slot declared by the selected Notarius target;
|
||||
- a prepared file does not satisfy that slot's Notarius media contract; or
|
||||
- a CLI binding attempts to replace a same-run generated D&D handoff.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- compare external bindings with the selected Notarius pipeline's canonical
|
||||
consumer documentation;
|
||||
- keep only campaign-owned external slots on the CLI; and
|
||||
- leave registry, scene, combat, and occurrence handoffs to Notarius pipeline
|
||||
composition.
|
||||
|
||||
Narratio validates selector structure and prepared evidence, while Notarius
|
||||
owns slot declarations, media compatibility, and generated-handoff conflicts.
|
||||
Relevant reference: [Notarius integration](./integrations/notarius.md).
|
||||
|
||||
## Atomic Notarius promotion unsupported
|
||||
|
||||
Symptom:
|
||||
@@ -198,7 +292,7 @@ Safe fix:
|
||||
|
||||
- compare installed Notarius output with the canonical Notarius contracts,
|
||||
including receipt `index_file: index.json` and index management names
|
||||
`manifest.json`, `rejected.json`, and `warnings.json`; align
|
||||
`manifest.json`, `rejected.json`, `warnings.json`, and `diagnostics.json`; align
|
||||
`pipeline.notarius` constraints and rerun. Do not bypass confinement or schema
|
||||
checks.
|
||||
|
||||
@@ -230,6 +324,8 @@ Likely causes:
|
||||
|
||||
- the executable/config path, pipeline ID, timeout, working directory, or
|
||||
configured output contracts changed;
|
||||
- a configured prepared reference selector, source, path, checksum, or byte
|
||||
size changed;
|
||||
- the durable bundle, index, lane set, provenance, regular-file status, or
|
||||
checksum no longer validates.
|
||||
|
||||
@@ -252,12 +348,110 @@ Safe fix:
|
||||
narratio run-stage extract 2026-04-04 --force
|
||||
```
|
||||
|
||||
Narratio fingerprints its invocation contract, not the contents of transitive
|
||||
Notarius inputs. Always force extraction after changing them; downstream
|
||||
Narratio fingerprints its invocation contract and prepared Narratio reference
|
||||
identities, not the contents of other transitive Notarius inputs. Always force
|
||||
extraction after changing those external inputs; downstream
|
||||
successful stages are then marked stale normally.
|
||||
|
||||
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
||||
|
||||
## Analysis artifact evidence is not current
|
||||
|
||||
Symptom:
|
||||
|
||||
- ordinary continuation or `session plan` schedules one or more configured
|
||||
artifacts even though a canonical output file exists; or
|
||||
- publish reports a configured artifact source unavailable.
|
||||
|
||||
Likely causes:
|
||||
|
||||
- the per-artifact record is stale, missing, failed, unselected, malformed, or
|
||||
from the legacy aggregate-only manifest contract;
|
||||
- a configured prompt/profile, dependency, input identity, output path, or
|
||||
effective variable changed; or
|
||||
- the recorded output is missing, unsafe, empty, or has a size/checksum that no
|
||||
longer matches its manifest evidence.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio session status 2026-04-04
|
||||
narratio session artifacts 2026-04-04
|
||||
narratio session plan 2026-04-04 --from analyze --through analyze
|
||||
```
|
||||
|
||||
Safe fix:
|
||||
|
||||
- investigate unexpected path or checksum changes as possible tampering;
|
||||
- otherwise let the selected analyze work rerun, or explicitly regenerate only
|
||||
the affected targets; and
|
||||
- never edit the fingerprint/checksum in the manifest or copy an old file into
|
||||
the canonical path as a substitute for current evidence.
|
||||
|
||||
```bash
|
||||
narratio analyze 2026-04-04 --artifacts session_recap
|
||||
```
|
||||
|
||||
Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection)
|
||||
and [Artifact Internals](./internal/artifacts.md#resolution-rules).
|
||||
|
||||
## Legacy aggregate analysis requires regeneration
|
||||
|
||||
Symptom:
|
||||
|
||||
- a manifest from an older Narratio version reports aggregate analyze success
|
||||
and the old files are present, but configured artifact sources remain
|
||||
unavailable.
|
||||
|
||||
Likely cause:
|
||||
|
||||
- the manifest has no supported per-artifact analyze state. Aggregate output
|
||||
lists do not establish current configured-artifact authority.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- regenerate the required artifacts. A partial selection makes only its
|
||||
targets and prerequisites eligible for current state; unselected legacy
|
||||
files intentionally remain unavailable. Run full analysis later when every
|
||||
enabled configured artifact must become current.
|
||||
|
||||
```bash
|
||||
narratio analyze 2026-04-04 --artifacts session_recap
|
||||
narratio analyze 2026-04-04
|
||||
```
|
||||
|
||||
After current records exist, inspect them and publish explicitly. Do not delete
|
||||
the legacy files merely to influence selection; availability is manifest-owned.
|
||||
|
||||
Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior)
|
||||
and [Manifest Internals](./internal/manifest.md#analyze-owned-artifact-state).
|
||||
|
||||
## Scriptorium private input changed without a rerun
|
||||
|
||||
Symptom:
|
||||
|
||||
- a prompt, profile, imported configuration file, executable, or other input
|
||||
loaded privately by Scriptorium changed, but Narratio still considers an
|
||||
artifact current.
|
||||
|
||||
Likely cause:
|
||||
|
||||
- analysis fingerprints cover Narratio-observable semantic identities, not
|
||||
executable contents or arbitrary files and transitive configuration that
|
||||
Scriptorium loads behind its configured paths and identifiers.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- explicitly force the affected target after changing an unobserved private
|
||||
input. Force applies to explicit targets; current prerequisites remain
|
||||
reusable unless selected themselves.
|
||||
|
||||
```bash
|
||||
narratio analyze 2026-04-04 --artifacts session_recap
|
||||
```
|
||||
|
||||
Relevant reference: [Analyze Internals](./internal/stage-analyze.md#invariants).
|
||||
|
||||
## Previous-session artifact input missing
|
||||
|
||||
Symptom:
|
||||
|
||||
@@ -39,7 +39,9 @@ with the sample campaign and a compatible local- or S3-audio session.
|
||||
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
|
||||
[glossary](campaigns/sample-campaign/glossary.yml),
|
||||
[players](campaigns/sample-campaign/players.yml), and
|
||||
[party](campaigns/sample-campaign/party.yml) fixtures.
|
||||
[party](campaigns/sample-campaign/party.yml) fixtures, plus an optional
|
||||
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
|
||||
follows the Notarius v0.6 contract.
|
||||
- [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
|
||||
reserves the expected filename and directory shape. Replace it with a real
|
||||
FLAC file before running transcription.
|
||||
|
||||
@@ -6,3 +6,4 @@ inputs:
|
||||
glossary_file: ./glossary.yml
|
||||
players_file: ./players.yml
|
||||
party_file: ./party.yml
|
||||
spell_catalog_file: ./spell_catalog.json
|
||||
|
||||
18
examples/campaigns/sample-campaign/spell_catalog.json
Normal file
18
examples/campaigns/sample-campaign/spell_catalog.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": "notarius.dnd.spell-catalog-overlay.v1",
|
||||
"catalogs": [
|
||||
{
|
||||
"id": "narratio.sample-campaign",
|
||||
"ruleset": "dnd-5e-2014",
|
||||
"source": {
|
||||
"title": "Narratio sample campaign spell names"
|
||||
},
|
||||
"spells": [
|
||||
{
|
||||
"name": "Aegis of Emberfall",
|
||||
"aliases": ["Emberfall Aegis"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,6 +14,11 @@ notarius:
|
||||
config_path: /usr/local/etc/notarius/config.yml
|
||||
pipeline_id: dnd-session
|
||||
timeout: 3h
|
||||
references:
|
||||
glossary: narratio.input.glossary
|
||||
party: narratio.input.party
|
||||
players: narratio.input.players
|
||||
spell_catalog: narratio.input.spell_catalog
|
||||
outputs:
|
||||
npc_registry:
|
||||
lane_id: npc-registry
|
||||
@@ -52,4 +57,3 @@ scriptorium:
|
||||
scenes:
|
||||
source: narratio.extraction.scene_descriptions
|
||||
required: true
|
||||
|
||||
|
||||
@@ -136,6 +136,13 @@ notarius:
|
||||
pipeline_id: dnd-session
|
||||
timeout: 3h
|
||||
working_directory: /usr/local/etc/notarius
|
||||
# External campaign references use prepared Narratio source IDs. Omit an
|
||||
# optional binding when the selected Notarius pipeline does not need it.
|
||||
references:
|
||||
glossary: narratio.input.glossary
|
||||
party: narratio.input.party
|
||||
players: narratio.input.players
|
||||
spell_catalog: narratio.input.spell_catalog
|
||||
# Each key creates source narratio.extraction.<key>. These constraints match
|
||||
# the current Notarius D&D lane contracts; update them with Notarius.
|
||||
outputs:
|
||||
|
||||
@@ -14,7 +14,9 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
f.Requests = append(f.Requests, req)
|
||||
copyRequest := req
|
||||
copyRequest.References = append([]ReferenceBinding(nil), req.References...)
|
||||
f.Requests = append(f.Requests, copyRequest)
|
||||
if f.Err != nil {
|
||||
return RunResult{}, f.Err
|
||||
}
|
||||
|
||||
@@ -6,13 +6,20 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const ReceiptSchemaVersion = "notarius.run-result.v1"
|
||||
const ReceiptSchemaVersion = "notarius.run-result.v2"
|
||||
|
||||
// Runner is the adapter boundary for a complete Notarius pipeline invocation.
|
||||
type Runner interface {
|
||||
Run(ctx context.Context, req RunRequest) (RunResult, error)
|
||||
}
|
||||
|
||||
// ReferenceBinding maps one normalized Notarius selector to an absolute
|
||||
// external reference path.
|
||||
type ReferenceBinding struct {
|
||||
Selector string
|
||||
Path string
|
||||
}
|
||||
|
||||
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
|
||||
type RunRequest struct {
|
||||
Binary string
|
||||
@@ -24,6 +31,7 @@ type RunRequest struct {
|
||||
ReceiptPath string
|
||||
LogPath string
|
||||
Timeout time.Duration
|
||||
References []ReferenceBinding
|
||||
}
|
||||
|
||||
// Receipt is the transport-neutral successful run receipt.
|
||||
@@ -35,11 +43,31 @@ type Receipt struct {
|
||||
IndexFile string
|
||||
NormalizedOutputCount int
|
||||
RejectedOutputCount int
|
||||
WarningCount int
|
||||
WarningGroupCount int
|
||||
WarningOccurrenceCount int
|
||||
DiagnosticGroupCount int
|
||||
DiagnosticOccurrenceCount int
|
||||
DiagnosticsTruncated bool
|
||||
ValidationStatus string
|
||||
ValidationSummaries []ValidationSummary
|
||||
DebugDirectory string
|
||||
}
|
||||
|
||||
// ValidationSummary retains the bounded outcome of one Notarius producer result.
|
||||
type ValidationSummary struct {
|
||||
Stage string
|
||||
StepID string
|
||||
LaneID string
|
||||
ModuleKey string
|
||||
ChunkID string
|
||||
Status string
|
||||
RejectingValidators []string
|
||||
ReasonCodes []string
|
||||
IncompleteValidators []string
|
||||
ProducerAttemptCount int
|
||||
TerminalAction string
|
||||
}
|
||||
|
||||
// LaneDescriptor identifies one normalized lane payload discovered through the index.
|
||||
type LaneDescriptor struct {
|
||||
LaneID string
|
||||
@@ -72,6 +100,8 @@ type Index struct {
|
||||
RejectedPath string
|
||||
WarningsFile string
|
||||
WarningsPath string
|
||||
DiagnosticsFile string
|
||||
DiagnosticsPath string
|
||||
Lanes []LaneDescriptor
|
||||
ChunkMap *PipelineDescriptor
|
||||
EvidenceContext *PipelineDescriptor
|
||||
@@ -90,8 +120,29 @@ type RejectionSummary struct {
|
||||
|
||||
// WarningSummary retains structured warning identity without free-form messages.
|
||||
type WarningSummary struct {
|
||||
Scope string
|
||||
Disposition string
|
||||
Category string
|
||||
ReasonCode string
|
||||
Origin DiagnosticOrigin
|
||||
OccurrenceCount int
|
||||
}
|
||||
|
||||
// DiagnosticOrigin identifies the framework-owned pipeline location of a finding.
|
||||
type DiagnosticOrigin struct {
|
||||
Stage string
|
||||
StepID string
|
||||
LaneID string
|
||||
ModuleKey string
|
||||
ValidatorKey string
|
||||
}
|
||||
|
||||
// DiagnosticSummary retains bounded advisory or observation group metadata.
|
||||
type DiagnosticSummary struct {
|
||||
Disposition string
|
||||
Category string
|
||||
ReasonCode string
|
||||
Origin DiagnosticOrigin
|
||||
OccurrenceCount int
|
||||
}
|
||||
|
||||
// RunResult describes a successfully decoded and validated Notarius bundle.
|
||||
@@ -105,4 +156,5 @@ type RunResult struct {
|
||||
Duration time.Duration
|
||||
Rejections []RejectionSummary
|
||||
Warnings []WarningSummary
|
||||
Diagnostics []DiagnosticSummary
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
@@ -22,6 +23,12 @@ const (
|
||||
canonicalManifestFile = "manifest.json"
|
||||
canonicalRejectedFile = "rejected.json"
|
||||
canonicalWarningsFile = "warnings.json"
|
||||
canonicalDiagnosticsFile = "diagnostics.json"
|
||||
warningsSchemaVersion = "notarius.warnings.v2"
|
||||
diagnosticsSchemaVersion = "notarius.diagnostics.v1"
|
||||
maxWarningGroups = 128
|
||||
maxDiagnosticGroups = 256
|
||||
maxFindingSamples = 3
|
||||
)
|
||||
|
||||
type subprocessRun func(context.Context, subprocess.RunRequest) (subprocess.RunResult, error)
|
||||
@@ -41,7 +48,8 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
||||
if r == nil || r.run == nil {
|
||||
return RunResult{}, fmt.Errorf("notarius subprocess runner is nil")
|
||||
}
|
||||
if err := validateRunRequest(req); err != nil {
|
||||
references, err := validateRunRequest(req)
|
||||
if err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
|
||||
@@ -50,8 +58,11 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
||||
"--config", req.ConfigPath,
|
||||
"--input", req.InputPath,
|
||||
"--output-dir", req.OutputRoot,
|
||||
"--json",
|
||||
}
|
||||
for _, reference := range references {
|
||||
args = append(args, "--reference", reference.Selector+"="+reference.Path)
|
||||
}
|
||||
args = append(args, "--json")
|
||||
processResult, err := r.run(ctx, subprocess.RunRequest{
|
||||
Executable: req.Binary,
|
||||
Args: args,
|
||||
@@ -95,24 +106,42 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
||||
if err != nil {
|
||||
return baseResult, err
|
||||
}
|
||||
diagnostics, diagnosticOccurrences, diagnosticsTruncated, err := loadDiagnostics(index.DiagnosticsPath)
|
||||
if err != nil {
|
||||
return baseResult, err
|
||||
}
|
||||
if receipt.NormalizedOutputCount != len(index.Lanes) || receipt.RejectedOutputCount != len(rejections) ||
|
||||
receipt.WarningGroupCount != len(warnings) || receipt.DiagnosticGroupCount != len(diagnostics) {
|
||||
return baseResult, fmt.Errorf("notarius receipt counts do not match published bundle")
|
||||
}
|
||||
warningOccurrences, err := sumWarningOccurrences(warnings)
|
||||
if err != nil {
|
||||
return baseResult, err
|
||||
}
|
||||
if receipt.WarningOccurrenceCount != warningOccurrences ||
|
||||
receipt.DiagnosticOccurrenceCount != diagnosticOccurrences ||
|
||||
receipt.DiagnosticsTruncated != diagnosticsTruncated {
|
||||
return baseResult, fmt.Errorf("notarius receipt occurrence counts do not match published bundle")
|
||||
}
|
||||
|
||||
baseResult.Receipt = receipt
|
||||
baseResult.Index = index
|
||||
baseResult.BundleRoot = bundleRoot
|
||||
baseResult.Rejections = rejections
|
||||
baseResult.Warnings = warnings
|
||||
baseResult.Diagnostics = diagnostics
|
||||
return baseResult, nil
|
||||
}
|
||||
|
||||
func validateRunRequest(req RunRequest) error {
|
||||
func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
|
||||
if strings.TrimSpace(req.Binary) == "" {
|
||||
return fmt.Errorf("notarius binary is required")
|
||||
return nil, fmt.Errorf("notarius binary is required")
|
||||
}
|
||||
if strings.TrimSpace(req.PipelineID) == "" {
|
||||
return fmt.Errorf("notarius pipeline id is required")
|
||||
return nil, fmt.Errorf("notarius pipeline id is required")
|
||||
}
|
||||
if req.Timeout <= 0 {
|
||||
return fmt.Errorf("notarius timeout must be positive")
|
||||
return nil, fmt.Errorf("notarius timeout must be positive")
|
||||
}
|
||||
for label, path := range map[string]string{
|
||||
"config": req.ConfigPath,
|
||||
@@ -123,34 +152,53 @@ func validateRunRequest(req RunRequest) error {
|
||||
"log": req.LogPath,
|
||||
} {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return fmt.Errorf("notarius %s path is required", label)
|
||||
return nil, fmt.Errorf("notarius %s path is required", label)
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return fmt.Errorf("notarius %s path must be absolute", label)
|
||||
return nil, fmt.Errorf("notarius %s path must be absolute", label)
|
||||
}
|
||||
}
|
||||
if filepath.Clean(req.ReceiptPath) == filepath.Clean(req.LogPath) {
|
||||
return fmt.Errorf("notarius receipt and log paths must be different")
|
||||
return nil, fmt.Errorf("notarius receipt and log paths must be different")
|
||||
}
|
||||
references := make([]ReferenceBinding, 0, len(req.References))
|
||||
selectors := make(map[string]struct{}, len(req.References))
|
||||
for index, binding := range req.References {
|
||||
selector, err := notariusref.NormalizeSelector(binding.Selector)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notarius reference %d selector: %w", index, err)
|
||||
}
|
||||
if _, duplicate := selectors[selector]; duplicate {
|
||||
return nil, fmt.Errorf("notarius reference selector %q is duplicated", selector)
|
||||
}
|
||||
selectors[selector] = struct{}{}
|
||||
if strings.TrimSpace(binding.Path) == "" {
|
||||
return nil, fmt.Errorf("notarius reference %q path is required", selector)
|
||||
}
|
||||
if !filepath.IsAbs(binding.Path) {
|
||||
return nil, fmt.Errorf("notarius reference %q path must be absolute", selector)
|
||||
}
|
||||
references = append(references, ReferenceBinding{Selector: selector, Path: binding.Path})
|
||||
}
|
||||
if err := requireRegularFile(req.ConfigPath); err != nil {
|
||||
return fmt.Errorf("validate notarius config path: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius config path: %w", err)
|
||||
}
|
||||
if err := requireRegularFile(req.InputPath); err != nil {
|
||||
return fmt.Errorf("validate notarius input path: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius input path: %w", err)
|
||||
}
|
||||
if err := requireDirectory(req.OutputRoot); err != nil {
|
||||
return fmt.Errorf("validate notarius output root: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius output root: %w", err)
|
||||
}
|
||||
if err := requireDirectory(req.WorkingDirectory); err != nil {
|
||||
return fmt.Errorf("validate notarius working directory: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius working directory: %w", err)
|
||||
}
|
||||
if err := validateLogDestination(req.ReceiptPath); err != nil {
|
||||
return fmt.Errorf("validate notarius receipt path: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius receipt path: %w", err)
|
||||
}
|
||||
if err := validateLogDestination(req.LogPath); err != nil {
|
||||
return fmt.Errorf("validate notarius log path: %w", err)
|
||||
return nil, fmt.Errorf("validate notarius log path: %w", err)
|
||||
}
|
||||
return nil
|
||||
return references, nil
|
||||
}
|
||||
|
||||
type receiptDocument struct {
|
||||
@@ -161,11 +209,69 @@ type receiptDocument struct {
|
||||
IndexFile string `json:"index_file"`
|
||||
NormalizedOutputCount *int `json:"normalized_output_count"`
|
||||
RejectedOutputCount *int `json:"rejected_output_count"`
|
||||
WarningCount *int `json:"warning_count"`
|
||||
WarningGroupCount *int `json:"warning_group_count"`
|
||||
WarningOccurrenceCount *int `json:"warning_occurrence_count"`
|
||||
DiagnosticGroupCount *int `json:"diagnostic_group_count"`
|
||||
DiagnosticOccurrenceCount *int `json:"diagnostic_occurrence_count"`
|
||||
DiagnosticsTruncated *bool `json:"diagnostics_truncated"`
|
||||
ValidationStatus string `json:"validation_status"`
|
||||
ValidationSummaries []validationSummaryDocument `json:"validation_summaries"`
|
||||
DebugDirectory string `json:"debug_directory"`
|
||||
}
|
||||
|
||||
type validationSummaryDocument struct {
|
||||
Stage string `json:"stage"`
|
||||
StepID string `json:"step_id"`
|
||||
LaneID string `json:"lane_id"`
|
||||
ModuleKey string `json:"module_key"`
|
||||
ChunkID string `json:"chunk_id"`
|
||||
Status string `json:"status"`
|
||||
RejectingValidators []string `json:"rejecting_validators"`
|
||||
ReasonCodes []string `json:"reason_codes"`
|
||||
IncompleteValidators []string `json:"incomplete_validators"`
|
||||
ProducerAttemptCount *int `json:"producer_attempt_count"`
|
||||
TerminalAction string `json:"terminal_action"`
|
||||
}
|
||||
|
||||
func validValidationStatus(value string) bool {
|
||||
switch value {
|
||||
case "approved", "rejected", "incomplete":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateValidationSummaries(documents []validationSummaryDocument) ([]ValidationSummary, error) {
|
||||
summaries := make([]ValidationSummary, 0, len(documents))
|
||||
for _, document := range documents {
|
||||
if document.Status != "complete" && document.Status != "rejected" && document.Status != "incomplete" {
|
||||
return nil, fmt.Errorf("notarius validation summary status %q is invalid", document.Status)
|
||||
}
|
||||
if document.ProducerAttemptCount == nil || *document.ProducerAttemptCount <= 0 || !validTerminalAction(document.TerminalAction) {
|
||||
return nil, fmt.Errorf("notarius validation summary is missing required fields")
|
||||
}
|
||||
summaries = append(summaries, ValidationSummary{
|
||||
Stage: document.Stage, StepID: document.StepID, LaneID: document.LaneID,
|
||||
ModuleKey: document.ModuleKey, ChunkID: document.ChunkID, Status: document.Status,
|
||||
RejectingValidators: append([]string(nil), document.RejectingValidators...),
|
||||
ReasonCodes: append([]string(nil), document.ReasonCodes...),
|
||||
IncompleteValidators: append([]string(nil), document.IncompleteValidators...),
|
||||
ProducerAttemptCount: *document.ProducerAttemptCount, TerminalAction: document.TerminalAction,
|
||||
})
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func validTerminalAction(value string) bool {
|
||||
switch value {
|
||||
case "accepted", "reject_output", "warn_continue", "fail_run":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func loadReceipt(path, pipelineID string) (Receipt, error) {
|
||||
var document receiptDocument
|
||||
if err := decodeBoundedJSON(path, maxReceiptBytes, &document); err != nil {
|
||||
@@ -177,7 +283,9 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
|
||||
if strings.TrimSpace(document.RunID) == "" || strings.TrimSpace(document.PipelineID) == "" ||
|
||||
strings.TrimSpace(document.OutputDirectory) == "" || strings.TrimSpace(document.ValidationStatus) == "" ||
|
||||
document.NormalizedOutputCount == nil ||
|
||||
document.RejectedOutputCount == nil || document.WarningCount == nil {
|
||||
document.RejectedOutputCount == nil || document.WarningGroupCount == nil ||
|
||||
document.WarningOccurrenceCount == nil || document.DiagnosticGroupCount == nil ||
|
||||
document.DiagnosticOccurrenceCount == nil || document.DiagnosticsTruncated == nil {
|
||||
return Receipt{}, fmt.Errorf("notarius receipt is missing required fields")
|
||||
}
|
||||
if document.IndexFile != canonicalIndexFile {
|
||||
@@ -186,9 +294,18 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
|
||||
if document.PipelineID != pipelineID {
|
||||
return Receipt{}, fmt.Errorf("notarius receipt pipeline id %q does not match requested pipeline %q", document.PipelineID, pipelineID)
|
||||
}
|
||||
if *document.NormalizedOutputCount < 0 || *document.RejectedOutputCount < 0 || *document.WarningCount < 0 {
|
||||
if *document.NormalizedOutputCount < 0 || *document.RejectedOutputCount < 0 ||
|
||||
*document.WarningGroupCount < 0 || *document.WarningOccurrenceCount < 0 ||
|
||||
*document.DiagnosticGroupCount < 0 || *document.DiagnosticOccurrenceCount < 0 {
|
||||
return Receipt{}, fmt.Errorf("notarius receipt counts must be non-negative")
|
||||
}
|
||||
if !validValidationStatus(document.ValidationStatus) {
|
||||
return Receipt{}, fmt.Errorf("notarius receipt validation_status %q is invalid", document.ValidationStatus)
|
||||
}
|
||||
validationSummaries, err := validateValidationSummaries(document.ValidationSummaries)
|
||||
if err != nil {
|
||||
return Receipt{}, err
|
||||
}
|
||||
if !filepath.IsAbs(document.OutputDirectory) {
|
||||
return Receipt{}, fmt.Errorf("notarius receipt output directory must be absolute")
|
||||
}
|
||||
@@ -203,8 +320,13 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
|
||||
IndexFile: document.IndexFile,
|
||||
NormalizedOutputCount: *document.NormalizedOutputCount,
|
||||
RejectedOutputCount: *document.RejectedOutputCount,
|
||||
WarningCount: *document.WarningCount,
|
||||
WarningGroupCount: *document.WarningGroupCount,
|
||||
WarningOccurrenceCount: *document.WarningOccurrenceCount,
|
||||
DiagnosticGroupCount: *document.DiagnosticGroupCount,
|
||||
DiagnosticOccurrenceCount: *document.DiagnosticOccurrenceCount,
|
||||
DiagnosticsTruncated: *document.DiagnosticsTruncated,
|
||||
ValidationStatus: document.ValidationStatus,
|
||||
ValidationSummaries: validationSummaries,
|
||||
DebugDirectory: document.DebugDirectory,
|
||||
}, nil
|
||||
}
|
||||
@@ -214,6 +336,7 @@ type indexDocument struct {
|
||||
OutputFiles *[]laneDocument `json:"output_files"`
|
||||
RejectedFile string `json:"rejected_file"`
|
||||
WarningsFile string `json:"warnings_file"`
|
||||
DiagnosticsFile string `json:"diagnostics_file"`
|
||||
ChunkMap *pipelineDocument `json:"chunk_map"`
|
||||
EvidenceContext *pipelineDocument `json:"evidence_context"`
|
||||
}
|
||||
@@ -250,6 +373,7 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
|
||||
{name: "manifest_file", got: document.ManifestFile, want: canonicalManifestFile},
|
||||
{name: "rejected_file", got: document.RejectedFile, want: canonicalRejectedFile},
|
||||
{name: "warnings_file", got: document.WarningsFile, want: canonicalWarningsFile},
|
||||
{name: "diagnostics_file", got: document.DiagnosticsFile, want: canonicalDiagnosticsFile},
|
||||
} {
|
||||
if field.got != field.want {
|
||||
return Index{}, fmt.Errorf("notarius index %s %q is incompatible; want %q", field.name, field.got, field.want)
|
||||
@@ -264,6 +388,7 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
|
||||
ManifestFile: document.ManifestFile,
|
||||
RejectedFile: document.RejectedFile,
|
||||
WarningsFile: document.WarningsFile,
|
||||
DiagnosticsFile: document.DiagnosticsFile,
|
||||
}
|
||||
var err error
|
||||
if index.ManifestPath, err = resolveRegularFile(bundleRoot, index.ManifestFile); err != nil {
|
||||
@@ -275,6 +400,9 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
|
||||
if index.WarningsPath, err = resolveRegularFile(bundleRoot, index.WarningsFile); err != nil {
|
||||
return Index{}, fmt.Errorf("resolve notarius warning file: %w", err)
|
||||
}
|
||||
if index.DiagnosticsPath, err = resolveRegularFile(bundleRoot, index.DiagnosticsFile); err != nil {
|
||||
return Index{}, fmt.Errorf("resolve notarius diagnostics file: %w", err)
|
||||
}
|
||||
|
||||
seenLanes := make(map[string]struct{}, len(*document.OutputFiles))
|
||||
for _, lane := range *document.OutputFiles {
|
||||
@@ -362,12 +490,34 @@ func loadRejections(path string) ([]RejectionSummary, error) {
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
type warningDocument struct {
|
||||
Warnings *[]struct {
|
||||
Scope string `json:"scope"`
|
||||
type findingGroupDocument struct {
|
||||
Disposition string `json:"disposition"`
|
||||
Category string `json:"category"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
Origin diagnosticOriginDocument `json:"origin"`
|
||||
OccurrenceCount *int `json:"occurrence_count"`
|
||||
Samples *[]struct {
|
||||
Scope string `json:"scope"`
|
||||
Message string `json:"message"`
|
||||
} `json:"warnings"`
|
||||
ChunkID string `json:"chunk_id"`
|
||||
ChunkIndex *int `json:"chunk_index"`
|
||||
} `json:"samples"`
|
||||
OmittedSampleCount *int `json:"omitted_sample_count"`
|
||||
}
|
||||
|
||||
type diagnosticOriginDocument struct {
|
||||
Stage string `json:"stage"`
|
||||
StepID string `json:"step_id"`
|
||||
LaneID string `json:"lane_id"`
|
||||
ModuleKey string `json:"module_key"`
|
||||
ValidatorKey string `json:"validator_key"`
|
||||
}
|
||||
|
||||
type warningDocument struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
GroupCount *int `json:"group_count"`
|
||||
OccurrenceCount *int `json:"occurrence_count"`
|
||||
Groups *[]findingGroupDocument `json:"groups"`
|
||||
}
|
||||
|
||||
func loadWarnings(path string) ([]WarningSummary, error) {
|
||||
@@ -375,19 +525,151 @@ func loadWarnings(path string) ([]WarningSummary, error) {
|
||||
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
|
||||
return nil, fmt.Errorf("decode notarius warnings: %w", err)
|
||||
}
|
||||
if document.Warnings == nil {
|
||||
return nil, fmt.Errorf("notarius warning document is missing warnings array")
|
||||
if document.SchemaVersion != warningsSchemaVersion || document.GroupCount == nil ||
|
||||
document.OccurrenceCount == nil || document.Groups == nil {
|
||||
return nil, fmt.Errorf("notarius warning document is missing or incompatible required fields")
|
||||
}
|
||||
summaries := make([]WarningSummary, 0, len(*document.Warnings))
|
||||
for _, item := range *document.Warnings {
|
||||
if strings.TrimSpace(item.ReasonCode) == "" || strings.TrimSpace(item.Message) == "" {
|
||||
return nil, fmt.Errorf("notarius warning entries require reason_code and message")
|
||||
if *document.GroupCount < 0 || *document.GroupCount > maxWarningGroups || *document.OccurrenceCount < 0 ||
|
||||
*document.GroupCount != len(*document.Groups) {
|
||||
return nil, fmt.Errorf("notarius warning document counts are inconsistent")
|
||||
}
|
||||
summaries = append(summaries, WarningSummary{Scope: item.Scope, ReasonCode: item.ReasonCode})
|
||||
summaries := make([]WarningSummary, 0, len(*document.Groups))
|
||||
occurrences := 0
|
||||
for _, group := range *document.Groups {
|
||||
if err := validateFindingGroup(group); err != nil {
|
||||
return nil, fmt.Errorf("notarius warning group: %w", err)
|
||||
}
|
||||
if group.Disposition != "warning" {
|
||||
return nil, fmt.Errorf("notarius warning group disposition %q is invalid", group.Disposition)
|
||||
}
|
||||
if *group.OccurrenceCount > int(^uint(0)>>1)-occurrences {
|
||||
return nil, fmt.Errorf("notarius warning occurrence count overflows")
|
||||
}
|
||||
occurrences += *group.OccurrenceCount
|
||||
summaries = append(summaries, WarningSummary{
|
||||
Disposition: group.Disposition, Category: group.Category, ReasonCode: group.ReasonCode,
|
||||
Origin: diagnosticOrigin(group.Origin), OccurrenceCount: *group.OccurrenceCount,
|
||||
})
|
||||
}
|
||||
if occurrences != *document.OccurrenceCount {
|
||||
return nil, fmt.Errorf("notarius warning document occurrence count is inconsistent")
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
type diagnosticDocument struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
GroupCount *int `json:"group_count"`
|
||||
OccurrenceCount *int `json:"occurrence_count"`
|
||||
Truncated *bool `json:"truncated"`
|
||||
UnrepresentedOccurrenceCount *int `json:"unrepresented_occurrence_count"`
|
||||
Groups *[]findingGroupDocument `json:"groups"`
|
||||
}
|
||||
|
||||
func loadDiagnostics(path string) ([]DiagnosticSummary, int, bool, error) {
|
||||
var document diagnosticDocument
|
||||
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
|
||||
return nil, 0, false, fmt.Errorf("decode notarius diagnostics: %w", err)
|
||||
}
|
||||
if document.SchemaVersion != diagnosticsSchemaVersion || document.GroupCount == nil ||
|
||||
document.OccurrenceCount == nil || document.Truncated == nil ||
|
||||
document.UnrepresentedOccurrenceCount == nil || document.Groups == nil {
|
||||
return nil, 0, false, fmt.Errorf("notarius diagnostics document is missing or incompatible required fields")
|
||||
}
|
||||
if *document.GroupCount < 0 || *document.GroupCount > maxDiagnosticGroups || *document.OccurrenceCount < 0 ||
|
||||
*document.UnrepresentedOccurrenceCount < 0 || *document.GroupCount != len(*document.Groups) {
|
||||
return nil, 0, false, fmt.Errorf("notarius diagnostics document counts are inconsistent")
|
||||
}
|
||||
if !*document.Truncated && *document.UnrepresentedOccurrenceCount != 0 {
|
||||
return nil, 0, false, fmt.Errorf("notarius diagnostics document has unrepresented occurrences without truncation")
|
||||
}
|
||||
summaries := make([]DiagnosticSummary, 0, len(*document.Groups))
|
||||
representedOccurrences := 0
|
||||
for _, group := range *document.Groups {
|
||||
if err := validateFindingGroup(group); err != nil {
|
||||
return nil, 0, false, fmt.Errorf("notarius diagnostic group: %w", err)
|
||||
}
|
||||
if group.Disposition != "advisory" && group.Disposition != "observation" {
|
||||
return nil, 0, false, fmt.Errorf("notarius diagnostic group disposition %q is invalid", group.Disposition)
|
||||
}
|
||||
if *group.OccurrenceCount > int(^uint(0)>>1)-representedOccurrences {
|
||||
return nil, 0, false, fmt.Errorf("notarius diagnostic occurrence count overflows")
|
||||
}
|
||||
representedOccurrences += *group.OccurrenceCount
|
||||
summaries = append(summaries, DiagnosticSummary{
|
||||
Disposition: group.Disposition, Category: group.Category, ReasonCode: group.ReasonCode,
|
||||
Origin: diagnosticOrigin(group.Origin), OccurrenceCount: *group.OccurrenceCount,
|
||||
})
|
||||
}
|
||||
if *document.UnrepresentedOccurrenceCount > int(^uint(0)>>1)-representedOccurrences ||
|
||||
representedOccurrences+*document.UnrepresentedOccurrenceCount != *document.OccurrenceCount {
|
||||
return nil, 0, false, fmt.Errorf("notarius diagnostics document occurrence count is inconsistent")
|
||||
}
|
||||
return summaries, *document.OccurrenceCount, *document.Truncated, nil
|
||||
}
|
||||
|
||||
func validateFindingGroup(group findingGroupDocument) error {
|
||||
if strings.TrimSpace(group.Disposition) == "" || strings.TrimSpace(group.Category) == "" ||
|
||||
strings.TrimSpace(group.ReasonCode) == "" || !validDiagnosticOriginStage(group.Origin.Stage) ||
|
||||
!validDiagnosticCategory(group.Disposition, group.Category) ||
|
||||
group.OccurrenceCount == nil || *group.OccurrenceCount <= 0 || group.Samples == nil ||
|
||||
group.OmittedSampleCount == nil || *group.OmittedSampleCount < 0 {
|
||||
return fmt.Errorf("missing required fields")
|
||||
}
|
||||
if len(*group.Samples) == 0 || len(*group.Samples) > maxFindingSamples ||
|
||||
*group.OmittedSampleCount != *group.OccurrenceCount-len(*group.Samples) {
|
||||
return fmt.Errorf("sample counts are inconsistent")
|
||||
}
|
||||
for _, sample := range *group.Samples {
|
||||
if strings.TrimSpace(sample.Scope) == "" || strings.TrimSpace(sample.Message) == "" ||
|
||||
(sample.ChunkIndex != nil && *sample.ChunkIndex < 0) {
|
||||
return fmt.Errorf("samples require scope and message")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validDiagnosticCategory(disposition, category string) bool {
|
||||
switch disposition {
|
||||
case "warning":
|
||||
return category == "configuration" || category == "degradation" ||
|
||||
category == "validation_incomplete" || category == "fallback"
|
||||
case "advisory":
|
||||
return category == "data_quality"
|
||||
case "observation":
|
||||
return category == "normalization"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validDiagnosticOriginStage(stage string) bool {
|
||||
switch stage {
|
||||
case "references", "chunk", "extract", "merge", "normalize":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func diagnosticOrigin(document diagnosticOriginDocument) DiagnosticOrigin {
|
||||
return DiagnosticOrigin{
|
||||
Stage: document.Stage, StepID: document.StepID, LaneID: document.LaneID,
|
||||
ModuleKey: document.ModuleKey, ValidatorKey: document.ValidatorKey,
|
||||
}
|
||||
}
|
||||
|
||||
func sumWarningOccurrences(values []WarningSummary) (int, error) {
|
||||
total := 0
|
||||
for _, value := range values {
|
||||
if value.OccurrenceCount > int(^uint(0)>>1)-total {
|
||||
return 0, fmt.Errorf("notarius warning occurrence count overflows")
|
||||
}
|
||||
total += value.OccurrenceCount
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func decodeBoundedJSON(path string, limit int64, destination any) error {
|
||||
data, err := fileops.ReadRegularFile(path, limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -52,6 +52,10 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
|
||||
if result.Receipt.SchemaVersion != ReceiptSchemaVersion || result.Receipt.RunID != "notarius-run-1" {
|
||||
t.Fatalf("receipt = %#v", result.Receipt)
|
||||
}
|
||||
if len(result.Receipt.ValidationSummaries) != 1 || result.Receipt.ValidationSummaries[0].LaneID != "npc-registry" ||
|
||||
result.Receipt.ValidationSummaries[0].Status != "complete" {
|
||||
t.Fatalf("validation summaries = %#v", result.Receipt.ValidationSummaries)
|
||||
}
|
||||
if len(result.Index.Lanes) != 1 || result.Index.Lanes[0].LaneID != "npc-registry" {
|
||||
t.Fatalf("lanes = %#v", result.Index.Lanes)
|
||||
}
|
||||
@@ -64,9 +68,107 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
|
||||
if len(result.Rejections) != 1 || result.Rejections[0].LaneID != "spells" || result.Rejections[0].ReasonCode != "invalid_spell" {
|
||||
t.Fatalf("rejections = %#v", result.Rejections)
|
||||
}
|
||||
if len(result.Warnings) != 1 || result.Warnings[0].Scope != "lane:npc-registry" || result.Warnings[0].ReasonCode != "normalized_name" {
|
||||
if len(result.Warnings) != 1 || result.Warnings[0].Category != "degradation" || result.Warnings[0].ReasonCode != "normalized_name" {
|
||||
t.Fatalf("warnings = %#v", result.Warnings)
|
||||
}
|
||||
if len(result.Diagnostics) != 1 || result.Diagnostics[0].Category != "data_quality" || result.Diagnostics[0].ReasonCode != "low_confidence" {
|
||||
t.Fatalf("diagnostics = %#v", result.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerBuildsOrderedReferenceArguments(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
referenceRoot := t.TempDir()
|
||||
req.References = []ReferenceBinding{
|
||||
{Selector: " party ", Path: filepath.Join(referenceRoot, "party context=primary.json")},
|
||||
{Selector: " npc-registry . extract . glossary ", Path: filepath.Join(referenceRoot, "glossary.json")},
|
||||
}
|
||||
originalReferences := append([]ReferenceBinding(nil), req.References...)
|
||||
var captured sharedsubprocess.RunRequest
|
||||
runner := &SubprocessRunner{run: func(_ context.Context, processReq sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
captured = processReq
|
||||
writeValidBundleAndReceipt(t, req, false)
|
||||
return sharedsubprocess.RunResult{ExitCode: 0}, nil
|
||||
}}
|
||||
|
||||
if _, err := runner.Run(context.Background(), req); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
wantArgs := []string{
|
||||
"run", req.PipelineID,
|
||||
"--config", req.ConfigPath,
|
||||
"--input", req.InputPath,
|
||||
"--output-dir", req.OutputRoot,
|
||||
"--reference", "party=" + req.References[0].Path,
|
||||
"--reference", "npc-registry.extract.glossary=" + req.References[1].Path,
|
||||
"--json",
|
||||
}
|
||||
if !reflect.DeepEqual(captured.Args, wantArgs) {
|
||||
t.Fatalf("subprocess args = %#v, want %#v", captured.Args, wantArgs)
|
||||
}
|
||||
if !reflect.DeepEqual(req.References, originalReferences) {
|
||||
t.Fatalf("Run() mutated caller references = %#v, want %#v", req.References, originalReferences)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerRejectsInvalidReferencesBeforeLaunch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
references func(string) []ReferenceBinding
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "invalid selector",
|
||||
references: func(root string) []ReferenceBinding {
|
||||
return []ReferenceBinding{{Selector: "lane.prepare.party", Path: filepath.Join(root, "party.json")}}
|
||||
},
|
||||
wantErr: "selector",
|
||||
},
|
||||
{
|
||||
name: "duplicate normalized selector",
|
||||
references: func(root string) []ReferenceBinding {
|
||||
return []ReferenceBinding{
|
||||
{Selector: "lane.party", Path: filepath.Join(root, "party.json")},
|
||||
{Selector: " lane . party ", Path: filepath.Join(root, "party-2.json")},
|
||||
}
|
||||
},
|
||||
wantErr: "duplicated",
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
references: func(string) []ReferenceBinding {
|
||||
return []ReferenceBinding{{Selector: "party", Path: " "}}
|
||||
},
|
||||
wantErr: "path is required",
|
||||
},
|
||||
{
|
||||
name: "relative path",
|
||||
references: func(string) []ReferenceBinding {
|
||||
return []ReferenceBinding{{Selector: "party", Path: "references/party.json"}}
|
||||
},
|
||||
wantErr: "path must be absolute",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := validRunRequest(t)
|
||||
req.References = tt.references(t.TempDir())
|
||||
started := false
|
||||
runner := &SubprocessRunner{run: func(context.Context, sharedsubprocess.RunRequest) (sharedsubprocess.RunResult, error) {
|
||||
started = true
|
||||
return sharedsubprocess.RunResult{}, nil
|
||||
}}
|
||||
|
||||
_, err := runner.Run(context.Background(), req)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("Run() error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
if started {
|
||||
t.Fatal("subprocess started after request validation failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
|
||||
@@ -171,8 +273,10 @@ func TestLoadReceiptValidation(t *testing.T) {
|
||||
valid := map[string]any{
|
||||
"schema_version": ReceiptSchemaVersion, "run_id": "run-1", "pipeline_id": "pipeline-1",
|
||||
"output_directory": filepath.Join(root, "outputs", "run-1"), "index_file": "index.json",
|
||||
"normalized_output_count": 1, "rejected_output_count": 0, "warning_count": 0,
|
||||
"validation_status": "approved", "future_field": true,
|
||||
"normalized_output_count": 1, "rejected_output_count": 0,
|
||||
"warning_group_count": 0, "warning_occurrence_count": 0,
|
||||
"diagnostic_group_count": 0, "diagnostic_occurrence_count": 0,
|
||||
"diagnostics_truncated": false, "validation_status": "approved", "future_field": true,
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -183,11 +287,12 @@ func TestLoadReceiptValidation(t *testing.T) {
|
||||
}{
|
||||
{name: "unknown fields tolerated", wantOK: true},
|
||||
{name: "malformed", raw: []byte("{")},
|
||||
{name: "unsupported version", mutate: func(v map[string]any) { v["schema_version"] = "notarius.run-result.v2" }},
|
||||
{name: "unsupported version", mutate: func(v map[string]any) { v["schema_version"] = "notarius.run-result.v1" }},
|
||||
{name: "missing field", mutate: func(v map[string]any) { delete(v, "run_id") }},
|
||||
{name: "pipeline mismatch", mutate: func(v map[string]any) { v["pipeline_id"] = "other" }},
|
||||
{name: "relative output", mutate: func(v map[string]any) { v["output_directory"] = "run-1" }},
|
||||
{name: "negative count", mutate: func(v map[string]any) { v["warning_count"] = -1 }},
|
||||
{name: "negative count", mutate: func(v map[string]any) { v["warning_group_count"] = -1 }},
|
||||
{name: "invalid validation status", mutate: func(v map[string]any) { v["validation_status"] = "valid" }},
|
||||
{
|
||||
name: "nested index", mutate: func(v map[string]any) { v["index_file"] = "nested/index.json" },
|
||||
wantError: `index_file "nested/index.json"`,
|
||||
@@ -369,22 +474,26 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
|
||||
root := t.TempDir()
|
||||
rejectedPath := filepath.Join(root, "rejected.json")
|
||||
warningsPath := filepath.Join(root, "warnings.json")
|
||||
diagnosticsPath := filepath.Join(root, "diagnostics.json")
|
||||
writeJSONFile(t, rejectedPath, map[string]any{"rejected": []any{map[string]any{
|
||||
"stage": "validate", "lane_id": "spells", "reason_code": "invalid", "message": "do not retain this", "future": true,
|
||||
}}, "future": true})
|
||||
writeJSONFile(t, warningsPath, map[string]any{"warnings": []any{map[string]any{
|
||||
"scope": "lane:spells", "reason_code": "bounded", "message": "do not retain this", "future": true,
|
||||
}}, "future": true})
|
||||
writeJSONFile(t, warningsPath, findingEnvelope(warningsSchemaVersion, []any{findingGroup("warning", "degradation", "bounded", "normalize", 2)}, 2, false, 0))
|
||||
writeJSONFile(t, diagnosticsPath, findingEnvelope(diagnosticsSchemaVersion, []any{findingGroup("advisory", "data_quality", "low_confidence", "normalize", 3)}, 4, true, 1))
|
||||
rejections, err := loadRejections(rejectedPath)
|
||||
if err != nil || len(rejections) != 1 || rejections[0].ReasonCode != "invalid" {
|
||||
t.Fatalf("loadRejections() = %#v, %v", rejections, err)
|
||||
}
|
||||
warnings, err := loadWarnings(warningsPath)
|
||||
if err != nil || len(warnings) != 1 || warnings[0].Scope != "lane:spells" {
|
||||
if err != nil || len(warnings) != 1 || warnings[0].Category != "degradation" || warnings[0].OccurrenceCount != 2 {
|
||||
t.Fatalf("loadWarnings() = %#v, %v", warnings, err)
|
||||
}
|
||||
diagnostics, occurrences, truncated, err := loadDiagnostics(diagnosticsPath)
|
||||
if err != nil || len(diagnostics) != 1 || occurrences != 4 || !truncated || diagnostics[0].Category != "data_quality" {
|
||||
t.Fatalf("loadDiagnostics() = %#v, %d, %t, %v", diagnostics, occurrences, truncated, err)
|
||||
}
|
||||
|
||||
for name, path := range map[string]string{"rejections": rejectedPath, "warnings": warningsPath} {
|
||||
for name, path := range map[string]string{"rejections": rejectedPath, "warnings": warningsPath, "diagnostics": diagnosticsPath} {
|
||||
t.Run("malformed "+name, func(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte("{"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
@@ -392,8 +501,10 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
|
||||
var err error
|
||||
if name == "rejections" {
|
||||
_, err = loadRejections(path)
|
||||
} else {
|
||||
} else if name == "warnings" {
|
||||
_, err = loadWarnings(path)
|
||||
} else {
|
||||
_, _, _, err = loadDiagnostics(path)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("summary decoder error = nil")
|
||||
@@ -410,16 +521,23 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
|
||||
if _, err := loadRejections(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("loadRejections(oversized) error = %v", err)
|
||||
}
|
||||
if _, _, _, err := loadDiagnostics(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("loadDiagnostics(oversized) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFakeRunnerCapturesRequestsAndHonorsContextAndError(t *testing.T) {
|
||||
req := RunRequest{PipelineID: "pipeline"}
|
||||
req := RunRequest{PipelineID: "pipeline", References: []ReferenceBinding{{Selector: "party", Path: "/references/party.json"}}}
|
||||
want := RunResult{BundleRoot: "/bundle"}
|
||||
fake := &FakeRunner{Result: want}
|
||||
got, err := fake.Run(context.Background(), req)
|
||||
if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{req}) {
|
||||
t.Fatalf("Run() = %#v, %v; requests = %#v", got, err, fake.Requests)
|
||||
}
|
||||
req.References[0].Path = "/references/changed.json"
|
||||
if fake.Requests[0].References[0].Path != "/references/party.json" {
|
||||
t.Fatalf("fake retained aliased request references: %#v", fake.Requests[0].References)
|
||||
}
|
||||
|
||||
wantErr := errors.New("configured failure")
|
||||
fake.Err = wantErr
|
||||
@@ -478,13 +596,12 @@ func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown boo
|
||||
}
|
||||
}
|
||||
rejection := map[string]any{"stage": "validate", "lane_id": "spells", "reason_code": "invalid_spell", "message": strings.Repeat("external detail", 20)}
|
||||
warning := map[string]any{"scope": "lane:npc-registry", "reason_code": "normalized_name", "message": strings.Repeat("external warning", 20)}
|
||||
if includeUnknown {
|
||||
rejection["future"] = true
|
||||
warning["future"] = true
|
||||
}
|
||||
writeJSONFile(t, filepath.Join(bundle, "rejected.json"), map[string]any{"rejected": []any{rejection}, "future": true})
|
||||
writeJSONFile(t, filepath.Join(bundle, "warnings.json"), map[string]any{"warnings": []any{warning}, "future": true})
|
||||
writeJSONFile(t, filepath.Join(bundle, "warnings.json"), findingEnvelope(warningsSchemaVersion, []any{findingGroup("warning", "degradation", "normalized_name", "normalize", 2)}, 2, false, 0))
|
||||
writeJSONFile(t, filepath.Join(bundle, "diagnostics.json"), findingEnvelope(diagnosticsSchemaVersion, []any{findingGroup("advisory", "data_quality", "low_confidence", "normalize", 3)}, 4, true, 1))
|
||||
index := validIndexValue([]any{map[string]any{
|
||||
"lane_id": "npc-registry", "file": "lanes/npc.json", "media_type": "application/json",
|
||||
"module_key": "dnd/npc-registry", "schema_id": "notarius.dnd.npc_registry",
|
||||
@@ -503,7 +620,13 @@ func writeValidBundleAndReceipt(t *testing.T, req RunRequest, includeUnknown boo
|
||||
receipt := map[string]any{
|
||||
"schema_version": ReceiptSchemaVersion, "run_id": "notarius-run-1", "pipeline_id": req.PipelineID,
|
||||
"output_directory": bundle, "index_file": "index.json", "normalized_output_count": 1,
|
||||
"rejected_output_count": 1, "warning_count": 1, "validation_status": "rejected",
|
||||
"rejected_output_count": 1, "warning_group_count": 1, "warning_occurrence_count": 2,
|
||||
"diagnostic_group_count": 1, "diagnostic_occurrence_count": 4,
|
||||
"diagnostics_truncated": true, "validation_status": "rejected",
|
||||
"validation_summaries": []any{map[string]any{
|
||||
"stage": "normalize", "lane_id": "npc-registry", "status": "complete",
|
||||
"producer_attempt_count": 1, "terminal_action": "accepted",
|
||||
}},
|
||||
}
|
||||
if includeUnknown {
|
||||
receipt["future"] = true
|
||||
@@ -517,7 +640,7 @@ func createBundleSkeleton(t *testing.T) string {
|
||||
if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(bundle) error = %v", err)
|
||||
}
|
||||
for _, name := range []string{"manifest.json", "rejected.json", "warnings.json", "lanes/npc.json", "chunk-map.json"} {
|
||||
for _, name := range []string{"manifest.json", "rejected.json", "warnings.json", "diagnostics.json", "lanes/npc.json", "chunk-map.json"} {
|
||||
if err := os.WriteFile(filepath.Join(bundle, filepath.FromSlash(name)), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error = %v", name, err)
|
||||
}
|
||||
@@ -528,10 +651,31 @@ func createBundleSkeleton(t *testing.T) string {
|
||||
func validIndexValue(lanes []any) map[string]any {
|
||||
return map[string]any{
|
||||
"manifest_file": "manifest.json", "output_files": lanes,
|
||||
"rejected_file": "rejected.json", "warnings_file": "warnings.json",
|
||||
"rejected_file": "rejected.json", "warnings_file": "warnings.json", "diagnostics_file": "diagnostics.json",
|
||||
}
|
||||
}
|
||||
|
||||
func findingGroup(disposition, category, reasonCode, origin string, occurrences int) map[string]any {
|
||||
return map[string]any{
|
||||
"disposition": disposition, "category": category, "reason_code": reasonCode,
|
||||
"origin": map[string]any{"stage": origin, "lane_id": "npc-registry"}, "occurrence_count": occurrences,
|
||||
"samples": []any{map[string]any{"scope": "lane:npc-registry", "message": "external detail"}},
|
||||
"omitted_sample_count": occurrences - 1,
|
||||
}
|
||||
}
|
||||
|
||||
func findingEnvelope(schema string, groups []any, occurrences int, truncated bool, unrepresented int) map[string]any {
|
||||
value := map[string]any{
|
||||
"schema_version": schema, "group_count": len(groups), "occurrence_count": occurrences,
|
||||
"groups": groups,
|
||||
}
|
||||
if schema == diagnosticsSchemaVersion {
|
||||
value["truncated"] = truncated
|
||||
value["unrepresented_occurrence_count"] = unrepresented
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func writeJSONFile(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
// Package subprocess provides reusable process execution and generated-config helpers.
|
||||
package subprocess
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
|
||||
@@ -43,8 +42,8 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
executeStagesFn = origExecuteStagesFn
|
||||
})
|
||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||
for _, s := range stages {
|
||||
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||
for _, s := range plan.Stages() {
|
||||
capturedStages = append(capturedStages, s.Name())
|
||||
}
|
||||
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||
@@ -115,8 +114,8 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") {
|
||||
t.Fatalf("output = %q, want analyze skip without force", out.String())
|
||||
if !strings.Contains(out.String(), "stage=analyze executed=1 skipped=0 force=false") {
|
||||
t.Fatalf("output = %q, want legacy analyze evidence rebuilt without implying force", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +143,8 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "executed=1 skipped=11") {
|
||||
t.Fatalf("output = %q, want all stages skipped", out.String())
|
||||
if !strings.Contains(out.String(), "executed=4 skipped=8") {
|
||||
t.Fatalf("output = %q, want extract reconsidered and legacy analyze plus delivery rebuilt", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,8 +158,8 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
executeStagesFn = origExecuteStagesFn
|
||||
})
|
||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||
for _, s := range stages {
|
||||
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||
for _, s := range plan.Stages() {
|
||||
capturedStages = append(capturedStages, s.Name())
|
||||
}
|
||||
capturedForce = opts.Force
|
||||
@@ -200,7 +199,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
executeStagesFn = origExecuteStagesFn
|
||||
})
|
||||
executeStagesFn = func(_ context.Context, _ *config.Config, _ []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||
executeStagesFn = func(_ context.Context, _ *config.Config, _ BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
|
||||
}
|
||||
@@ -293,8 +292,8 @@ func TestExecutePublishForceRunsPublish(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
executeStagesFn = origExecuteStagesFn
|
||||
})
|
||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||
for _, s := range stages {
|
||||
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||
for _, s := range plan.Stages() {
|
||||
capturedStages = append(capturedStages, s.Name())
|
||||
}
|
||||
capturedForce = opts.Force
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
var supportedCommands = []string{"run", "run-stage", "analyze", "publish", "clean", "session"}
|
||||
var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session"}
|
||||
|
||||
var runCommandFn = Run
|
||||
|
||||
// Execute dispatches CLI commands and returns a process exit code.
|
||||
func Execute(args []string, stdout, stderr io.Writer) int {
|
||||
@@ -22,8 +24,12 @@ func Execute(args []string, stdout, stderr io.Writer) int {
|
||||
|
||||
var err error
|
||||
switch cmd {
|
||||
case "version":
|
||||
err = Version(cmdArgs, stdout)
|
||||
case "run":
|
||||
err = Run(ctx, cmdArgs, stdout)
|
||||
err = runCommandFn(ctx, cmdArgs, stdout)
|
||||
case "regenerate-artifacts":
|
||||
err = RegenerateArtifacts(ctx, cmdArgs, stdout)
|
||||
case "run-stage":
|
||||
err = RunStage(ctx, cmdArgs, stdout)
|
||||
case "analyze":
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestExecuteValidCommands(t *testing.T) {
|
||||
wantOut string
|
||||
}{
|
||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=11 skipped=1; manifest="},
|
||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nextract: run\nrender: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
|
||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "analyze: skip\n targets: none\n prerequisites: none\n execute: none\n reuse: none\npublish: skip\nnotify: skip"},
|
||||
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
||||
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
@@ -25,6 +27,37 @@ type materializingNotariusRunner struct {
|
||||
failuresRemaining int
|
||||
}
|
||||
|
||||
type assertExtractionSourcesStage struct {
|
||||
keys []string
|
||||
runs *int
|
||||
}
|
||||
|
||||
func (s assertExtractionSourcesStage) Name() string { return "analyze" }
|
||||
|
||||
func (s assertExtractionSourcesStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||
definitions := artifacts.ExtractionDefinitionsFromConfig(env.Config.Pipeline.Notarius)
|
||||
catalog, err := artifacts.BootstrapRuntimeCatalog(nil, env.EffectiveArtifacts, definitions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths, err := env.ArtifactStore.EnsureLayoutFor(env.Config.Session.Campaign, env.Config.Session.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog.HydrateExtractionArtifacts(paths, m, definitions)
|
||||
for _, key := range s.keys {
|
||||
sourceID := artifacts.ExtractionArtifactSourceID(key)
|
||||
entry, ok := catalog.Lookup(sourceID)
|
||||
if !ok || !entry.Available || entry.SourceID != sourceID || entry.Path == "" {
|
||||
return nil, fmt.Errorf("extraction source %q unavailable: %#v, present=%v", sourceID, entry, ok)
|
||||
}
|
||||
}
|
||||
if s.runs != nil {
|
||||
*s.runs = *s.runs + 1
|
||||
}
|
||||
return &stage.StageResult{}, nil
|
||||
}
|
||||
|
||||
func (r *materializingNotariusRunner) Run(_ context.Context, req notarius.RunRequest) (notarius.RunResult, error) {
|
||||
r.requests = append(r.requests, req)
|
||||
if r.failuresRemaining > 0 {
|
||||
@@ -41,29 +74,44 @@ func (r *materializingNotariusRunner) Run(_ context.Context, req notarius.RunReq
|
||||
filepath.Join(bundle, "index.json"): `{"manifest_file":"manifest.json"}`,
|
||||
filepath.Join(bundle, "manifest.json"): `{}`,
|
||||
filepath.Join(bundle, "rejected.json"): `{"rejected":[]}`,
|
||||
filepath.Join(bundle, "warnings.json"): `{"warnings":[]}`,
|
||||
filepath.Join(lanesDir, "npcs.json"): `{"npcs":[]}`,
|
||||
filepath.Join(bundle, "warnings.json"): `{"schema_version":"notarius.warnings.v2","group_count":0,"occurrence_count":0,"groups":[]}`,
|
||||
filepath.Join(bundle, "diagnostics.json"): `{"schema_version":"notarius.diagnostics.v1","group_count":0,"occurrence_count":0,"truncated":false,"unrepresented_occurrence_count":0,"groups":[]}`,
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return notarius.RunResult{}, err
|
||||
}
|
||||
}
|
||||
output := r.cfg.Outputs["npc_registry"]
|
||||
keys := make([]string, 0, len(r.cfg.Outputs))
|
||||
for key := range r.cfg.Outputs {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lanes := make([]notarius.LaneDescriptor, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
output := r.cfg.Outputs[key]
|
||||
filename := key + ".json"
|
||||
path := filepath.Join(lanesDir, filename)
|
||||
if err := os.WriteFile(path, []byte(`{"records":[]}`), 0o644); err != nil {
|
||||
return notarius.RunResult{}, err
|
||||
}
|
||||
lanes = append(lanes, notarius.LaneDescriptor{
|
||||
LaneID: output.LaneID, File: filepath.ToSlash(filepath.Join("lanes", filename)), Path: path,
|
||||
MediaType: output.MediaType, SchemaID: output.SchemaID,
|
||||
SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
|
||||
})
|
||||
}
|
||||
return notarius.RunResult{
|
||||
Receipt: notarius.Receipt{
|
||||
SchemaVersion: notarius.ReceiptSchemaVersion, RunID: externalRunID,
|
||||
PipelineID: req.PipelineID, OutputDirectory: bundle, IndexFile: "index.json",
|
||||
NormalizedOutputCount: 1, ValidationStatus: "valid",
|
||||
NormalizedOutputCount: len(lanes), ValidationStatus: "approved",
|
||||
},
|
||||
BundleRoot: bundle,
|
||||
Index: notarius.Index{
|
||||
Path: filepath.Join(bundle, "index.json"), RejectedPath: filepath.Join(bundle, "rejected.json"),
|
||||
WarningsPath: filepath.Join(bundle, "warnings.json"),
|
||||
Lanes: []notarius.LaneDescriptor{{
|
||||
LaneID: output.LaneID, File: "lanes/npcs.json", Path: filepath.Join(lanesDir, "npcs.json"),
|
||||
MediaType: output.MediaType, SchemaID: output.SchemaID,
|
||||
SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
|
||||
}},
|
||||
DiagnosticsPath: filepath.Join(bundle, "diagnostics.json"),
|
||||
Lanes: lanes,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -100,6 +148,185 @@ func TestExtractLifecycleDisabledThenEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
originalPaths := configureLifecycleReferences(t, cfg)
|
||||
|
||||
summary, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if len(summary.Executed) != 2 || len(runner.requests) != 1 {
|
||||
t.Fatalf("summary = %#v requests=%d", summary, len(runner.requests))
|
||||
}
|
||||
paths, err := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureLayoutFor() error = %v", err)
|
||||
}
|
||||
want := []struct {
|
||||
selector string
|
||||
sourceID string
|
||||
filename string
|
||||
}{
|
||||
{selector: "glossary", sourceID: artifactpolicy.SourceInputGlossary, filename: "glossary.yml"},
|
||||
{selector: "party", sourceID: artifactpolicy.SourceInputParty, filename: "party.yml"},
|
||||
{selector: "players", sourceID: artifactpolicy.SourceInputPlayers, filename: "players.yml"},
|
||||
{selector: "spells", sourceID: artifactpolicy.SourceInputSpellCatalog, filename: "spell_catalog.json"},
|
||||
}
|
||||
request := runner.requests[0]
|
||||
if len(request.References) != len(want) {
|
||||
t.Fatalf("references = %#v", request.References)
|
||||
}
|
||||
loaded := loadLifecycleManifest(t, cfg)
|
||||
for index, expected := range want {
|
||||
binding := request.References[index]
|
||||
canonical := filepath.Join(paths.InputsDir, expected.filename)
|
||||
snapshot := filepath.Join(
|
||||
artifacts.SessionRunNotariusReferencesDirForCampaign(
|
||||
cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, loaded.RunID,
|
||||
),
|
||||
expected.filename,
|
||||
)
|
||||
if binding.Selector != expected.selector || binding.Path != snapshot || binding.Path == canonical || binding.Path == originalPaths[expected.sourceID] {
|
||||
t.Fatalf("reference[%d] = %#v, want selector %q snapshot %q and not prepared/source paths", index, binding, expected.selector, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
extract := loaded.Stages["extract"]
|
||||
if extract == nil || extract.Status != manifest.StatusSucceeded || extract.Metadata["reference_count"] != float64(len(want)) {
|
||||
t.Fatalf("extract record = %#v", extract)
|
||||
}
|
||||
references, ok := extract.Metadata["references"].([]any)
|
||||
if !ok || len(references) != len(want) || len(references) > config.MaxNotariusReferenceBindings {
|
||||
t.Fatalf("reference metadata = %#v", extract.Metadata["references"])
|
||||
}
|
||||
for index, raw := range references {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok || len(entry) != 5 || entry["selector"] != want[index].selector || entry["source_id"] != want[index].sourceID {
|
||||
t.Fatalf("reference metadata[%d] = %#v", index, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDownstream(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
originalPaths := configureLifecycleReferences(t, cfg)
|
||||
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("initial executeStages() error = %v", err)
|
||||
}
|
||||
before := loadLifecycleManifest(t, cfg)
|
||||
beforeChecksum := lifecycleInputChecksum(t, before, "party")
|
||||
for _, name := range []string{"render", "analyze", "publish"} {
|
||||
before.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
}
|
||||
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), before); err != nil {
|
||||
t.Fatalf("Save(downstream success) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(originalPaths[artifactpolicy.SourceInputParty], []byte("changed party bytes\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(party source) error = %v", err)
|
||||
}
|
||||
|
||||
prepared := loadLifecycleManifest(t, cfg)
|
||||
prepare, err := stage.Select("prepare")
|
||||
if err != nil {
|
||||
t.Fatalf("stage.Select(prepare) error = %v", err)
|
||||
}
|
||||
if _, err := prepare.Run(context.Background(), env, prepared); err != nil {
|
||||
t.Fatalf("prepare.Run() error = %v", err)
|
||||
}
|
||||
if lifecycleInputChecksum(t, prepared, "party") == beforeChecksum {
|
||||
t.Fatal("prepared party checksum did not change")
|
||||
}
|
||||
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), prepared); err != nil {
|
||||
t.Fatalf("Save(reprepared manifest) error = %v", err)
|
||||
}
|
||||
|
||||
plan, err := BuildSingleStagePlan("extract")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
|
||||
}
|
||||
run, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatalf("rerun executeStages() error = %v", err)
|
||||
}
|
||||
if len(run.Executed) != 1 || len(run.Skipped) != 0 || len(runner.requests) != 2 {
|
||||
t.Fatalf("rerun summary = %#v requests=%d", run, len(runner.requests))
|
||||
}
|
||||
after := loadLifecycleManifest(t, cfg)
|
||||
if after.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("extract status = %#v", after.Stages["extract"])
|
||||
}
|
||||
if after.Stages["render"] == nil || after.Stages["render"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("render status = %#v, want succeeded sibling", after.Stages["render"])
|
||||
}
|
||||
for _, name := range []string{"analyze", "publish"} {
|
||||
if after.Stages[name] == nil || after.Stages[name].Status != manifest.StatusStale {
|
||||
t.Fatalf("%s status = %#v, want stale", name, after.Stages[name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleSessionOverrideBytesReachCanonicalReference(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
configureLifecycleReferences(t, cfg)
|
||||
overridePath := filepath.Join(filepath.Dir(cfg.SessionPath), "session-party.yml")
|
||||
if err := os.WriteFile(overridePath, []byte("session override party\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(session override) error = %v", err)
|
||||
}
|
||||
cfg.StableInputs.PartyFile = config.ResolvedInputFile{
|
||||
Path: "./session-party.yml", ConfigPath: cfg.SessionPath, Source: "session_config",
|
||||
}
|
||||
cfg.Session.Inputs.PartyFile = "./session-party.yml"
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if len(runner.requests) != 1 {
|
||||
t.Fatalf("requests = %d", len(runner.requests))
|
||||
}
|
||||
var partyPath string
|
||||
for _, binding := range runner.requests[0].References {
|
||||
if binding.Selector == "party" {
|
||||
partyPath = binding.Path
|
||||
}
|
||||
}
|
||||
contents, err := os.ReadFile(partyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(prepared party) error = %v", err)
|
||||
}
|
||||
if string(contents) != "session override party\n" || partyPath == overridePath {
|
||||
t.Fatalf("prepared party path=%q contents=%q override=%q", partyPath, contents, overridePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleEmptyReferencesPreserveAllDndExtractionSources(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||
cfg.Pipeline.Notarius.Outputs = lifecycleDndOutputs()
|
||||
keys := make([]string, 0, len(cfg.Pipeline.Notarius.Outputs))
|
||||
for key := range cfg.Pipeline.Notarius.Outputs {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
analyzeRuns := 0
|
||||
extractPlan, err := BuildSingleStagePlan("extract")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
|
||||
}
|
||||
plan := append(extractPlan, assertExtractionSourcesStage{keys: keys, runs: &analyzeRuns})
|
||||
|
||||
summary, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if len(summary.Executed) != 2 || len(runner.requests) != 1 || len(runner.requests[0].References) != 0 || analyzeRuns != 1 {
|
||||
t.Fatalf("summary=%#v requests=%#v analyze=%d", summary, runner.requests, analyzeRuns)
|
||||
}
|
||||
loaded := loadLifecycleManifest(t, cfg)
|
||||
if got := len(loaded.Stages["extract"].Outputs); got != len(keys)+1 {
|
||||
t.Fatalf("extract outputs = %d, want %d lanes plus index", got, len(keys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLifecycleChangedOutcomeRerunsSucceededDownstream(t *testing.T) {
|
||||
cfg, env, runner := extractionLifecycleFixture(t, false)
|
||||
analyzeRuns := 0
|
||||
@@ -394,6 +621,73 @@ func markLifecycleStageSucceeded(t *testing.T, cfg *config.Config, name string)
|
||||
}
|
||||
}
|
||||
|
||||
func prepareExtractLifecyclePlan(t *testing.T) []stage.Stage {
|
||||
t.Helper()
|
||||
prepare, err := BuildSingleStagePlan("prepare")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSingleStagePlan(prepare) error = %v", err)
|
||||
}
|
||||
extract, err := BuildSingleStagePlan("extract")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
|
||||
}
|
||||
return append(prepare, extract...)
|
||||
}
|
||||
|
||||
func configureLifecycleReferences(t *testing.T, cfg *config.Config) map[string]string {
|
||||
t.Helper()
|
||||
cfg.Pipeline.Notarius.References = map[string]string{
|
||||
"party": artifactpolicy.SourceInputParty,
|
||||
"players": artifactpolicy.SourceInputPlayers,
|
||||
"glossary": artifactpolicy.SourceInputGlossary,
|
||||
"spells": artifactpolicy.SourceInputSpellCatalog,
|
||||
}
|
||||
spellPath := filepath.Join(filepath.Dir(cfg.CampaignPath), "spells.json")
|
||||
if err := os.WriteFile(spellPath, []byte(`{"spells":[]}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(spell catalog) error = %v", err)
|
||||
}
|
||||
cfg.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
|
||||
Path: "./spells.json", ConfigPath: cfg.CampaignPath, Source: "campaign_config",
|
||||
}
|
||||
cfg.Session.Inputs.SpellCatalogFile = "./spells.json"
|
||||
|
||||
return map[string]string{
|
||||
artifactpolicy.SourceInputParty: filepath.Join(filepath.Dir(cfg.CampaignPath), "party.yml"),
|
||||
artifactpolicy.SourceInputPlayers: filepath.Join(filepath.Dir(cfg.CampaignPath), "players.yml"),
|
||||
artifactpolicy.SourceInputGlossary: filepath.Join(filepath.Dir(cfg.CampaignPath), "glossary.yml"),
|
||||
artifactpolicy.SourceInputSpellCatalog: spellPath,
|
||||
}
|
||||
}
|
||||
|
||||
func lifecycleInputChecksum(t *testing.T, m *manifest.Manifest, kind string) string {
|
||||
t.Helper()
|
||||
for _, input := range m.Inputs {
|
||||
if input.Kind == kind {
|
||||
if strings.TrimSpace(input.Checksum) == "" {
|
||||
t.Fatalf("input %q has no checksum: %#v", kind, input)
|
||||
}
|
||||
return input.Checksum
|
||||
}
|
||||
}
|
||||
t.Fatalf("manifest input %q not found: %#v", kind, m.Inputs)
|
||||
return ""
|
||||
}
|
||||
|
||||
func lifecycleDndOutputs() map[string]config.NotariusOutputConfig {
|
||||
return map[string]config.NotariusOutputConfig{
|
||||
"item_registry": {LaneID: "item-registry", MediaType: "application/json", SchemaID: "notarius.dnd.item_registry", SchemaVersion: "v1", ModuleKey: "dnd/item-registry"},
|
||||
"npc_registry": {LaneID: "npc-registry", MediaType: "application/json", SchemaID: "notarius.dnd.npc_registry", SchemaVersion: "v1", ModuleKey: "dnd/npc-registry"},
|
||||
"location_registry": {LaneID: "location-registry", MediaType: "application/json", SchemaID: "notarius.dnd.location_registry", SchemaVersion: "v1", ModuleKey: "dnd/location-registry"},
|
||||
"scene_descriptions": {LaneID: "scene-descriptions", MediaType: "application/json", SchemaID: "notarius.dnd.scene_descriptions", SchemaVersion: "v1", ModuleKey: "dnd/scene-descriptions"},
|
||||
"item_occurrences": {LaneID: "item-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.item_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/item-occurrences"},
|
||||
"spells": {LaneID: "spells", MediaType: "application/json", SchemaID: "notarius.dnd.spells", SchemaVersion: "v1", ModuleKey: "dnd/spells"},
|
||||
"combat_turns": {LaneID: "combat-turns", MediaType: "application/json", SchemaID: "notarius.dnd.combat_turns", SchemaVersion: "v1", ModuleKey: "dnd/combat-turns"},
|
||||
"npc_occurrences": {LaneID: "npc-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.npc_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/npc-occurrences"},
|
||||
"location_occurrences": {LaneID: "location-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.location_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/location-occurrences"},
|
||||
"enemy_events": {LaneID: "enemy-events", MediaType: "application/json", SchemaID: "notarius.dnd.enemy_events", SchemaVersion: "v1", ModuleKey: "dnd/enemy-events"},
|
||||
}
|
||||
}
|
||||
|
||||
func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *stage.Env, *materializingNotariusRunner) {
|
||||
t.Helper()
|
||||
cfg := testConfig(t)
|
||||
|
||||
@@ -31,6 +31,7 @@ func buildHelperArtifactCatalog(cfg *config.Config, m *manifest.Manifest) (*arti
|
||||
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
|
||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||
}
|
||||
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
@@ -43,7 +44,11 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
|
||||
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
||||
fmt.Fprintln(out, "Configured:")
|
||||
for _, entry := range catalog.ListConfigured() {
|
||||
writeArtifactLine(out, entry.SourceID, lockSet)
|
||||
state := "unavailable"
|
||||
if entry.Available {
|
||||
state = "available"
|
||||
}
|
||||
writeExtractionArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet)
|
||||
}
|
||||
fmt.Fprintln(out, "Extraction:")
|
||||
for _, entry := range catalog.ListExtraction() {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -458,6 +458,63 @@ inputs:
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorCommandsReportConfiguredSpellCatalog(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
campaignBytes, err := os.ReadFile(campaignPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read campaign: %v", err)
|
||||
}
|
||||
campaignYAML := strings.Replace(string(campaignBytes), " party_file: ./party.yml\n", " party_file: ./party.yml\n spell_catalog_file: ./spells.json\n", 1)
|
||||
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
||||
t.Fatalf("write campaign: %v", err)
|
||||
}
|
||||
spellPath := filepath.Join(filepath.Dir(campaignPath), "spells.json")
|
||||
mustWriteTestFile(t, spellPath, "{\"spells\":[]}\n")
|
||||
|
||||
fake := &storage.FakeBackend{}
|
||||
var storeInitCalls int
|
||||
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{sessionPath})
|
||||
commonArgs := []string{
|
||||
"2026-05-03",
|
||||
"--config", pipelinePath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
validateArgs := append([]string{"session", "validate"}, commonArgs...)
|
||||
if code := Execute(validateArgs, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("validate exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "OK inputs spell_catalog: "+spellPath) {
|
||||
t.Fatalf("validate stdout = %q, want spell catalog finding", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
statusArgs := append([]string{"session", "status"}, commonArgs...)
|
||||
if code := Execute(statusArgs, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("status exit code = %d, want 0; stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "Stable input spell_catalog: "+spellPath) {
|
||||
t.Fatalf("status stdout = %q, want spell catalog inventory", stdout.String())
|
||||
}
|
||||
|
||||
if err := os.Remove(spellPath); err != nil {
|
||||
t.Fatalf("remove spell catalog: %v", err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := Execute(validateArgs, &stdout, &stderr); code == 0 {
|
||||
t.Fatalf("validate missing spell catalog exit code = 0; stdout=%q", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "ERROR inputs spell_catalog missing:") {
|
||||
t.Fatalf("validate stdout = %q, want missing spell catalog finding", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
@@ -55,21 +55,26 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck {
|
||||
items := []struct {
|
||||
name string
|
||||
in config.ResolvedInputFile
|
||||
optional bool
|
||||
}{
|
||||
{name: "speakers", in: cfg.StableInputs.SpeakersFile},
|
||||
{name: "autocorrect", in: cfg.StableInputs.AutocorrectFile},
|
||||
{name: "glossary", in: cfg.StableInputs.GlossaryFile},
|
||||
{name: "players", in: cfg.StableInputs.PlayersFile},
|
||||
{name: "party", in: cfg.StableInputs.PartyFile},
|
||||
{name: "spell_catalog", in: cfg.StableInputs.SpellCatalogFile, optional: true},
|
||||
}
|
||||
out := make([]stableInputCheck, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.optional && strings.TrimSpace(item.in.Path) == "" {
|
||||
continue
|
||||
}
|
||||
path, err := resolveHelperConfigRelativePath(item.in)
|
||||
if err != nil {
|
||||
out = append(out, stableInputCheck{Name: item.name, Err: err})
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if err := requireInspectionFile(path, item.name); err != nil {
|
||||
out = append(out, stableInputCheck{Name: item.name, Path: path, Err: err})
|
||||
continue
|
||||
}
|
||||
@@ -271,8 +276,8 @@ func requireInspectionFile(path, label string) error {
|
||||
}
|
||||
return fmt.Errorf("stat %s %q: %w", label, path, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("%s %q is a directory", label, path)
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s %q is not a regular file", label, path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
34
internal/app/operator_inspection_fifo_test.go
Normal file
34
internal/app/operator_inspection_fifo_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//go:build unix
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestInspectStableInputsRejectsSpellCatalogFIFO(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourcePath := filepath.Join(root, "spells.fifo")
|
||||
if err := unix.Mkfifo(sourcePath, 0o644); err != nil {
|
||||
t.Fatalf("Mkfifo() error = %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{StableInputs: config.ResolvedStableInputs{
|
||||
SpellCatalogFile: config.ResolvedInputFile{Path: sourcePath, ConfigPath: filepath.Join(root, "campaign.yml")},
|
||||
}}
|
||||
for _, check := range inspectStableInputs(cfg) {
|
||||
if check.Name != "spell_catalog" {
|
||||
continue
|
||||
}
|
||||
if check.Err == nil || !strings.Contains(check.Err.Error(), "not a regular file") {
|
||||
t.Fatalf("spell catalog check = %#v, want regular-file rejection", check)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("spell catalog inspection result was not reported")
|
||||
}
|
||||
@@ -2,34 +2,30 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/logging"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
// Plan validates configuration, prepares the local workdir, and prints stage order.
|
||||
// Plan validates configuration and prints a read-only execution preview.
|
||||
func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
var flags commonConfigFlags
|
||||
var force bool
|
||||
addCommonConfigFlags(fs, &flags)
|
||||
fs.BoolVar(&force, "force", false, "show all stages as scheduled to rerun")
|
||||
|
||||
if err := parseSessionAwareFlags("plan", fs, args, &flags.sessionID); err != nil {
|
||||
request, err := parseBoundedRunRequest("plan", args, out)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if flags.sessionID == "" {
|
||||
return fmt.Errorf("plan: session_id is required")
|
||||
}
|
||||
flags := request.Config
|
||||
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
@@ -39,38 +35,75 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
if err := config.Validate(cfg); err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
if _, err := loadSecretsFromConfig(cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)); err != nil {
|
||||
effective, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
m, err := loadManifestIfPresent(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
if err := validateBoundedPrerequisites(request.Plan, m); err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
|
||||
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
||||
paths, err := store.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
paths := store.SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
model, err := cloneManifestForPlan(m, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: prepare workdir: %w", err)
|
||||
return fmt.Errorf("plan: clone session state: %w", err)
|
||||
}
|
||||
|
||||
stages := BuildFullPlan()
|
||||
var m *manifest.Manifest
|
||||
m, err = loadManifestIfPresent(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
stages := request.Plan.Stages()
|
||||
stageEnv := &stage.Env{
|
||||
Config: cfg, SelectedArtifactKeys: append([]string(nil), request.SelectedArtifacts...),
|
||||
EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force,
|
||||
}
|
||||
decisions := decideStageActions(stages, m, force)
|
||||
|
||||
runCount := 0
|
||||
skipCount := 0
|
||||
if _, err := fmt.Fprintf(out, "narratio session plan: workdir prepared at %s\n", paths.Root); err != nil {
|
||||
if _, err := fmt.Fprintf(out, "narratio session plan: read-only workdir at %s\n", paths.Root); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range decisions {
|
||||
if d.Action == stageActionRun {
|
||||
for _, selectedStage := range stages {
|
||||
action := decideStageAction(selectedStage, model, request.Force)
|
||||
var validation *stage.ResumeValidation
|
||||
if validator, ok := selectedStage.(stage.ResumeValidator); ok &&
|
||||
(action == stageActionSkip || selectedStage.Name() == "analyze") {
|
||||
checked, validationErr := validator.ValidateResume(ctx, stageEnv, model)
|
||||
if validationErr != nil {
|
||||
return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr)
|
||||
}
|
||||
checked = checked.Normalized()
|
||||
validation = &checked
|
||||
if action == stageActionSkip && !checked.Resumable {
|
||||
at := time.Now().UTC()
|
||||
model.MarkStageStale(selectedStage.Name(), at, checked.Reason)
|
||||
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(
|
||||
model, selectedStage.Name(), at, staleReasonNotResumable,
|
||||
); invalidationErr != nil {
|
||||
return fmt.Errorf("plan: model resume invalidation for stage %q: %w", selectedStage.Name(), invalidationErr)
|
||||
}
|
||||
action = stageActionRun
|
||||
}
|
||||
}
|
||||
if action == stageActionRun {
|
||||
runCount++
|
||||
} else {
|
||||
skipCount++
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "%s: %s\n", d.Stage.Name(), d.Action); err != nil {
|
||||
if _, err := fmt.Fprintf(out, "%s: %s\n", selectedStage.Name(), action); err != nil {
|
||||
return err
|
||||
}
|
||||
if validation != nil && validation.Analyze != nil {
|
||||
if err := writeAnalyzePlanDetails(out, validation.Analyze); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if action == stageActionRun {
|
||||
if err := modelPlannedStageRun(model, selectedStage, cfg, request.Force); err != nil {
|
||||
return fmt.Errorf("plan: model stage %q: %w", selectedStage.Name(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil {
|
||||
return err
|
||||
@@ -78,3 +111,102 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneManifestForPlan(source *manifest.Manifest, cfg *config.Config) (*manifest.Manifest, error) {
|
||||
if source == nil {
|
||||
created := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
created.Campaign = cfg.Session.Campaign
|
||||
return created, nil
|
||||
}
|
||||
data, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cloned manifest.Manifest
|
||||
if err := json.Unmarshal(data, &cloned); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cloned, nil
|
||||
}
|
||||
|
||||
func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, cfg *config.Config, force bool) error {
|
||||
prior := capturePriorStageOutcome(model, selectedStage.Name())
|
||||
at := time.Now().UTC()
|
||||
model.MarkStageRunning(selectedStage.Name(), at)
|
||||
if force {
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||
model, selectedStage.Name(), at, staleReasonForcedReplacement,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if reason := plannedSelfSkipReason(selectedStage.Name(), cfg); reason != "" {
|
||||
model.MarkStageSkipped(selectedStage.Name(), at, reason)
|
||||
if !prior.isSameSelfSkip(reason) {
|
||||
_, err := invalidateDependentSucceededStagesWithReason(
|
||||
model, selectedStage.Name(), at, staleReasonSelfSkip,
|
||||
)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
model.MarkStageSucceeded(selectedStage.Name(), at, nil)
|
||||
if !prior.exists || prior.status != manifest.StatusSucceeded {
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||
model, selectedStage.Name(), at, staleReasonChangedResult,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func plannedSelfSkipReason(stageName string, cfg *config.Config) string {
|
||||
if stageName == "extract" && cfg != nil && cfg.Pipeline != nil &&
|
||||
(cfg.Pipeline.Notarius == nil || !cfg.Pipeline.Notarius.Enabled) {
|
||||
return "notarius_disabled"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeAnalyzePlanDetails(out io.Writer, summary *stage.AnalyzeResumeSummary) error {
|
||||
if summary == nil {
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, " targets: %s\n", planStringList(summary.ExplicitTargets)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, " prerequisites: %s\n", planArtifactList(summary.PrerequisiteWork)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, " execute: %s\n", planArtifactList(summary.ExecutionOrder)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintf(out, " reuse: %s\n", planArtifactList(summary.ReusedCurrent))
|
||||
return err
|
||||
}
|
||||
|
||||
func planStringList(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(values, ", ")
|
||||
}
|
||||
|
||||
func planArtifactList(values []stage.AnalyzeResumeArtifact) string {
|
||||
if len(values) == 0 {
|
||||
return "none"
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
detail := value.Role
|
||||
if value.Reason != "" {
|
||||
detail += ":" + value.Reason
|
||||
}
|
||||
if value.Forced {
|
||||
detail += ":forced"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s(%s)", value.Key, detail))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
||||
func TestPlanDoesNotCreateWorkdir(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
@@ -24,10 +29,10 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
||||
t.Fatalf("first Plan() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
|
||||
t.Fatalf("first output = %q, want workdir prepared", got)
|
||||
if !strings.Contains(got, "narratio session plan: read-only workdir at") {
|
||||
t.Fatalf("first output = %q, want read-only workdir", got)
|
||||
}
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
|
||||
if !strings.Contains(got, name+": run") {
|
||||
t.Fatalf("first output = %q, missing stage %q", got, name)
|
||||
}
|
||||
@@ -37,26 +42,16 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
||||
}
|
||||
|
||||
sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
||||
expectedDirs := []string{
|
||||
sessionWorkdir,
|
||||
filepath.Join(sessionWorkdir, "inputs"),
|
||||
filepath.Join(sessionWorkdir, "audio"),
|
||||
filepath.Join(sessionWorkdir, "transcripts", "raw"),
|
||||
filepath.Join(sessionWorkdir, "transcripts", "trimmed"),
|
||||
filepath.Join(sessionWorkdir, "artifacts"),
|
||||
filepath.Join(sessionWorkdir, "config"),
|
||||
filepath.Join(sessionWorkdir, "logs"),
|
||||
}
|
||||
for _, dir := range expectedDirs {
|
||||
assertDir(t, dir)
|
||||
if _, err := os.Stat(sessionWorkdir); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("workdir stat error = %v, want absent", err)
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := Plan(context.Background(), args, &out); err != nil {
|
||||
t.Fatalf("second Plan() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "narratio session plan: workdir prepared at") {
|
||||
t.Fatalf("second output = %q, want workdir prepared", out.String())
|
||||
if !strings.Contains(out.String(), "narratio session plan: read-only workdir at") {
|
||||
t.Fatalf("second output = %q, want read-only workdir", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +84,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFailsWhenConfiguredSecretsDirMissing(t *testing.T) {
|
||||
func TestPlanDoesNotLoadConfiguredSecrets(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
configDir := t.TempDir()
|
||||
pipelinePath := filepath.Join(configDir, "pipeline.yml")
|
||||
@@ -130,21 +125,125 @@ inputs:
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "validate secrets env_dir") {
|
||||
t.Fatalf("error = %q, want secrets validation error context", err.Error())
|
||||
if err != nil {
|
||||
t.Fatalf("Plan() error = %v, want missing runtime secrets ignored", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertDir(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
info, err := os.Stat(path)
|
||||
func TestPlanAndRunShareAnalyzeArtifactDecisionsWithoutPlanSideEffects(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Stat(%q) error = %v", path, err)
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
t.Fatalf("%q is not a directory", path)
|
||||
analyze, err := stage.Select("analyze")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake := &scriptorium.FakeRunner{}
|
||||
first, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
|
||||
Env: &Env{Scriptorium: fake},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("initial analyze: %v", err)
|
||||
}
|
||||
if len(fake.RunRequests) != 2 {
|
||||
t.Fatalf("initial adapter requests = %d, want 2", len(fake.RunRequests))
|
||||
}
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
m, err := store.Load(context.Background(), first.ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
|
||||
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
}
|
||||
m.MarkStageStale("render", time.Now().UTC(), "upstream selection requires reconsideration")
|
||||
m.MarkStageSkipped("extract", time.Now().UTC(), "notarius_disabled")
|
||||
if err := store.Save(context.Background(), first.ManifestPath, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifestBefore, err := os.ReadFile(first.ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filesBefore := planFixtureFiles(t, filepath.Dir(first.ManifestPath))
|
||||
marker := filepath.Join(t.TempDir(), "adapter-invoked")
|
||||
binaryDir := t.TempDir()
|
||||
binary := filepath.Join(binaryDir, "scriptorium")
|
||||
if err := os.WriteFile(binary, []byte("#!/bin/sh\ntouch \""+marker+"\"\nexit 99\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", binaryDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
var out bytes.Buffer
|
||||
err = Plan(context.Background(), []string{
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||
"--from", "render", "--through", "analyze",
|
||||
}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Plan() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"render: run", "extract: run", "analyze: run",
|
||||
" targets: player_handout, session_recap",
|
||||
" prerequisites: none", " execute: none",
|
||||
"player_handout(target:current)", "session_recap(target:current)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("plan output = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
manifestAfter, err := os.ReadFile(first.ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(manifestBefore, manifestAfter) {
|
||||
t.Fatal("plan modified the session manifest")
|
||||
}
|
||||
if filesAfter := planFixtureFiles(t, filepath.Dir(first.ManifestPath)); !reflect.DeepEqual(filesAfter, filesBefore) {
|
||||
t.Fatalf("plan files = %#v, want unchanged %#v", filesAfter, filesBefore)
|
||||
}
|
||||
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("adapter marker stat = %v, want absent", err)
|
||||
}
|
||||
|
||||
fake.RunRequests = nil
|
||||
actual, err := executeStages(context.Background(), cfg, []stage.Stage{
|
||||
resultStage{name: "render", result: &stage.StageResult{}},
|
||||
resultStage{name: "extract", result: &stage.StageResult{Disposition: stage.StageDispositionSkipped, SkipReason: "notarius_disabled"}},
|
||||
analyze,
|
||||
}, RunOptions{
|
||||
Env: &Env{Scriptorium: fake},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("actual analyze: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(actual.Executed, []string{"render", "extract", "analyze"}) ||
|
||||
!reflect.DeepEqual(actual.Skipped, []string{"extract"}) || len(fake.RunRequests) != 0 {
|
||||
t.Fatalf("actual decision: executed=%#v skipped=%#v adapter_requests=%d, want planned stage decisions with artifact reuse", actual.Executed, actual.Skipped, len(fake.RunRequests))
|
||||
}
|
||||
}
|
||||
|
||||
func planFixtureFiles(t *testing.T, root string) []string {
|
||||
t.Helper()
|
||||
var files []string
|
||||
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, relative+":"+entry.Type().String())
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
@@ -2,13 +2,134 @@ package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
// BoundedPlan is one validated inclusive range of the canonical pipeline.
|
||||
// It owns effective endpoints and membership so command, runner, and
|
||||
// composition callers do not independently interpret range bounds.
|
||||
type BoundedPlan struct {
|
||||
stages []stage.Stage
|
||||
canonicalNames []string
|
||||
startIndex int
|
||||
endIndex int
|
||||
explicitFrom bool
|
||||
explicitThrough bool
|
||||
}
|
||||
|
||||
// BuildBoundedPlan selects an inclusive contiguous range of the canonical
|
||||
// pipeline. Empty endpoints default to the beginning or end respectively.
|
||||
func BuildBoundedPlan(from, through string) (BoundedPlan, error) {
|
||||
registry := stage.All()
|
||||
names := make([]string, len(registry))
|
||||
indices := make(map[string]int, len(registry))
|
||||
for index, candidate := range registry {
|
||||
if candidate == nil {
|
||||
return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage %d is nil", index)
|
||||
}
|
||||
name := candidate.Name()
|
||||
if _, duplicate := indices[name]; duplicate {
|
||||
return BoundedPlan{}, fmt.Errorf("build bounded plan: duplicate canonical stage %q", name)
|
||||
}
|
||||
names[index] = name
|
||||
indices[name] = index
|
||||
}
|
||||
if len(registry) == 0 {
|
||||
return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage registry is empty")
|
||||
}
|
||||
|
||||
start := 0
|
||||
if from != "" {
|
||||
var ok bool
|
||||
start, ok = indices[from]
|
||||
if !ok {
|
||||
return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown from stage %q; valid stages: %s", from, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
end := len(registry) - 1
|
||||
if through != "" {
|
||||
var ok bool
|
||||
end, ok = indices[through]
|
||||
if !ok {
|
||||
return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown through stage %q; valid stages: %s", through, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
if start > end {
|
||||
return BoundedPlan{}, fmt.Errorf("build bounded plan: from stage %q occurs after through stage %q; valid stages: %s", from, through, strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
return BoundedPlan{
|
||||
stages: append([]stage.Stage(nil), registry[start:end+1]...),
|
||||
canonicalNames: append([]string(nil), names...),
|
||||
startIndex: start,
|
||||
endIndex: end,
|
||||
explicitFrom: from != "",
|
||||
explicitThrough: through != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stages returns a copy of the selected canonical stages.
|
||||
func (p BoundedPlan) Stages() []stage.Stage {
|
||||
return append([]stage.Stage(nil), p.stages...)
|
||||
}
|
||||
|
||||
// Names returns selected stage names in canonical order.
|
||||
func (p BoundedPlan) Names() []string {
|
||||
out := make([]string, 0, len(p.stages))
|
||||
for _, candidate := range p.stages {
|
||||
out = append(out, candidate.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// From returns the effective inclusive start stage.
|
||||
func (p BoundedPlan) From() string {
|
||||
if len(p.canonicalNames) == 0 || p.startIndex < 0 || p.startIndex >= len(p.canonicalNames) {
|
||||
return ""
|
||||
}
|
||||
return p.canonicalNames[p.startIndex]
|
||||
}
|
||||
|
||||
// Through returns the effective inclusive end stage.
|
||||
func (p BoundedPlan) Through() string {
|
||||
if len(p.canonicalNames) == 0 || p.endIndex < 0 || p.endIndex >= len(p.canonicalNames) {
|
||||
return ""
|
||||
}
|
||||
return p.canonicalNames[p.endIndex]
|
||||
}
|
||||
|
||||
// Contains reports whether a canonical stage is selected by the range.
|
||||
func (p BoundedPlan) Contains(name string) bool {
|
||||
for _, candidate := range p.stages {
|
||||
if candidate.Name() == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PrefixNames returns canonical stages excluded before the selected start.
|
||||
func (p BoundedPlan) PrefixNames() []string {
|
||||
if p.startIndex <= 0 || p.startIndex > len(p.canonicalNames) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), p.canonicalNames[:p.startIndex]...)
|
||||
}
|
||||
|
||||
// HasExplicitBounds reports whether either endpoint was supplied by the caller.
|
||||
func (p BoundedPlan) HasExplicitBounds() bool {
|
||||
return p.explicitFrom || p.explicitThrough
|
||||
}
|
||||
|
||||
// BuildFullPlan returns the canonical full stage list in deterministic order.
|
||||
func BuildFullPlan() []stage.Stage {
|
||||
return stage.All()
|
||||
plan, err := BuildBoundedPlan("", "")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return plan.Stages()
|
||||
}
|
||||
|
||||
// BuildSingleStagePlan returns a one-stage plan for an exact stage name.
|
||||
@@ -19,3 +140,22 @@ func BuildSingleStagePlan(name string) ([]stage.Stage, error) {
|
||||
}
|
||||
return []stage.Stage{s}, nil
|
||||
}
|
||||
|
||||
// buildSingleStageExecutionPlan selects one canonical stage without applying
|
||||
// bounded-run prefix prerequisites. The run-stage family validates the stage's
|
||||
// concrete inputs and intentionally retains its established direct-execution
|
||||
// semantics.
|
||||
func buildSingleStageExecutionPlan(name string) (BoundedPlan, error) {
|
||||
stages, err := BuildSingleStagePlan(name)
|
||||
if err != nil {
|
||||
return BoundedPlan{}, err
|
||||
}
|
||||
plan, err := BuildBoundedPlan(name, name)
|
||||
if err != nil {
|
||||
return BoundedPlan{}, fmt.Errorf("build stage plan: %w", err)
|
||||
}
|
||||
plan.stages = stages
|
||||
plan.explicitFrom = false
|
||||
plan.explicitThrough = false
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
func TestBuildFullPlanOrder(t *testing.T) {
|
||||
got := BuildFullPlan()
|
||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"}
|
||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
||||
}
|
||||
@@ -34,3 +40,113 @@ func TestBuildSingleStagePlanUnknown(t *testing.T) {
|
||||
t.Fatal("expected error for unknown stage, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBoundedPlanEndpoints(t *testing.T) {
|
||||
canonical := stageNames(BuildFullPlan())
|
||||
for index, name := range canonical {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
one, err := BuildBoundedPlan(name, name)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBoundedPlan(%q, %q) error = %v", name, name, err)
|
||||
}
|
||||
if got := one.Names(); !reflect.DeepEqual(got, []string{name}) {
|
||||
t.Fatalf("one-stage names = %#v, want %q", got, name)
|
||||
}
|
||||
if one.From() != name || one.Through() != name || !one.Contains(name) || !one.HasExplicitBounds() {
|
||||
t.Fatalf("one-stage plan endpoints or membership = %#v", one)
|
||||
}
|
||||
|
||||
from, err := BuildBoundedPlan(name, "")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBoundedPlan(%q, empty) error = %v", name, err)
|
||||
}
|
||||
if got := from.Names(); !reflect.DeepEqual(got, canonical[index:]) {
|
||||
t.Fatalf("from names = %#v, want %#v", got, canonical[index:])
|
||||
}
|
||||
|
||||
through, err := BuildBoundedPlan("", name)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBoundedPlan(empty, %q) error = %v", name, err)
|
||||
}
|
||||
if got := through.Names(); !reflect.DeepEqual(got, canonical[:index+1]) {
|
||||
t.Fatalf("through names = %#v, want %#v", got, canonical[:index+1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBoundedPlanDefaultsToFullCanonicalPlan(t *testing.T) {
|
||||
plan, err := BuildBoundedPlan("", "")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBoundedPlan() error = %v", err)
|
||||
}
|
||||
want := stageNames(BuildFullPlan())
|
||||
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("bounded names = %#v, want %#v", got, want)
|
||||
}
|
||||
if plan.From() != want[0] || plan.Through() != want[len(want)-1] || plan.HasExplicitBounds() {
|
||||
t.Fatalf("default endpoints = %q through %q explicit=%t", plan.From(), plan.Through(), plan.HasExplicitBounds())
|
||||
}
|
||||
if got := plan.PrefixNames(); len(got) != 0 {
|
||||
t.Fatalf("default prefix = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBoundedPlanRejectsInvalidBounds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
from string
|
||||
through string
|
||||
want []string
|
||||
}{
|
||||
{name: "unknown from", from: "missing", through: "analyze", want: []string{"unknown from stage", "missing", "prepare", "notify"}},
|
||||
{name: "unknown through", from: "extract", through: "missing", want: []string{"unknown through stage", "missing", "prepare", "notify"}},
|
||||
{name: "reversed", from: "publish", through: "render", want: []string{"publish", "occurs after", "render", "prepare", "notify"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := BuildBoundedPlan(test.from, test.through)
|
||||
if err == nil {
|
||||
t.Fatal("BuildBoundedPlan() error = nil")
|
||||
}
|
||||
for _, fragment := range test.want {
|
||||
if !strings.Contains(err.Error(), fragment) {
|
||||
t.Fatalf("error = %q, want fragment %q", err, fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedPlanIsContiguousAndCannotMutateRegistry(t *testing.T) {
|
||||
before := stageNames(BuildFullPlan())
|
||||
plan, err := BuildBoundedPlan("trim", "analyze")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBoundedPlan() error = %v", err)
|
||||
}
|
||||
want := []string{"trim", "render", "extract", "analyze"}
|
||||
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("names = %#v, want contiguous %#v", got, want)
|
||||
}
|
||||
if got := plan.PrefixNames(); !reflect.DeepEqual(got, before[:5]) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, before[:5])
|
||||
}
|
||||
stages := plan.Stages()
|
||||
stages[0] = nil
|
||||
names := plan.Names()
|
||||
names[0] = "changed"
|
||||
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("mutated plan names = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := stageNames(BuildFullPlan()); !reflect.DeepEqual(got, before) {
|
||||
t.Fatalf("canonical registry changed = %#v, want %#v", got, before)
|
||||
}
|
||||
}
|
||||
|
||||
func stageNames(stages []stage.Stage) []string {
|
||||
names := make([]string, 0, len(stages))
|
||||
for _, candidate := range stages {
|
||||
names = append(names, candidate.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -146,7 +146,12 @@ func TestPostPublishCleanupRetriesWhenInitialObligationSaveFails(t *testing.T) {
|
||||
|
||||
store.fail = nil
|
||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("retry executeStages() error = %v", err)
|
||||
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||
}
|
||||
assertExists(t, seed.spoolAudioDir)
|
||||
assertCleanupPending(t, cfg)
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||
}
|
||||
assertMissing(t, seed.spoolAudioDir)
|
||||
assertCleanupComplete(t, cfg)
|
||||
@@ -178,7 +183,12 @@ func TestPostPublishCleanupRetriesFailedDeletionWithoutTouchingOtherRuns(t *test
|
||||
|
||||
removeRunScopedDirFn = originalRemove
|
||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("retry executeStages() error = %v", err)
|
||||
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||
}
|
||||
assertExists(t, seed.runWorkDir)
|
||||
assertCleanupPending(t, cfg)
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||
}
|
||||
assertMissing(t, seed.runWorkDir)
|
||||
assertExists(t, seed.otherRunDir)
|
||||
@@ -218,12 +228,16 @@ func TestPostPublishCleanupRetriesWhenCompletionEvidenceSaveFails(t *testing.T)
|
||||
|
||||
store.fail = nil
|
||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("retry executeStages() error = %v", err)
|
||||
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||
}
|
||||
assertCleanupPending(t, cfg)
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||
}
|
||||
assertCleanupComplete(t, cfg)
|
||||
|
||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("idempotent retry executeStages() error = %v", err)
|
||||
t.Fatalf("idempotent non-publish executeStages() error = %v", err)
|
||||
}
|
||||
assertMissing(t, seed.spoolAudioDir)
|
||||
}
|
||||
@@ -328,7 +342,10 @@ func TestPostPublishCleanupFailsOnUnsafePath(t *testing.T) {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||
if _, err = executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||
}
|
||||
_, err = executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") {
|
||||
t.Fatalf("executeStages() error = %v, want safe-path failure", err)
|
||||
}
|
||||
@@ -525,6 +542,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
|
||||
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
}
|
||||
setAppAnalyzeEvidence(seedManifest, "session_recap", "artifacts/session_recap.md", []byte("# recap\n"))
|
||||
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
||||
|
||||
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/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
|
||||
@@ -37,7 +36,7 @@ inputs:
|
||||
if storeInitCalls != 1 {
|
||||
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "narratio session plan: workdir prepared") {
|
||||
if !strings.Contains(stdout.String(), "narratio session plan: read-only workdir") {
|
||||
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
||||
}
|
||||
if _, ok := fake.Objects[remoteKey]; !ok {
|
||||
@@ -85,7 +84,7 @@ inputs:
|
||||
`,
|
||||
command: []string{"run", "2026-05-03"},
|
||||
configureRun: func() {
|
||||
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
|
||||
executeStagesFn = func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
|
||||
return nil, errors.New("adapter failed")
|
||||
}
|
||||
},
|
||||
@@ -99,7 +98,7 @@ inputs:
|
||||
`,
|
||||
command: []string{"run", "2026-05-03"},
|
||||
configureRun: func() {
|
||||
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
|
||||
executeStagesFn = func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
|
||||
return nil, context.Canceled
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
||||
@@ -34,12 +33,12 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
executeStagesFn = origExecuteStagesFn
|
||||
})
|
||||
executeStagesFn = func(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||
executeStagesFn = func(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||
if opts.Env == nil {
|
||||
opts.Env = &Env{}
|
||||
}
|
||||
opts.Env.Scriptorium = &scriptorium.NoopRunner{}
|
||||
return executeStages(ctx, cfg, stages, opts)
|
||||
return executePlan(ctx, cfg, plan, opts)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
@@ -224,12 +223,12 @@ previous_session_id: 2026-04-26
|
||||
executeStagesFn = origExecuteStagesFn
|
||||
newObjectStoreFromConfigFn = origObjectStoreFn
|
||||
})
|
||||
executeStagesFn = func(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||
executeStagesFn = func(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||
if opts.Env == nil {
|
||||
opts.Env = &Env{}
|
||||
}
|
||||
opts.Env.Scriptorium = scriptoriumFake
|
||||
return executeStages(ctx, cfg, stages, opts)
|
||||
return executePlan(ctx, cfg, plan, opts)
|
||||
}
|
||||
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
||||
objectStoreConstructed = true
|
||||
@@ -282,6 +281,7 @@ func restoreWorkflowManifestJSON(t *testing.T, sessionID, campaign string) []byt
|
||||
for i, stageName := range stages {
|
||||
m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil)
|
||||
}
|
||||
setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", []byte("# restored recap\n"))
|
||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||
if err := store.Save(context.Background(), path, m); err != nil {
|
||||
t.Fatalf("save workflow manifest fixture: %v", err)
|
||||
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -11,22 +12,14 @@ import (
|
||||
|
||||
// Run executes the pipeline plan and persists manifest state.
|
||||
func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
|
||||
var flags commonConfigFlags
|
||||
var force bool
|
||||
var selectedArtifacts artifactSelectionFlag
|
||||
addCommonConfigFlags(fs, &flags)
|
||||
fs.BoolVar(&force, "force", false, "rerun stages even when already succeeded")
|
||||
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
|
||||
|
||||
if err := parseSessionAwareFlags("run", fs, args, &flags.sessionID); err != nil {
|
||||
request, err := parseBoundedRunRequest("run", args, out)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if flags.sessionID == "" {
|
||||
return fmt.Errorf("run: session_id is required")
|
||||
}
|
||||
flags := request.Config
|
||||
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||
if err != nil {
|
||||
return fmt.Errorf("run: %w", err)
|
||||
@@ -36,18 +29,13 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||
if err := config.Validate(cfg); err != nil {
|
||||
return fmt.Errorf("run: %w", err)
|
||||
}
|
||||
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
||||
if err != nil {
|
||||
return fmt.Errorf("run: invalid --artifacts: %w", err)
|
||||
}
|
||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, normalizedArtifacts)
|
||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run: %w", err)
|
||||
}
|
||||
stages := BuildFullPlan()
|
||||
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
|
||||
Force: force,
|
||||
SelectedArtifacts: normalizedArtifacts,
|
||||
summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{
|
||||
Force: request.Force,
|
||||
SelectedArtifacts: request.SelectedArtifacts,
|
||||
EffectiveArtifacts: effectiveArtifacts,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -19,11 +19,6 @@ const (
|
||||
stageActionSkip stageAction = "skip"
|
||||
)
|
||||
|
||||
type stageDecision struct {
|
||||
Stage stage.Stage
|
||||
Action stageAction
|
||||
}
|
||||
|
||||
const (
|
||||
staleReasonForcedReplacement = "upstream stage was force-run"
|
||||
staleReasonChangedResult = "upstream stage result changed"
|
||||
@@ -39,19 +34,9 @@ type priorStageOutcome struct {
|
||||
outputs int
|
||||
}
|
||||
|
||||
func decideStageActions(stages []stage.Stage, m *manifest.Manifest, force bool) []stageDecision {
|
||||
out := make([]stageDecision, 0, len(stages))
|
||||
for _, s := range stages {
|
||||
out = append(out, stageDecision{
|
||||
Stage: s,
|
||||
Action: decideStageAction(s, m, force),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decideStageAction(s stage.Stage, m *manifest.Manifest, force bool) stageAction {
|
||||
// TODO: incorporate stale detection once checksum/input change tracking is implemented.
|
||||
// A succeeded aggregate record is the initial skip candidate. The runner and
|
||||
// planner then let stage-owned resume validation refine that decision.
|
||||
if !force && stageSucceeded(m, s.Name()) {
|
||||
return stageActionSkip
|
||||
}
|
||||
@@ -122,30 +107,153 @@ func canonicalStageNames() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func downstreamStageNames(stageName string) []string {
|
||||
names := canonicalStageNames()
|
||||
for i, name := range names {
|
||||
if name != stageName {
|
||||
continue
|
||||
}
|
||||
return append([]string(nil), names[i+1:]...)
|
||||
}
|
||||
return nil
|
||||
type invalidationRelation struct {
|
||||
canonical []string
|
||||
direct map[string][]string
|
||||
}
|
||||
|
||||
func invalidateDownstreamSucceededStagesWithReason(m *manifest.Manifest, upstreamStage string, at time.Time, reason string) []string {
|
||||
if m == nil || m.Stages == nil {
|
||||
var canonicalInvalidationEdges = map[string][]string{
|
||||
"prepare": {"transcribe"},
|
||||
"transcribe": {"merge"},
|
||||
"merge": {"polish"},
|
||||
"polish": {"normalize"},
|
||||
"normalize": {"trim"},
|
||||
"trim": {"render", "extract"},
|
||||
"render": {"analyze"},
|
||||
"extract": {"analyze"},
|
||||
"analyze": {"publish"},
|
||||
"publish": {"notify"},
|
||||
"notify": {},
|
||||
}
|
||||
|
||||
func newInvalidationRelation(registry []stage.Stage, direct map[string][]string) (*invalidationRelation, error) {
|
||||
canonical := make([]string, 0, len(registry))
|
||||
known := make(map[string]struct{}, len(registry))
|
||||
for index, candidate := range registry {
|
||||
if candidate == nil {
|
||||
return nil, fmt.Errorf("canonical stage registry entry %d is nil", index)
|
||||
}
|
||||
name := strings.TrimSpace(candidate.Name())
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("canonical stage registry entry %d has an empty name", index)
|
||||
}
|
||||
if _, duplicate := known[name]; duplicate {
|
||||
return nil, fmt.Errorf("canonical stage registry contains duplicate stage %q", name)
|
||||
}
|
||||
known[name] = struct{}{}
|
||||
canonical = append(canonical, name)
|
||||
}
|
||||
|
||||
cloned := make(map[string][]string, len(direct))
|
||||
for source, targets := range direct {
|
||||
if _, ok := known[source]; !ok {
|
||||
return nil, fmt.Errorf("invalidation relation classifies unknown stage %q", source)
|
||||
}
|
||||
cloned[source] = []string{}
|
||||
seenTargets := make(map[string]struct{}, len(targets))
|
||||
for _, target := range targets {
|
||||
if _, ok := known[target]; !ok {
|
||||
return nil, fmt.Errorf("invalidation relation edge %q -> %q references an unknown stage", source, target)
|
||||
}
|
||||
if _, duplicate := seenTargets[target]; duplicate {
|
||||
return nil, fmt.Errorf("invalidation relation contains duplicate edge %q -> %q", source, target)
|
||||
}
|
||||
seenTargets[target] = struct{}{}
|
||||
cloned[source] = append(cloned[source], target)
|
||||
}
|
||||
}
|
||||
for _, name := range canonical {
|
||||
if _, classified := direct[name]; !classified {
|
||||
return nil, fmt.Errorf("invalidation relation is missing classification for stage %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
relation := &invalidationRelation{canonical: canonical, direct: cloned}
|
||||
visiting := make(map[string]bool, len(canonical))
|
||||
visited := make(map[string]bool, len(canonical))
|
||||
var visit func(string) error
|
||||
visit = func(name string) error {
|
||||
if visiting[name] {
|
||||
return fmt.Errorf("invalidation relation contains a cycle involving stage %q", name)
|
||||
}
|
||||
if visited[name] {
|
||||
return nil
|
||||
}
|
||||
visiting[name] = true
|
||||
for _, target := range relation.direct[name] {
|
||||
if err := visit(target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
visiting[name] = false
|
||||
visited[name] = true
|
||||
return nil
|
||||
}
|
||||
for _, name := range canonical {
|
||||
if err := visit(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return relation, nil
|
||||
}
|
||||
|
||||
func canonicalInvalidationRelation() (*invalidationRelation, error) {
|
||||
return newInvalidationRelation(stage.All(), canonicalInvalidationEdges)
|
||||
}
|
||||
|
||||
func (r *invalidationRelation) Dependents(stageName string) ([]string, error) {
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("invalidation relation is nil")
|
||||
}
|
||||
if _, ok := r.direct[stageName]; !ok {
|
||||
return nil, fmt.Errorf("unknown stage %q in invalidation relation", stageName)
|
||||
}
|
||||
reachable := make(map[string]bool, len(r.canonical))
|
||||
var collect func(string)
|
||||
collect = func(name string) {
|
||||
for _, target := range r.direct[name] {
|
||||
if reachable[target] {
|
||||
continue
|
||||
}
|
||||
reachable[target] = true
|
||||
collect(target)
|
||||
}
|
||||
}
|
||||
collect(stageName)
|
||||
out := make([]string, 0, len(reachable))
|
||||
for _, name := range r.canonical {
|
||||
if reachable[name] {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dependentStageNames(stageName string) ([]string, error) {
|
||||
relation, err := canonicalInvalidationRelation()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return relation.Dependents(stageName)
|
||||
}
|
||||
|
||||
func invalidateDependentSucceededStagesWithReason(m *manifest.Manifest, upstreamStage string, at time.Time, reason string) ([]string, error) {
|
||||
dependents, err := dependentStageNames(upstreamStage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m == nil || m.Stages == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
invalidated := make([]string, 0)
|
||||
for _, downstream := range downstreamStageNames(upstreamStage) {
|
||||
sr := m.Stages[downstream]
|
||||
for _, dependent := range dependents {
|
||||
sr := m.Stages[dependent]
|
||||
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
||||
continue
|
||||
}
|
||||
m.MarkStageStale(downstream, at, reason)
|
||||
invalidated = append(invalidated, downstream)
|
||||
m.MarkStageStale(dependent, at, reason)
|
||||
invalidated = append(invalidated, dependent)
|
||||
}
|
||||
return invalidated
|
||||
return invalidated, nil
|
||||
}
|
||||
|
||||
@@ -1,69 +1,109 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
func TestDecideStageActions(t *testing.T) {
|
||||
func TestDecideStageAction(t *testing.T) {
|
||||
stages := BuildFullPlan()[:2]
|
||||
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
||||
|
||||
got := decideStageActions(stages, m, false)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(decisions) = %d, want 2", len(got))
|
||||
if got := decideStageAction(stages[0], m, false); got != stageActionSkip {
|
||||
t.Fatalf("prepare action = %q, want %q", got, stageActionSkip)
|
||||
}
|
||||
if got[0].Action != stageActionSkip {
|
||||
t.Fatalf("prepare action = %q, want %q", got[0].Action, stageActionSkip)
|
||||
}
|
||||
if got[1].Action != stageActionRun {
|
||||
t.Fatalf("transcribe action = %q, want %q", got[1].Action, stageActionRun)
|
||||
if got := decideStageAction(stages[1], m, false); got != stageActionRun {
|
||||
t.Fatalf("transcribe action = %q, want %q", got, stageActionRun)
|
||||
}
|
||||
|
||||
forced := decideStageActions(stages, m, true)
|
||||
if forced[0].Action != stageActionRun {
|
||||
t.Fatalf("forced prepare action = %q, want %q", forced[0].Action, stageActionRun)
|
||||
if got := decideStageAction(stages[0], m, true); got != stageActionRun {
|
||||
t.Fatalf("forced prepare action = %q, want %q", got, stageActionRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownstreamStageNames(t *testing.T) {
|
||||
got := downstreamStageNames("polish")
|
||||
want := []string{"normalize", "trim", "extract", "render", "analyze", "publish", "notify"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
||||
func TestInvalidationDependents(t *testing.T) {
|
||||
tests := []struct {
|
||||
stage string
|
||||
want []string
|
||||
}{
|
||||
{"prepare", []string{"transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||
{"transcribe", []string{"merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||
{"merge", []string{"polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||
{"polish", []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||
{"normalize", []string{"trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||
{"trim", []string{"render", "extract", "analyze", "publish", "notify"}},
|
||||
{"render", []string{"analyze", "publish", "notify"}},
|
||||
{"extract", []string{"analyze", "publish", "notify"}},
|
||||
{"analyze", []string{"publish", "notify"}},
|
||||
{"publish", []string{"notify"}},
|
||||
{"notify", []string{}},
|
||||
}
|
||||
|
||||
missing := downstreamStageNames("unknown")
|
||||
if len(missing) != 0 {
|
||||
t.Fatalf("downstreamStageNames(unknown) = %#v, want empty", missing)
|
||||
for _, test := range tests {
|
||||
t.Run(test.stage, func(t *testing.T) {
|
||||
got, err := dependentStageNames(test.stage)
|
||||
if err != nil {
|
||||
t.Fatalf("dependentStageNames(%q) error = %v", test.stage, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("dependentStageNames(%q) = %#v, want %#v", test.stage, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := dependentStageNames("unknown"); err == nil || !strings.Contains(err.Error(), "unknown stage") {
|
||||
t.Fatalf("dependentStageNames(unknown) error = %v, want unknown-stage error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateDownstreamSucceededStagesWithReason(t *testing.T) {
|
||||
func TestInvalidationRelationRejectsInvalidInventory(t *testing.T) {
|
||||
canonical := []stage.Stage{
|
||||
invalidationTestStage("one"),
|
||||
invalidationTestStage("two"),
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
registry []stage.Stage
|
||||
edges map[string][]string
|
||||
want string
|
||||
}{
|
||||
{name: "duplicate registry name", registry: append(canonical, invalidationTestStage("one")), edges: map[string][]string{"one": {"two"}, "two": {}}, want: "duplicate stage"},
|
||||
{name: "unknown source", registry: canonical, edges: map[string][]string{"one": {"two"}, "two": {}, "three": {}}, want: "unknown stage"},
|
||||
{name: "unknown target", registry: canonical, edges: map[string][]string{"one": {"three"}, "two": {}}, want: "unknown stage"},
|
||||
{name: "missing classification", registry: canonical, edges: map[string][]string{"one": {"two"}}, want: "missing classification"},
|
||||
{name: "cycle", registry: canonical, edges: map[string][]string{"one": {"two"}, "two": {"one"}}, want: "cycle"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := newInvalidationRelation(test.registry, test.edges)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("newInvalidationRelation() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidateDependentSucceededStagesWithReason(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
m := manifest.New("2026-05-03", now)
|
||||
m.MarkStageSucceeded("prepare", now, nil)
|
||||
m.MarkStageSucceeded("transcribe", now, nil)
|
||||
m.MarkStageSucceeded("merge", now, nil)
|
||||
m.MarkStageSucceeded("polish", now, nil)
|
||||
m.MarkStageSucceeded("normalize", now, nil)
|
||||
m.MarkStageSucceeded("trim", now, nil)
|
||||
m.MarkStageSucceeded("extract", now, nil)
|
||||
m.MarkStageSucceeded("render", now, nil)
|
||||
m.MarkStageFailed("analyze", now, "analysis failed")
|
||||
m.MarkStageSucceeded("publish", now, nil)
|
||||
m.MarkStageSucceeded("notify", now, nil)
|
||||
|
||||
got := invalidateDownstreamSucceededStagesWithReason(m, "polish", now.Add(1*time.Second), staleReasonChangedResult)
|
||||
want := []string{"normalize", "trim", "extract", "render", "publish", "notify"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("invalidateDownstreamSucceededStagesWithReason() = %#v, want %#v", got, want)
|
||||
for _, name := range canonicalStageNames() {
|
||||
m.MarkStageSucceeded(name, now, nil)
|
||||
}
|
||||
m.MarkStageFailed("analyze", now, "analysis failed")
|
||||
|
||||
got, err := invalidateDependentSucceededStagesWithReason(m, "polish", now.Add(time.Second), staleReasonChangedResult)
|
||||
if err != nil {
|
||||
t.Fatalf("invalidateDependentSucceededStagesWithReason() error = %v", err)
|
||||
}
|
||||
want := []string{"normalize", "trim", "render", "extract", "publish", "notify"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("invalidateDependentSucceededStagesWithReason() = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, stageName := range want {
|
||||
if m.Stages[stageName].Status != manifest.StatusStale {
|
||||
t.Fatalf("%s status = %q, want stale", stageName, m.Stages[stageName].Status)
|
||||
@@ -72,34 +112,38 @@ func TestInvalidateDownstreamSucceededStagesWithReason(t *testing.T) {
|
||||
if m.Stages["analyze"].Status != manifest.StatusFailed {
|
||||
t.Fatalf("analyze status = %q, want failed", m.Stages["analyze"].Status)
|
||||
}
|
||||
if m.Stages["prepare"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("prepare status = %q, want succeeded", m.Stages["prepare"].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionPositionControlsForceInvalidation(t *testing.T) {
|
||||
func TestRenderAndExtractInvalidationAreIndependent(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
tests := []struct {
|
||||
upstream string
|
||||
want []string
|
||||
}{
|
||||
{upstream: "trim", want: []string{"extract", "render", "analyze", "publish", "notify"}},
|
||||
{upstream: "extract", want: []string{"render", "analyze", "publish", "notify"}},
|
||||
{upstream: "render", want: []string{"analyze", "publish", "notify"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.upstream, func(t *testing.T) {
|
||||
for _, upstream := range []string{"render", "extract"} {
|
||||
t.Run(upstream, func(t *testing.T) {
|
||||
m := manifest.New("2026-05-03", now)
|
||||
for _, name := range canonicalStageNames() {
|
||||
m.MarkStageSucceeded(name, now, nil)
|
||||
}
|
||||
got := invalidateDownstreamSucceededStagesWithReason(m, test.upstream, now.Add(time.Second), staleReasonForcedReplacement)
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("invalidated = %#v, want %#v", got, test.want)
|
||||
got, err := invalidateDependentSucceededStagesWithReason(m, upstream, now.Add(time.Second), staleReasonForcedReplacement)
|
||||
if err != nil {
|
||||
t.Fatalf("invalidate dependents: %v", err)
|
||||
}
|
||||
if test.upstream == "render" && m.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("forcing render changed extract: %#v", m.Stages["extract"])
|
||||
want := []string{"analyze", "publish", "notify"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("invalidated = %#v, want %#v", got, want)
|
||||
}
|
||||
sibling := "render"
|
||||
if upstream == "render" {
|
||||
sibling = "extract"
|
||||
}
|
||||
if m.Stages[sibling].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("%s invalidated sibling %s: %#v", upstream, sibling, m.Stages[sibling])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type invalidationTestStage string
|
||||
|
||||
func (s invalidationTestStage) Name() string { return string(s) }
|
||||
func (s invalidationTestStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||
return &stage.StageResult{}, nil
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ type singleStageCommand struct {
|
||||
}
|
||||
|
||||
func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSummary, error) {
|
||||
stages, err := BuildSingleStagePlan(req.StageName)
|
||||
plan, err := buildSingleStageExecutionPlan(req.StageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||
}
|
||||
@@ -220,7 +220,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||
}
|
||||
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
|
||||
summary, err := executeStagesFn(ctx, cfg, plan, RunOptions{
|
||||
Force: req.Force,
|
||||
SelectedArtifacts: req.SelectedArtifacts,
|
||||
EffectiveArtifacts: effectiveArtifacts,
|
||||
|
||||
@@ -225,7 +225,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("load manifest after force: %v", err)
|
||||
}
|
||||
for _, name := range []string{"normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
|
||||
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
||||
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
||||
}
|
||||
|
||||
@@ -40,9 +40,17 @@ type RunSummary struct {
|
||||
Skipped []string
|
||||
}
|
||||
|
||||
var executeStagesFn = executeStages
|
||||
var executeStagesFn = executePlan
|
||||
|
||||
func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (summary *RunSummary, resultErr error) {
|
||||
func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (summary *RunSummary, resultErr error) {
|
||||
stages := plan.Stages()
|
||||
var prerequisiteStore manifest.Store
|
||||
if opts.Env != nil {
|
||||
prerequisiteStore = opts.Env.ManifestStore
|
||||
}
|
||||
if err := inspectBoundedPrerequisites(ctx, cfg, plan, prerequisiteStore); err != nil {
|
||||
return nil, fmt.Errorf("validate bounded run prerequisites: %w", err)
|
||||
}
|
||||
effectiveArtifacts := opts.EffectiveArtifacts
|
||||
if !effectiveArtifacts.Resolved() && cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
|
||||
var err error
|
||||
@@ -131,6 +139,13 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
return nil, fmt.Errorf("create manifest: %w", err)
|
||||
}
|
||||
}
|
||||
// The preflight prerequisite inspection avoids creating run state for an
|
||||
// already-invalid request. Recheck the manifest protected by the session
|
||||
// lock because another invocation may have changed prerequisite state while
|
||||
// this invocation waited to acquire the lock.
|
||||
if err := validateBoundedPrerequisites(plan, m); err != nil {
|
||||
return nil, fmt.Errorf("validate bounded run prerequisites under session lock: %w", err)
|
||||
}
|
||||
identity.applyToSessionManifest(m)
|
||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
||||
@@ -169,7 +184,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
fmt.Errorf("load secrets from files: %w", err),
|
||||
)
|
||||
}
|
||||
if env.WhisperX == nil {
|
||||
if env.WhisperX == nil && stagesContainAny(stages, "transcribe") {
|
||||
client, err := buildDefaultWhisperXClient(env.Config)
|
||||
if err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
@@ -179,7 +194,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
}
|
||||
env.WhisperX = client
|
||||
}
|
||||
if env.Seriatim == nil {
|
||||
if env.Seriatim == nil && stagesContainAny(stages, "merge", "normalize", "trim", "render") {
|
||||
runner, err := buildDefaultSeriatimRunner(env.Config)
|
||||
if err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
@@ -189,7 +204,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
}
|
||||
env.Seriatim = runner
|
||||
}
|
||||
if env.Audita == nil {
|
||||
if env.Audita == nil && stagesContainAny(stages, "polish") {
|
||||
runner, err := buildDefaultAuditaRunner(env.Config)
|
||||
if err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
@@ -202,7 +217,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
if env.Notarius == nil && needsNotariusForRun(env.Config, stages) {
|
||||
env.Notarius = notarius.NewSubprocessRunner()
|
||||
}
|
||||
if env.Scriptorium == nil {
|
||||
if env.Scriptorium == nil && stagesContainAny(stages, "trim", "analyze") {
|
||||
env.Scriptorium = scriptorium.NewSubprocessRunner()
|
||||
}
|
||||
if env.ObjectStore == nil && needsObjectStoreForRun(env.Config, stages, effectiveArtifacts) {
|
||||
@@ -232,23 +247,21 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
return config.MergePublishLockRules(staticLocks, remote.Locks), nil
|
||||
}
|
||||
}
|
||||
if env.Notifier == nil {
|
||||
if env.Notifier == nil && stagesContainAny(stages, "notify") {
|
||||
env.Notifier = ¬ify.NoopSender{}
|
||||
}
|
||||
|
||||
stageEnv := env
|
||||
|
||||
decisions := decideStageActions(stages, m, opts.Force)
|
||||
|
||||
runNames := make([]string, 0, len(decisions))
|
||||
executed := make([]string, 0, len(decisions))
|
||||
skipped := make([]string, 0, len(decisions))
|
||||
for _, d := range decisions {
|
||||
s := d.Stage
|
||||
runNames := make([]string, 0, len(stages))
|
||||
executed := make([]string, 0, len(stages))
|
||||
skipped := make([]string, 0, len(stages))
|
||||
for _, s := range stages {
|
||||
stageEnv.Force = opts.Force
|
||||
runNames = append(runNames, s.Name())
|
||||
d.Action = decideStageAction(s, m, opts.Force)
|
||||
action := decideStageAction(s, m, opts.Force)
|
||||
|
||||
if d.Action == stageActionSkip {
|
||||
if action == stageActionSkip {
|
||||
if validator, ok := s.(stage.ResumeValidator); ok {
|
||||
validation, err := validator.ValidateResume(ctx, stageEnv, m)
|
||||
if err != nil {
|
||||
@@ -261,9 +274,14 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
if !validation.Resumable {
|
||||
staleAt := nowUTC()
|
||||
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
|
||||
invalidateDownstreamSucceededStagesWithReason(
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||
m, s.Name(), staleAt, staleReasonNotResumable,
|
||||
); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||
fmt.Errorf("invalidate dependents after resume validation for stage %q: %w", s.Name(), err),
|
||||
)
|
||||
}
|
||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||
@@ -271,12 +289,12 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
)
|
||||
}
|
||||
env.Logger.Info("stage result is not resumable", "stage", s.Name(), "reason", validation.Reason)
|
||||
d.Action = stageActionRun
|
||||
action = stageActionRun
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if d.Action == stageActionSkip {
|
||||
if action == stageActionSkip {
|
||||
skipped = append(skipped, s.Name())
|
||||
skipAt := nowUTC()
|
||||
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
|
||||
@@ -292,6 +310,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
}
|
||||
executed = append(executed, s.Name())
|
||||
priorOutcome := capturePriorStageOutcome(m, s.Name())
|
||||
priorAnalyzeState := captureAnalyzeState(m, s.Name())
|
||||
|
||||
now := nowUTC()
|
||||
runManifest.SetStageAction(s.Name(), manifest.RunStageActionRun, now)
|
||||
@@ -305,13 +324,20 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
}
|
||||
m.MarkStageRunning(s.Name(), now)
|
||||
if opts.Force {
|
||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), now, staleReasonForcedReplacement)
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), now, staleReasonForcedReplacement); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||
fmt.Errorf("invalidate dependents before forced stage %q: %w", s.Name(), err),
|
||||
)
|
||||
}
|
||||
}
|
||||
env.Logger.Info("starting stage", "stage", s.Name())
|
||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||
operationErr := fmt.Errorf("save manifest before stage %q: %w", s.Name(), err)
|
||||
m.MarkStageFailed(s.Name(), nowUTC(), operationErr.Error())
|
||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), nowUTC(), staleReasonFailure)
|
||||
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), nowUTC(), staleReasonFailure); invalidationErr != nil {
|
||||
operationErr = errors.Join(operationErr, fmt.Errorf("invalidate dependents after stage %q persistence failure: %w", s.Name(), invalidationErr))
|
||||
}
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest, operationErr,
|
||||
)
|
||||
@@ -319,13 +345,28 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "running", "path", manifestPath)
|
||||
|
||||
result, err := s.Run(ctx, stageEnv, m)
|
||||
var analyzeProjection *validatedAnalyzeProjection
|
||||
if err == nil {
|
||||
err = validateStageResult(result)
|
||||
if err == nil {
|
||||
analyzeProjection, err = validateSuccessfulAnalyzeProjection(s.Name(), result)
|
||||
}
|
||||
} else if result != nil && result.AnalyzeState != nil {
|
||||
var projectionErr error
|
||||
analyzeProjection, projectionErr = validateFailedAnalyzeProjection(s.Name(), result)
|
||||
if projectionErr != nil {
|
||||
err = errors.Join(err, projectionErr)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if analyzeProjection != nil {
|
||||
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
||||
}
|
||||
failedAt := nowUTC()
|
||||
m.MarkStageFailed(s.Name(), failedAt, err.Error())
|
||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure)
|
||||
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure); invalidationErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("invalidate dependents after stage %q failure: %w", s.Name(), invalidationErr))
|
||||
}
|
||||
runManifest.MarkStageFailed(s.Name(), failedAt, err.Error())
|
||||
identity.applyToRunManifest(runManifest, manifestPath)
|
||||
env.Logger.Info("stage failed", "stage", s.Name(), "error", err)
|
||||
@@ -340,7 +381,12 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||
applyStageResultToManifest(m, s.Name(), result)
|
||||
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
|
||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip)
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||
fmt.Errorf("invalidate dependents after stage %q self-skip: %w", s.Name(), err),
|
||||
)
|
||||
}
|
||||
}
|
||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
@@ -362,21 +408,42 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
continue
|
||||
}
|
||||
|
||||
outputs := mapResultOutputs(s.Name(), result, runID)
|
||||
sessionOutputs := mapResultOutputs(s.Name(), result, runID)
|
||||
runOutputs := sessionOutputs
|
||||
if analyzeProjection != nil {
|
||||
sessionOutputs = analyzeProjectionOutputs(analyzeProjection.session, "")
|
||||
runOutputs = analyzeProjectionOutputs(analyzeProjection.invocation, runID)
|
||||
}
|
||||
succeededAt := nowUTC()
|
||||
m.MarkStageSucceeded(s.Name(), succeededAt, outputs)
|
||||
m.MarkStageSucceeded(s.Name(), succeededAt, sessionOutputs)
|
||||
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
||||
applyStageResultToManifest(m, s.Name(), result)
|
||||
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
|
||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult)
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||
fmt.Errorf("invalidate dependents after changed stage %q result: %w", s.Name(), err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||
operationErr := fmt.Errorf("save manifest after stage %q: %w", s.Name(), err)
|
||||
if analyzeProjection != nil {
|
||||
restoreAnalyzeState(m, priorAnalyzeState)
|
||||
failedAt := nowUTC()
|
||||
m.MarkStageFailed(s.Name(), failedAt, operationErr.Error())
|
||||
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure); invalidationErr != nil {
|
||||
operationErr = errors.Join(operationErr, fmt.Errorf("invalidate dependents after analyze projection persistence failure: %w", invalidationErr))
|
||||
}
|
||||
runManifest.MarkStageFailed(s.Name(), failedAt, operationErr.Error())
|
||||
}
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||
fmt.Errorf("save manifest after stage %q: %w", s.Name(), err),
|
||||
operationErr,
|
||||
)
|
||||
}
|
||||
runManifest.MarkStageSucceeded(s.Name(), succeededAt, outputs)
|
||||
runManifest.MarkStageSucceeded(s.Name(), succeededAt, runOutputs)
|
||||
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
||||
identity.applyToRunManifest(runManifest, manifestPath)
|
||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||
@@ -401,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
|
||||
// remove. Persist its completed publishing result before cleanup starts so a
|
||||
// successful deletion cannot be undone by a later diagnostic write.
|
||||
if containsStage(executed, "publish") {
|
||||
if err := runPostPublishCleanup(ctx, env, manifestPath, m, executed); err != nil {
|
||||
return nil, persistPostPublishCleanupFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m,
|
||||
fmt.Errorf("post-publish cleanup incomplete: %w", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return &RunSummary{
|
||||
SessionID: cfg.Session.SessionID,
|
||||
@@ -419,6 +488,22 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
}, nil
|
||||
}
|
||||
|
||||
func stagesContainAny(stages []stage.Stage, names ...string) bool {
|
||||
wanted := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
wanted[name] = struct{}{}
|
||||
}
|
||||
for _, candidate := range stages {
|
||||
if candidate == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := wanted[candidate.Name()]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func persistPostPublishCleanupFailure(
|
||||
ctx context.Context,
|
||||
sessionStore manifest.Store,
|
||||
|
||||
@@ -564,12 +564,12 @@ func TestExecuteStagesUsesOptionalResumeValidation(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
seed.MarkStageSucceeded("checked", time.Now().UTC(), nil)
|
||||
seed.MarkStageSucceeded("extract", time.Now().UTC(), nil)
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
runs := 0
|
||||
candidate := resumeCheckingStage{name: "checked", validation: test.validation, runs: &runs}
|
||||
candidate := resumeCheckingStage{name: "extract", validation: test.validation, runs: &runs}
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
@@ -581,7 +581,7 @@ func TestExecuteStagesUsesOptionalResumeValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesNonResumableResultRerunsSucceededDownstream(t *testing.T) {
|
||||
func TestExecuteStagesNonResumableResultPreservesSucceededSibling(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
@@ -599,7 +599,7 @@ func TestExecuteStagesNonResumableResultRerunsSucceededDownstream(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if extractRuns != 1 || renderRuns != 1 || len(summary.Executed) != 2 || len(summary.Skipped) != 0 {
|
||||
if extractRuns != 1 || renderRuns != 0 || len(summary.Executed) != 1 || len(summary.Skipped) != 1 {
|
||||
t.Fatalf("extract runs=%d render runs=%d summary=%#v", extractRuns, renderRuns, summary)
|
||||
}
|
||||
}
|
||||
@@ -1019,14 +1019,14 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
||||
store := &manifest.LocalStore{}
|
||||
manifestPath := manifestPathFor(cfg)
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||
seed.MarkStageSucceeded("optional", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{
|
||||
seed.MarkStageSucceeded("extract", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{
|
||||
Kind: "old_output",
|
||||
SourceID: "narratio.example.old",
|
||||
LocalPath: "artifacts/old.json",
|
||||
}})
|
||||
seed.Stages["optional"].Logs = []string{"old.log"}
|
||||
seed.Stages["optional"].GeneratedConfigs = []string{"old.yml"}
|
||||
seed.Stages["optional"].Metadata = map[string]any{"old": true}
|
||||
seed.Stages["extract"].Logs = []string{"old.log"}
|
||||
seed.Stages["extract"].GeneratedConfigs = []string{"old.yml"}
|
||||
seed.Stages["extract"].Metadata = map[string]any{"old": true}
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
@@ -1038,7 +1038,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
||||
optionalRuns := 0
|
||||
stages := []stage.Stage{
|
||||
resultStage{
|
||||
name: "optional",
|
||||
name: "extract",
|
||||
runs: &optionalRuns,
|
||||
order: &order,
|
||||
result: &stage.StageResult{
|
||||
@@ -1049,16 +1049,16 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
||||
Metadata: map[string]any{"enabled": false},
|
||||
},
|
||||
},
|
||||
resultStage{name: "later", order: &order, result: &stage.StageResult{}},
|
||||
resultStage{name: "analyze", order: &order, result: &stage.StageResult{}},
|
||||
}
|
||||
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{Force: true})
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if strings.Join(order, ",") != "optional,later" {
|
||||
t.Fatalf("execution order = %v, want optional then later", order)
|
||||
if strings.Join(order, ",") != "extract,analyze" {
|
||||
t.Fatalf("execution order = %v, want extract then analyze", order)
|
||||
}
|
||||
if len(summary.Executed) != 2 || len(summary.Skipped) != 1 || summary.Skipped[0] != "optional" {
|
||||
if len(summary.Executed) != 2 || len(summary.Skipped) != 1 || summary.Skipped[0] != "extract" {
|
||||
t.Fatalf("summary = %#v, want optional executed and self-skipped before later", summary)
|
||||
}
|
||||
|
||||
@@ -1066,7 +1066,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Load() session manifest error = %v", err)
|
||||
}
|
||||
selfSkipped := sessionManifest.Stages["optional"]
|
||||
selfSkipped := sessionManifest.Stages["extract"]
|
||||
if selfSkipped == nil || selfSkipped.Status != manifest.StatusSkipped {
|
||||
t.Fatalf("optional stage = %#v, want skipped", selfSkipped)
|
||||
}
|
||||
@@ -1081,7 +1081,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
||||
selfSkipped.Metadata["enabled"] != false || selfSkipped.Metadata["old"] != nil {
|
||||
t.Fatalf("optional result details = %#v, want current bounded diagnostics and metadata", selfSkipped)
|
||||
}
|
||||
if later := sessionManifest.Stages["later"]; later == nil || later.Status != manifest.StatusSucceeded {
|
||||
if later := sessionManifest.Stages["analyze"]; later == nil || later.Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("later stage = %#v, want succeeded", later)
|
||||
}
|
||||
|
||||
@@ -1089,7 +1089,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadRun() error = %v", err)
|
||||
}
|
||||
runStage := runManifest.Stages["optional"]
|
||||
runStage := runManifest.Stages["extract"]
|
||||
if runStage == nil || runStage.Action != manifest.RunStageActionRun || runStage.Status != manifest.StatusSkipped {
|
||||
t.Fatalf("run optional stage = %#v, want run action with skipped status", runStage)
|
||||
}
|
||||
@@ -1108,7 +1108,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
||||
|
||||
func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
invalid := resultStage{name: "optional", result: &stage.StageResult{
|
||||
invalid := resultStage{name: "extract", result: &stage.StageResult{
|
||||
Disposition: stage.StageDispositionSkipped,
|
||||
SkipReason: "integration_disabled",
|
||||
Outputs: []artifacts.Ref{{Kind: "unexpected"}},
|
||||
@@ -1129,7 +1129,7 @@ func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
||||
if loadErr != nil {
|
||||
t.Fatalf("Load() session manifest error = %v", loadErr)
|
||||
}
|
||||
if got := loaded.Stages["optional"]; got == nil || got.Status != manifest.StatusFailed {
|
||||
if got := loaded.Stages["extract"]; got == nil || got.Status != manifest.StatusFailed {
|
||||
t.Fatalf("optional stage = %#v, want failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
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/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
||||
@@ -21,7 +20,7 @@ func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
||||
var capturedSessionID string
|
||||
origExecuteStagesFn := executeStagesFn
|
||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
|
||||
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
|
||||
capturedSessionID = cfg.Session.SessionID
|
||||
return &RunSummary{
|
||||
SessionID: cfg.Session.SessionID,
|
||||
@@ -117,7 +116,7 @@ inputs:
|
||||
`)
|
||||
origExecuteStagesFn := executeStagesFn
|
||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
|
||||
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
|
||||
return &RunSummary{
|
||||
SessionID: cfg.Session.SessionID,
|
||||
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
|
||||
@@ -194,8 +193,8 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
|
||||
var capturedArtifacts []string
|
||||
origExecuteStagesFn := executeStagesFn
|
||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||
for _, s := range stages {
|
||||
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||
for _, s := range plan.Stages() {
|
||||
capturedStages = append(capturedStages, s.Name())
|
||||
}
|
||||
capturedForce = opts.Force
|
||||
@@ -257,7 +256,7 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
|
||||
{
|
||||
name: "plan",
|
||||
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
want: "narratio session plan: workdir prepared",
|
||||
want: "narratio session plan: read-only workdir",
|
||||
},
|
||||
{
|
||||
name: "artifacts",
|
||||
|
||||
17
internal/app/version.go
Normal file
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())
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
SourceInputPlayers = "narratio.input.players"
|
||||
SourceInputParty = "narratio.input.party"
|
||||
SourceInputGlossary = "narratio.input.glossary"
|
||||
SourceInputSpellCatalog = "narratio.input.spell_catalog"
|
||||
|
||||
configuredSourcePrefix = "narratio.artifact."
|
||||
extractionSourcePrefix = "narratio.extraction."
|
||||
@@ -55,6 +56,14 @@ type ScriptoriumInputSourceDescriptor struct {
|
||||
PreviousSession *PreviousSessionSourceDescriptor
|
||||
}
|
||||
|
||||
// PreparedInputSourceDescriptor describes a prepared stable input's source,
|
||||
// manifest kind, and canonical staged filename.
|
||||
type PreparedInputSourceDescriptor struct {
|
||||
SourceID string
|
||||
ManifestKind string
|
||||
Filename string
|
||||
}
|
||||
|
||||
// PreviousSessionSourceDescriptor describes one canonical previous-session input source.
|
||||
type PreviousSessionSourceDescriptor struct {
|
||||
SourceID string
|
||||
@@ -158,9 +167,9 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
|
||||
if trimmed == "" {
|
||||
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
|
||||
}
|
||||
if IsStableInputSource(trimmed) {
|
||||
if prepared, ok := DescribePreparedInputSource(trimmed); ok {
|
||||
return ScriptoriumInputSourceDescriptor{
|
||||
Source: Source{ID: trimmed, Kind: SourceKindStableInput},
|
||||
Source: Source{ID: prepared.SourceID, Kind: SourceKindStableInput},
|
||||
}, nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
|
||||
@@ -185,15 +194,34 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
|
||||
return ScriptoriumInputSourceDescriptor{Source: classified}, nil
|
||||
}
|
||||
|
||||
// IsStableInputSource reports whether source is a prepared stable input source
|
||||
// available only to Scriptorium input resolution.
|
||||
func IsStableInputSource(source string) bool {
|
||||
switch strings.TrimSpace(source) {
|
||||
case SourceInputPlayers, SourceInputParty, SourceInputGlossary:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
var preparedInputSources = map[string]PreparedInputSourceDescriptor{
|
||||
SourceInputPlayers: {
|
||||
SourceID: SourceInputPlayers,
|
||||
ManifestKind: "players",
|
||||
Filename: "players.yml",
|
||||
},
|
||||
SourceInputParty: {
|
||||
SourceID: SourceInputParty,
|
||||
ManifestKind: "party",
|
||||
Filename: "party.yml",
|
||||
},
|
||||
SourceInputGlossary: {
|
||||
SourceID: SourceInputGlossary,
|
||||
ManifestKind: "glossary",
|
||||
Filename: "glossary.yml",
|
||||
},
|
||||
SourceInputSpellCatalog: {
|
||||
SourceID: SourceInputSpellCatalog,
|
||||
ManifestKind: "spell_catalog",
|
||||
Filename: "spell_catalog.json",
|
||||
},
|
||||
}
|
||||
|
||||
// DescribePreparedInputSource returns the canonical descriptor for a prepared
|
||||
// stable input source.
|
||||
func DescribePreparedInputSource(source string) (PreparedInputSourceDescriptor, bool) {
|
||||
descriptor, ok := preparedInputSources[strings.TrimSpace(source)]
|
||||
return descriptor, ok
|
||||
}
|
||||
|
||||
// DescribePreviousSessionSource validates a canonical previous-session source id
|
||||
|
||||
@@ -170,6 +170,7 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
|
||||
{name: "prepared players input", source: "narratio.input.players", wantKind: SourceKindStableInput},
|
||||
{name: "prepared party input", source: "narratio.input.party", wantKind: SourceKindStableInput},
|
||||
{name: "prepared glossary input", source: "narratio.input.glossary", wantKind: SourceKindStableInput},
|
||||
{name: "prepared spell catalog input", source: "narratio.input.spell_catalog", wantKind: SourceKindStableInput},
|
||||
{name: "configured", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"},
|
||||
{name: "previous", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap", wantPrev: true},
|
||||
{name: "invalid previous", source: "narratio.previous_session.artifact.", wantErr: ErrInvalidPreviousSessionSource},
|
||||
@@ -211,6 +212,35 @@ func TestDescribeScriptoriumInputSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePreparedInputSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
source string
|
||||
manifestKind string
|
||||
filename string
|
||||
}{
|
||||
{source: SourceInputPlayers, manifestKind: "players", filename: "players.yml"},
|
||||
{source: SourceInputParty, manifestKind: "party", filename: "party.yml"},
|
||||
{source: SourceInputGlossary, manifestKind: "glossary", filename: "glossary.yml"},
|
||||
{source: SourceInputSpellCatalog, manifestKind: "spell_catalog", filename: "spell_catalog.json"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.manifestKind, func(t *testing.T) {
|
||||
descriptor, ok := DescribePreparedInputSource(" " + tt.source + " ")
|
||||
if !ok {
|
||||
t.Fatalf("DescribePreparedInputSource(%q) ok = false", tt.source)
|
||||
}
|
||||
if descriptor.SourceID != tt.source || descriptor.ManifestKind != tt.manifestKind || descriptor.Filename != tt.filename {
|
||||
t.Fatalf("DescribePreparedInputSource(%q) = %#v", tt.source, descriptor)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if _, ok := DescribePreparedInputSource("narratio.input.unknown"); ok {
|
||||
t.Fatal("DescribePreparedInputSource(unknown) ok = true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInputConfiguredReference(t *testing.T) {
|
||||
configured := map[string]struct{}{"session_recap": {}}
|
||||
|
||||
|
||||
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
|
||||
ProducerRunID string
|
||||
Provenance string
|
||||
Contract *artifactmodel.ContractMetadata
|
||||
Checksum string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// SessionArtifactNotFoundError includes context when a known artifact cannot be read.
|
||||
@@ -259,6 +262,9 @@ func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest,
|
||||
OutputKind: entry.OutputKind,
|
||||
ProducerRunID: entry.ProducerRunID,
|
||||
Provenance: entry.Provenance,
|
||||
Contract: cloneArtifactContract(entry.Contract),
|
||||
Checksum: entry.Checksum,
|
||||
Size: entry.Size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing.T) {
|
||||
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromManifest(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
||||
@@ -385,16 +385,17 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing
|
||||
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
||||
}
|
||||
sourceID := ConfiguredArtifactSourceID("player_handout")
|
||||
if err := catalog.MarkAvailableFromDisk(sourceID, outputPath); err != nil {
|
||||
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
|
||||
}
|
||||
m := analyzeEvidenceFixture(t, paths, "player_handout", "artifacts/player_handout.md", []byte("handout\n"))
|
||||
catalog.HydrateAnalyzeArtifacts(paths, m, map[string]ConfiguredArtifactDefinition{
|
||||
"player_handout": {Enabled: false, OutputPath: "artifacts/player_handout.md"},
|
||||
})
|
||||
|
||||
resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, sourceID, catalog)
|
||||
resolved, err := ResolveSessionArtifactWithCatalog(paths, m, sourceID, catalog)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||
}
|
||||
if resolved.Provenance != ArtifactProvenanceDisabledFromDisk {
|
||||
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceDisabledFromDisk)
|
||||
if resolved.Provenance != ArtifactProvenanceCurrentAnalyzeManifest {
|
||||
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceCurrentAnalyzeManifest)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
|
||||
ArtifactProvenanceDisabledFromDisk = "filesystem.disabled_artifact_output"
|
||||
ArtifactProvenanceCurrentAnalyzeManifest = "manifest.current_analyze_artifact"
|
||||
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
|
||||
)
|
||||
|
||||
@@ -64,6 +65,9 @@ type CatalogEntry struct {
|
||||
Path string
|
||||
Provenance string
|
||||
ProducerRunID string
|
||||
Contract *artifactmodel.ContractMetadata
|
||||
Checksum string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// ArtifactCatalog tracks built-in, configured, and extraction artifact definitions and runtime state.
|
||||
@@ -98,6 +102,7 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
|
||||
if _, exists := c.extractionIndex[trimmed]; exists {
|
||||
return fmt.Errorf("duplicate extraction artifact key %q", trimmed)
|
||||
}
|
||||
def := configured[key]
|
||||
sourceID := ExtractionArtifactSourceID(trimmed)
|
||||
if err := c.addEntry(CatalogEntry{
|
||||
SourceID: sourceID,
|
||||
@@ -105,6 +110,10 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
|
||||
ProducerStage: "extract",
|
||||
OutputKind: "notarius_lane",
|
||||
Planned: true,
|
||||
Contract: &artifactmodel.ContractMetadata{
|
||||
MediaType: def.MediaType, SchemaID: def.SchemaID,
|
||||
SchemaVersion: def.SchemaVersion, ModuleKey: def.ModuleKey,
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register extraction artifact %q: %w", trimmed, err)
|
||||
}
|
||||
@@ -221,7 +230,7 @@ func (c *ArtifactCatalog) Lookup(sourceID string) (CatalogEntry, bool) {
|
||||
return CatalogEntry{}, false
|
||||
}
|
||||
entry, ok := c.entries[strings.TrimSpace(sourceID)]
|
||||
return entry, ok
|
||||
return cloneCatalogEntry(entry), ok
|
||||
}
|
||||
|
||||
// SourceIDForConfiguredKey returns canonical source ID for one configured key.
|
||||
@@ -255,7 +264,7 @@ func (c *ArtifactCatalog) ListConfigured() []CatalogEntry {
|
||||
out := make([]CatalogEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
sourceID := c.configuredIndex[key]
|
||||
out = append(out, c.entries[sourceID])
|
||||
out = append(out, cloneCatalogEntry(c.entries[sourceID]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -272,7 +281,7 @@ func (c *ArtifactCatalog) ListExtraction() []CatalogEntry {
|
||||
sort.Strings(keys)
|
||||
out := make([]CatalogEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
out = append(out, c.entries[c.extractionIndex[key]])
|
||||
out = append(out, cloneCatalogEntry(c.entries[c.extractionIndex[key]]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -282,17 +291,71 @@ func (c *ArtifactCatalog) MarkAvailableGenerated(sourceID, path string) error {
|
||||
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
|
||||
}
|
||||
|
||||
// MarkAvailableFromDisk marks one source as available from disabled artifact on disk.
|
||||
func (c *ArtifactCatalog) MarkAvailableFromDisk(sourceID, path string) error {
|
||||
return c.markAvailable(sourceID, path, ArtifactProvenanceDisabledFromDisk)
|
||||
// MarkAvailableGeneratedEvidence marks one source as available from the
|
||||
// current analyze invocation and retains the semantic output identity needed
|
||||
// by later scheduled dependents.
|
||||
func (c *ArtifactCatalog) MarkAvailableGeneratedEvidence(
|
||||
sourceID, path, producerRunID, checksum string,
|
||||
size int64,
|
||||
contract *artifactmodel.ContractMetadata,
|
||||
) error {
|
||||
producerRunID = strings.TrimSpace(producerRunID)
|
||||
checksum = strings.TrimSpace(checksum)
|
||||
if err := ValidateRunIdentity(producerRunID); err != nil {
|
||||
return fmt.Errorf("generated artifact producer run id: %w", err)
|
||||
}
|
||||
if err := validateSHA256(checksum); err != nil {
|
||||
return fmt.Errorf("generated artifact checksum: %w", err)
|
||||
}
|
||||
if size <= 0 {
|
||||
return fmt.Errorf("generated artifact size must be positive")
|
||||
}
|
||||
if contract == nil || strings.TrimSpace(contract.MediaType) == "" ||
|
||||
strings.TrimSpace(contract.SchemaID) == "" || strings.TrimSpace(contract.SchemaVersion) == "" {
|
||||
return fmt.Errorf("generated artifact contract is incomplete")
|
||||
}
|
||||
if err := c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun); err != nil {
|
||||
return err
|
||||
}
|
||||
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||
entry.ProducerRunID = producerRunID
|
||||
entry.Checksum = checksum
|
||||
entry.Size = size
|
||||
entry.Contract = cloneArtifactContract(contract)
|
||||
c.entries[entry.SourceID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, producerRunID string) error {
|
||||
func (c *ArtifactCatalog) markAvailableFromExtractManifest(
|
||||
sourceID, path, producerRunID, checksum string,
|
||||
size int64,
|
||||
contract *artifactmodel.ContractMetadata,
|
||||
) error {
|
||||
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
|
||||
return err
|
||||
}
|
||||
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
||||
entry.Checksum = strings.TrimSpace(checksum)
|
||||
entry.Size = size
|
||||
entry.Contract = cloneArtifactContract(contract)
|
||||
c.entries[entry.SourceID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ArtifactCatalog) markAvailableFromAnalyzeManifest(
|
||||
sourceID, path, producerRunID, checksum string,
|
||||
size int64,
|
||||
contract *artifactmodel.ContractMetadata,
|
||||
) error {
|
||||
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentAnalyzeManifest); err != nil {
|
||||
return err
|
||||
}
|
||||
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
||||
entry.Checksum = strings.TrimSpace(checksum)
|
||||
entry.Size = size
|
||||
entry.Contract = cloneArtifactContract(contract)
|
||||
c.entries[entry.SourceID] = entry
|
||||
return nil
|
||||
}
|
||||
@@ -313,10 +376,29 @@ func (c *ArtifactCatalog) markAvailable(sourceID, path, provenance string) error
|
||||
entry.Available = true
|
||||
entry.Path = trimmedPath
|
||||
entry.Provenance = provenance
|
||||
entry.ProducerRunID = ""
|
||||
entry.Checksum = ""
|
||||
entry.Size = 0
|
||||
if provenance == ArtifactProvenanceGeneratedCurrentAnalyzeRun {
|
||||
entry.Contract = nil
|
||||
}
|
||||
c.entries[normalizedID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneCatalogEntry(entry CatalogEntry) CatalogEntry {
|
||||
entry.Contract = cloneArtifactContract(entry.Contract)
|
||||
return entry
|
||||
}
|
||||
|
||||
func cloneArtifactContract(contract *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
|
||||
if contract == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *contract
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("artifact catalog is nil")
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package artifacts
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
)
|
||||
|
||||
func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
|
||||
catalog := NewArtifactCatalog()
|
||||
@@ -179,27 +184,27 @@ func TestArtifactCatalogMarkAvailableGenerated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCatalogMarkAvailableFromDisk(t *testing.T) {
|
||||
func TestArtifactCatalogMarkAvailableGeneratedEvidence(t *testing.T) {
|
||||
catalog := NewArtifactCatalog()
|
||||
if err := catalog.RegisterConfiguredArtifacts(
|
||||
map[string]ConfiguredArtifactDefinition{
|
||||
"session_recap": {Enabled: false, OutputPath: "artifacts/session_recap.md"},
|
||||
},
|
||||
nil,
|
||||
); err != nil {
|
||||
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
||||
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{
|
||||
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sourceID, _ := catalog.SourceIDForConfiguredKey("session_recap")
|
||||
if err := catalog.MarkAvailableFromDisk(sourceID, "/tmp/session_recap.md"); err != nil {
|
||||
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
|
||||
contract := &artifactmodel.ContractMetadata{
|
||||
MediaType: "text/markdown", SchemaID: "narratio.session_recap", SchemaVersion: "1",
|
||||
}
|
||||
checksum := strings.Repeat("a", 64)
|
||||
if err := catalog.MarkAvailableGeneratedEvidence(
|
||||
sourceID, "/tmp/session_recap.md", "run-1", checksum, 42, contract,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, _ := catalog.Lookup(sourceID)
|
||||
if !entry.Available {
|
||||
t.Fatalf("entry.Available = false, want true")
|
||||
}
|
||||
if entry.Provenance != ArtifactProvenanceDisabledFromDisk {
|
||||
t.Fatalf("entry.Provenance = %q, want %q", entry.Provenance, ArtifactProvenanceDisabledFromDisk)
|
||||
if !entry.Available || entry.ProducerRunID != "run-1" || entry.Checksum != checksum || entry.Size != 42 ||
|
||||
entry.Contract == nil || *entry.Contract != *contract {
|
||||
t.Fatalf("generated evidence entry = %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,11 @@ func (c *ArtifactCatalog) HydrateExtractionArtifacts(
|
||||
if proof.State != ExtractionEvidenceValid {
|
||||
return
|
||||
}
|
||||
for sourceID, path := range proof.Outputs {
|
||||
_ = c.markAvailableFromExtractManifest(sourceID, path, proof.ProducerRunID)
|
||||
for sourceID, output := range proof.Outputs {
|
||||
_ = c.markAvailableFromExtractManifest(
|
||||
sourceID, output.Path, proof.ProducerRunID,
|
||||
output.Checksum, output.Size, output.Contract,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,17 @@ func TestHydrateExtractionArtifactsAcceptsOnlyCompleteCurrentBundle(t *testing.T
|
||||
if entry.Provenance != ArtifactProvenanceCurrentExtractManifest || entry.ProducerRunID != "extract-run-1" {
|
||||
t.Fatalf("hydrated provenance = %#v", entry)
|
||||
}
|
||||
wantOutput := currentManifest.Stages["extract"].Outputs[0]
|
||||
info, err := os.Stat(wantOutput.LocalPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.Checksum != wantOutput.Checksum || entry.Size != info.Size() {
|
||||
t.Fatalf("hydrated content identity = (%q, %d), want (%q, %d)", entry.Checksum, entry.Size, wantOutput.Checksum, info.Size())
|
||||
}
|
||||
if entry.Contract == nil || *entry.Contract != *wantOutput.Contract {
|
||||
t.Fatalf("hydrated contract = %#v, want %#v", entry.Contract, wantOutput.Contract)
|
||||
}
|
||||
resolved, err := ResolveSessionArtifactWithCatalog(paths, currentManifest, entry.SourceID, catalog)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
@@ -23,7 +24,15 @@ const (
|
||||
type ExtractionEvidence struct {
|
||||
State ExtractionEvidenceState
|
||||
Reason, ProducerRunID string
|
||||
Outputs map[string]string
|
||||
Outputs map[string]ExtractionEvidenceOutput
|
||||
}
|
||||
|
||||
// ExtractionEvidenceOutput is the verified semantic identity of one lane.
|
||||
type ExtractionEvidenceOutput struct {
|
||||
Path string
|
||||
Checksum string
|
||||
Size int64
|
||||
Contract *artifactmodel.ContractMetadata
|
||||
}
|
||||
|
||||
// InspectExtractionEvidence verifies structure, confinement, identities, contracts, and payload bytes.
|
||||
@@ -72,7 +81,7 @@ func InspectExtractionEvidence(
|
||||
for key, d := range configured {
|
||||
expected[ExtractionArtifactSourceID(key)] = d
|
||||
}
|
||||
seen, outputs := map[string]struct{}{}, map[string]string{}
|
||||
seen, outputs := map[string]struct{}{}, map[string]ExtractionEvidenceOutput{}
|
||||
indexSeen := false
|
||||
for _, out := range r.Outputs {
|
||||
if strings.TrimSpace(out.ProducerRunID) != runID {
|
||||
@@ -82,7 +91,7 @@ func InspectExtractionEvidence(
|
||||
if indexSeen || out.Kind != extractionIndexKind || filepath.Clean(out.LocalPath) != filepath.Join(root, "index.json") {
|
||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract index path is not canonical"}
|
||||
}
|
||||
if state, reason := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
|
||||
if state, reason, _ := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
|
||||
return ExtractionEvidence{State: state, Reason: reason}
|
||||
}
|
||||
indexSeen = true
|
||||
@@ -98,11 +107,15 @@ func InspectExtractionEvidence(
|
||||
if !compatibleCatalogExtractionContract(out.Contract, d) || !compatibleCatalogExtractionProvenance(out.ExternalProvenance, receiptRunID, receiptPipelineID, d) {
|
||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract output contract or provenance is incompatible"}
|
||||
}
|
||||
if state, reason := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
|
||||
state, reason, size := inspectExtractionPayload(root, out.LocalPath, out.Checksum)
|
||||
if state != ExtractionEvidenceValid {
|
||||
return ExtractionEvidence{State: state, Reason: reason}
|
||||
}
|
||||
seen[out.SourceID] = struct{}{}
|
||||
outputs[out.SourceID] = out.LocalPath
|
||||
outputs[out.SourceID] = ExtractionEvidenceOutput{
|
||||
Path: out.LocalPath, Checksum: out.Checksum, Size: size,
|
||||
Contract: cloneArtifactContract(out.Contract),
|
||||
}
|
||||
}
|
||||
if !indexSeen || len(seen) != len(expected) || len(r.Outputs) != len(expected)+1 {
|
||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result is incomplete"}
|
||||
@@ -110,30 +123,30 @@ func InspectExtractionEvidence(
|
||||
return ExtractionEvidence{State: ExtractionEvidenceValid, ProducerRunID: runID, Outputs: outputs}
|
||||
}
|
||||
|
||||
func inspectExtractionPayload(root, path, checksum string) (ExtractionEvidenceState, string) {
|
||||
func inspectExtractionPayload(root, path, checksum string) (ExtractionEvidenceState, string, int64) {
|
||||
if !filepath.IsAbs(path) || !pathWithinExtractionRoot(root, path) || strings.TrimSpace(checksum) == "" {
|
||||
return ExtractionEvidenceUnsafe, "extract output path or checksum is unsafe"
|
||||
return ExtractionEvidenceUnsafe, "extract output path or checksum is unsafe", 0
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return ExtractionEvidenceObsolete, "extract output is missing"
|
||||
return ExtractionEvidenceObsolete, "extract output is missing", 0
|
||||
}
|
||||
if !safeExtractionComponents(root, path) {
|
||||
return ExtractionEvidenceUnsafe, "extract output path contains unsafe components"
|
||||
return ExtractionEvidenceUnsafe, "extract output path contains unsafe components", 0
|
||||
}
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return ExtractionEvidenceUnsafe, "extract output is not a regular file"
|
||||
return ExtractionEvidenceUnsafe, "extract output is not a regular file", 0
|
||||
}
|
||||
actual, err := SHA256File(path)
|
||||
if err != nil {
|
||||
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read"
|
||||
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read", 0
|
||||
}
|
||||
if actual != checksum {
|
||||
return ExtractionEvidenceObsolete, "extract output checksum does not match durable bytes"
|
||||
return ExtractionEvidenceObsolete, "extract output checksum does not match durable bytes", 0
|
||||
}
|
||||
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
|
||||
if err != nil || !json.Valid(body) {
|
||||
return ExtractionEvidenceObsolete, "extract output is not valid JSON"
|
||||
return ExtractionEvidenceObsolete, "extract output is not valid JSON", 0
|
||||
}
|
||||
return ExtractionEvidenceValid, ""
|
||||
return ExtractionEvidenceValid, "", info.Size()
|
||||
}
|
||||
|
||||
@@ -113,6 +113,12 @@ func SessionRunNotariusLogPathForCampaign(rootDir, campaign, sessionID, runID st
|
||||
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius.stderr.log")
|
||||
}
|
||||
|
||||
// SessionRunNotariusReferencesDirForCampaign returns the invocation-local
|
||||
// directory containing verified reference snapshots supplied to Notarius.
|
||||
func SessionRunNotariusReferencesDirForCampaign(rootDir, campaign, sessionID, runID string) string {
|
||||
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "references")
|
||||
}
|
||||
|
||||
// SessionRunNotariusOutputRootForCampaign returns the invocation-local Notarius output root.
|
||||
func SessionRunNotariusOutputRootForCampaign(rootDir, campaign, sessionID, runID string) string {
|
||||
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius-output")
|
||||
|
||||
@@ -64,6 +64,7 @@ func TestSessionNotariusPathsForCampaign(t *testing.T) {
|
||||
{name: "extract directory", got: SessionRunExtractDirForCampaign(root, campaign, sessionID, runID), want: extractDir},
|
||||
{name: "receipt", got: SessionRunNotariusReceiptPathForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius.receipt.json")},
|
||||
{name: "stderr", got: SessionRunNotariusLogPathForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius.stderr.log")},
|
||||
{name: "references", got: SessionRunNotariusReferencesDirForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "references")},
|
||||
{name: "output root", got: SessionRunNotariusOutputRootForCampaign(root, campaign, sessionID, runID), want: filepath.Join(extractDir, "notarius-output")},
|
||||
{name: "durable bundle", got: SessionNotariusBundleDirForCampaign(root, campaign, sessionID, runID), want: filepath.Join(root, "work", campaign, sessionID, "artifacts", "notarius", runID)},
|
||||
}
|
||||
|
||||
191
internal/artifacts/prepared_input.go
Normal file
191
internal/artifacts/prepared_input.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
// ErrPreparedInputAbsent reports that the current manifest has no record for a
|
||||
// supported prepared stable input.
|
||||
var ErrPreparedInputAbsent = errors.New("prepared input absent")
|
||||
|
||||
// PreparedInputAbsentError identifies the prepared source absent from the
|
||||
// current manifest.
|
||||
type PreparedInputAbsentError struct {
|
||||
SourceID string
|
||||
}
|
||||
|
||||
func (e *PreparedInputAbsentError) Error() string {
|
||||
return fmt.Sprintf("%s: %q", ErrPreparedInputAbsent, e.SourceID)
|
||||
}
|
||||
|
||||
func (e *PreparedInputAbsentError) Unwrap() error {
|
||||
return ErrPreparedInputAbsent
|
||||
}
|
||||
|
||||
// PreparedInputIdentity is the verified identity of one canonical prepared
|
||||
// session input.
|
||||
type PreparedInputIdentity struct {
|
||||
SourceID string
|
||||
ManifestKind string
|
||||
Path string
|
||||
RelativePath string
|
||||
Checksum string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// ResolvePreparedInput resolves a prepared stable source exclusively from its
|
||||
// current manifest record and verifies the canonical file's identity.
|
||||
func ResolvePreparedInput(paths SessionPaths, m *manifest.Manifest, sourceID string) (PreparedInputIdentity, error) {
|
||||
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
|
||||
if !ok {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("unsupported prepared input source %q", sourceID)
|
||||
}
|
||||
|
||||
rootPath, canonicalPath, relativePath, err := preparedInputCanonicalPaths(paths, descriptor)
|
||||
if err != nil {
|
||||
return PreparedInputIdentity{}, err
|
||||
}
|
||||
|
||||
matching := make([]manifest.InputRecord, 0, 1)
|
||||
if m != nil {
|
||||
for _, record := range m.Inputs {
|
||||
if strings.TrimSpace(record.Kind) == descriptor.ManifestKind {
|
||||
matching = append(matching, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matching) == 0 {
|
||||
if m != nil {
|
||||
for _, record := range m.Inputs {
|
||||
recordedPath, pathErr := resolvePreparedManifestPath(paths, record.Path, rootPath)
|
||||
if pathErr == nil && recordedPath == canonicalPath {
|
||||
return PreparedInputIdentity{}, fmt.Errorf(
|
||||
"prepared input source %q canonical path is recorded with manifest kind %q, want %q",
|
||||
descriptor.SourceID,
|
||||
strings.TrimSpace(record.Kind),
|
||||
descriptor.ManifestKind,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return PreparedInputIdentity{}, &PreparedInputAbsentError{SourceID: descriptor.SourceID}
|
||||
}
|
||||
if len(matching) != 1 {
|
||||
return PreparedInputIdentity{}, fmt.Errorf(
|
||||
"prepared input source %q has %d manifest records for kind %q; want exactly one",
|
||||
descriptor.SourceID,
|
||||
len(matching),
|
||||
descriptor.ManifestKind,
|
||||
)
|
||||
}
|
||||
|
||||
record := matching[0]
|
||||
recordedPath, err := resolvePreparedManifestPath(paths, record.Path, rootPath)
|
||||
if err != nil {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest path: %w", descriptor.SourceID, err)
|
||||
}
|
||||
if recordedPath != canonicalPath {
|
||||
return PreparedInputIdentity{}, fmt.Errorf(
|
||||
"prepared input source %q manifest path %q does not match canonical path %q",
|
||||
descriptor.SourceID,
|
||||
recordedPath,
|
||||
canonicalPath,
|
||||
)
|
||||
}
|
||||
declaredChecksum := strings.TrimSpace(record.Checksum)
|
||||
if declaredChecksum == "" {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q manifest checksum is required", descriptor.SourceID)
|
||||
}
|
||||
|
||||
file, err := fileops.OpenConfinedRegularFile(rootPath, relativePath)
|
||||
if err != nil {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("open prepared input source %q: %w", descriptor.SourceID, err)
|
||||
}
|
||||
digest := sha256.New()
|
||||
size, readErr := io.Copy(digest, file)
|
||||
closeErr := file.Close()
|
||||
if readErr != nil {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("checksum prepared input source %q: %w", descriptor.SourceID, readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("close prepared input source %q: %w", descriptor.SourceID, closeErr)
|
||||
}
|
||||
if size == 0 {
|
||||
return PreparedInputIdentity{}, fmt.Errorf("prepared input source %q is empty", descriptor.SourceID)
|
||||
}
|
||||
checksum := hex.EncodeToString(digest.Sum(nil))
|
||||
if !strings.EqualFold(checksum, declaredChecksum) {
|
||||
return PreparedInputIdentity{}, fmt.Errorf(
|
||||
"prepared input source %q checksum mismatch: manifest=%q actual=%q",
|
||||
descriptor.SourceID,
|
||||
declaredChecksum,
|
||||
checksum,
|
||||
)
|
||||
}
|
||||
|
||||
return PreparedInputIdentity{
|
||||
SourceID: descriptor.SourceID,
|
||||
ManifestKind: descriptor.ManifestKind,
|
||||
Path: canonicalPath,
|
||||
RelativePath: filepath.ToSlash(relativePath),
|
||||
Checksum: checksum,
|
||||
Size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func preparedInputCanonicalPaths(
|
||||
paths SessionPaths,
|
||||
descriptor artifactpolicy.PreparedInputSourceDescriptor,
|
||||
) (rootPath, canonicalPath, relativePath string, err error) {
|
||||
rootPath, err = filepath.Abs(strings.TrimSpace(paths.Root))
|
||||
if err != nil || strings.TrimSpace(paths.Root) == "" {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("session root is required")
|
||||
}
|
||||
return "", "", "", err
|
||||
}
|
||||
canonicalPath, err = filepath.Abs(filepath.Join(paths.InputsDir, descriptor.Filename))
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("resolve prepared input canonical path: %w", err)
|
||||
}
|
||||
relativePath, err = filepath.Rel(rootPath, canonicalPath)
|
||||
if err != nil || relativePath == "." || relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) {
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("resolve prepared input below session root: %w", err)
|
||||
}
|
||||
return "", "", "", fmt.Errorf("prepared input canonical path %q is outside session root %q", canonicalPath, rootPath)
|
||||
}
|
||||
return filepath.Clean(rootPath), filepath.Clean(canonicalPath), filepath.Clean(relativePath), nil
|
||||
}
|
||||
|
||||
func resolvePreparedManifestPath(paths SessionPaths, recordedPath, rootPath string) (string, error) {
|
||||
if strings.TrimSpace(recordedPath) == "" {
|
||||
return "", fmt.Errorf("recorded path is required")
|
||||
}
|
||||
resolved := ResolveSessionLocalPathForRead(paths, recordedPath)
|
||||
if strings.TrimSpace(resolved) == "" {
|
||||
return "", fmt.Errorf("recorded path is required")
|
||||
}
|
||||
absolute, err := filepath.Abs(resolved)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve recorded path: %w", err)
|
||||
}
|
||||
relative, err := filepath.Rel(rootPath, absolute)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve recorded path below session root: %w", err)
|
||||
}
|
||||
if relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("recorded path %q is outside session root %q", absolute, rootPath)
|
||||
}
|
||||
return filepath.Clean(absolute), nil
|
||||
}
|
||||
222
internal/artifacts/prepared_input_test.go
Normal file
222
internal/artifacts/prepared_input_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func TestResolvePreparedInputReturnsVerifiedIdentity(t *testing.T) {
|
||||
paths, m, canonicalPath, checksum := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n"))
|
||||
m.Inputs[0].Path = filepath.ToSlash(filepath.Join("inputs", "spell_catalog.json"))
|
||||
|
||||
identity, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolvePreparedInput() error = %v", err)
|
||||
}
|
||||
wantAbsolute, err := filepath.Abs(canonicalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("filepath.Abs() error = %v", err)
|
||||
}
|
||||
if identity.SourceID != artifactpolicy.SourceInputSpellCatalog ||
|
||||
identity.ManifestKind != "spell_catalog" ||
|
||||
identity.Path != wantAbsolute ||
|
||||
identity.RelativePath != "inputs/spell_catalog.json" ||
|
||||
identity.Checksum != checksum ||
|
||||
identity.Size != int64(len("{\"spells\":[]}\n")) {
|
||||
t.Fatalf("ResolvePreparedInput() = %#v", identity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePreparedInputRequiresCurrentManifestRecord(t *testing.T) {
|
||||
paths, _, _, _ := preparedInputFixture(t, artifactpolicy.SourceInputPlayers, []byte("- Alice\n"))
|
||||
|
||||
for _, m := range []*manifest.Manifest{nil, manifest.New("session", time.Now().UTC())} {
|
||||
_, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputPlayers)
|
||||
if !errors.Is(err, ErrPreparedInputAbsent) {
|
||||
t.Fatalf("ResolvePreparedInput() error = %v, want ErrPreparedInputAbsent", err)
|
||||
}
|
||||
var absent *PreparedInputAbsentError
|
||||
if !errors.As(err, &absent) || absent.SourceID != artifactpolicy.SourceInputPlayers {
|
||||
t.Fatalf("ResolvePreparedInput() error = %#v, want typed players absence", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePreparedInputRejectsInvalidManifestEvidence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*testing.T, SessionPaths, *manifest.Manifest, string)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "duplicate record",
|
||||
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
|
||||
m.Inputs = append(m.Inputs, m.Inputs[0])
|
||||
},
|
||||
wantErr: "2 manifest records",
|
||||
},
|
||||
{
|
||||
name: "wrong kind",
|
||||
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
|
||||
m.Inputs[0].Kind = "players"
|
||||
},
|
||||
wantErr: "recorded with manifest kind",
|
||||
},
|
||||
{
|
||||
name: "wrong canonical path",
|
||||
mutate: func(t *testing.T, paths SessionPaths, m *manifest.Manifest, _ string) {
|
||||
wrong := filepath.Join(paths.InputsDir, "other.json")
|
||||
if err := os.WriteFile(wrong, []byte("other\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(wrong) error = %v", err)
|
||||
}
|
||||
m.Inputs[0].Path = wrong
|
||||
},
|
||||
wantErr: "does not match canonical path",
|
||||
},
|
||||
{
|
||||
name: "traversal path",
|
||||
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
|
||||
m.Inputs[0].Path = filepath.Join("..", "..", "outside.json")
|
||||
},
|
||||
wantErr: "outside session root",
|
||||
},
|
||||
{
|
||||
name: "missing checksum",
|
||||
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
|
||||
m.Inputs[0].Checksum = " "
|
||||
},
|
||||
wantErr: "manifest checksum is required",
|
||||
},
|
||||
{
|
||||
name: "checksum mismatch",
|
||||
mutate: func(_ *testing.T, _ SessionPaths, m *manifest.Manifest, _ string) {
|
||||
m.Inputs[0].Checksum = strings.Repeat("0", 64)
|
||||
},
|
||||
wantErr: "checksum mismatch",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
paths, m, canonicalPath, _ := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n"))
|
||||
tt.mutate(t, paths, m, canonicalPath)
|
||||
_, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ResolvePreparedInput() error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePreparedInputRejectsInvalidCanonicalFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*testing.T, string)
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing",
|
||||
mutate: func(t *testing.T, path string) {
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("Remove() error = %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: "open prepared input source",
|
||||
},
|
||||
{
|
||||
name: "symlink",
|
||||
mutate: func(t *testing.T, path string) {
|
||||
outside := filepath.Join(t.TempDir(), "outside.json")
|
||||
if err := os.WriteFile(outside, []byte("outside\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(outside) error = %v", err)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("Remove() error = %v", err)
|
||||
}
|
||||
if err := os.Symlink(outside, path); err != nil {
|
||||
t.Fatalf("Symlink() error = %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: "not a regular file",
|
||||
},
|
||||
{
|
||||
name: "directory",
|
||||
mutate: func(t *testing.T, path string) {
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("Remove() error = %v", err)
|
||||
}
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("Mkdir() error = %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: "not a regular file",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
mutate: func(t *testing.T, path string) {
|
||||
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(empty) error = %v", err)
|
||||
}
|
||||
},
|
||||
wantErr: "is empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
paths, m, canonicalPath, _ := preparedInputFixture(t, artifactpolicy.SourceInputSpellCatalog, []byte("{\"spells\":[]}\n"))
|
||||
tt.mutate(t, canonicalPath)
|
||||
_, err := ResolvePreparedInput(paths, m, artifactpolicy.SourceInputSpellCatalog)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("ResolvePreparedInput() error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePreparedInputRejectsUnsupportedSource(t *testing.T) {
|
||||
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
|
||||
_, err := ResolvePreparedInput(paths, nil, "narratio.input.unknown")
|
||||
if err == nil || errors.Is(err, ErrPreparedInputAbsent) || !strings.Contains(err.Error(), "unsupported prepared input source") {
|
||||
t.Fatalf("ResolvePreparedInput() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func preparedInputFixture(
|
||||
t *testing.T,
|
||||
sourceID string,
|
||||
payload []byte,
|
||||
) (SessionPaths, *manifest.Manifest, string, string) {
|
||||
t.Helper()
|
||||
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
|
||||
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
|
||||
if !ok {
|
||||
t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID)
|
||||
}
|
||||
canonicalPath := filepath.Join(paths.InputsDir, descriptor.Filename)
|
||||
if err := os.MkdirAll(paths.InputsDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(inputs) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(canonicalPath, payload, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(canonical) error = %v", err)
|
||||
}
|
||||
checksum, err := SHA256File(canonicalPath)
|
||||
if err != nil {
|
||||
t.Fatalf("SHA256File() error = %v", err)
|
||||
}
|
||||
m := manifest.New("session", time.Now().UTC())
|
||||
m.Inputs = []manifest.InputRecord{{
|
||||
Kind: descriptor.ManifestKind,
|
||||
Path: canonicalPath,
|
||||
Checksum: checksum,
|
||||
Source: "campaign_config",
|
||||
}}
|
||||
return paths, m, canonicalPath, checksum
|
||||
}
|
||||
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"
|
||||
@@ -138,6 +138,74 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
||||
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config")
|
||||
}
|
||||
|
||||
func TestCampaignSessionMergeSpellCatalog(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
campaignValue string
|
||||
sessionValue string
|
||||
wantPath string
|
||||
wantSource string
|
||||
}{
|
||||
{name: "omitted", wantPath: "", wantSource: "campaign_config"},
|
||||
{name: "campaign inherited", campaignValue: "./campaign-spells.json", wantPath: "./campaign-spells.json", wantSource: "campaign_config"},
|
||||
{name: "session override", campaignValue: "./campaign-spells.json", sessionValue: "./session-spells.json", wantPath: "./session-spells.json", wantSource: "session_config"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
campaignSpell := ""
|
||||
if tt.campaignValue != "" {
|
||||
campaignSpell = " spell_catalog_file: " + tt.campaignValue + "\n"
|
||||
}
|
||||
sessionSpell := ""
|
||||
if tt.sessionValue != "" {
|
||||
sessionSpell = " spell_catalog_file: " + tt.sessionValue + "\n"
|
||||
}
|
||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n"+campaignSpell,
|
||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+sessionSpell,
|
||||
)
|
||||
|
||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||
}
|
||||
wantConfigPath := campaignPath
|
||||
if tt.wantSource == "session_config" {
|
||||
wantConfigPath = sessionPath
|
||||
}
|
||||
assertResolvedStableInput(t, cfg.StableInputs.SpellCatalogFile, tt.wantPath, wantConfigPath, tt.wantSource)
|
||||
if cfg.Session.Inputs.SpellCatalogFile != tt.wantPath {
|
||||
t.Fatalf("session spell_catalog_file = %q, want %q", cfg.Session.Inputs.SpellCatalogFile, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
campaignLine string
|
||||
sessionLine string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "campaign", campaignLine: " spell_catalog_file: ' '\n", wantErr: "campaign.inputs.spell_catalog_file"},
|
||||
{name: "session", sessionLine: " spell_catalog_file: ' '\n", wantErr: "session.inputs.spell_catalog_file"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n"+tt.campaignLine,
|
||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+tt.sessionLine,
|
||||
)
|
||||
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t,
|
||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n",
|
||||
|
||||
@@ -54,6 +54,7 @@ type CampaignInputsConfig struct {
|
||||
GlossaryFile string `yaml:"glossary_file"`
|
||||
PlayersFile string `yaml:"players_file"`
|
||||
PartyFile string `yaml:"party_file"`
|
||||
SpellCatalogFile string `yaml:"spell_catalog_file"`
|
||||
}
|
||||
|
||||
// SessionConfig contains per-session inputs and metadata.
|
||||
@@ -266,6 +267,7 @@ type NotariusConfig struct {
|
||||
PipelineID string `yaml:"pipeline_id"`
|
||||
Timeout string `yaml:"timeout"`
|
||||
WorkingDirectory string `yaml:"working_directory"`
|
||||
References map[string]string `yaml:"references"`
|
||||
Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
|
||||
}
|
||||
|
||||
@@ -293,6 +295,7 @@ type SessionInputsConfig struct {
|
||||
GlossaryFile string `yaml:"glossary_file"`
|
||||
PlayersFile string `yaml:"players_file"`
|
||||
PartyFile string `yaml:"party_file"`
|
||||
SpellCatalogFile string `yaml:"spell_catalog_file"`
|
||||
}
|
||||
|
||||
// SessionAudioS3Input configures S3 session-audio input discovery.
|
||||
@@ -308,6 +311,7 @@ type ResolvedStableInputs struct {
|
||||
GlossaryFile ResolvedInputFile
|
||||
PlayersFile ResolvedInputFile
|
||||
PartyFile ResolvedInputFile
|
||||
SpellCatalogFile ResolvedInputFile
|
||||
}
|
||||
|
||||
// ResolvedInputFile records one merged config path and its source config file.
|
||||
|
||||
@@ -35,6 +35,9 @@ const (
|
||||
DefaultAuditaReport = true
|
||||
DefaultNotariusBinary = "notarius"
|
||||
DefaultNotariusTimeout = "3h"
|
||||
// MaxNotariusReferenceBindings is the maximum number of CLI reference
|
||||
// bindings accepted for one Notarius invocation.
|
||||
MaxNotariusReferenceBindings = 256
|
||||
|
||||
DefaultScriptoriumBinary = "scriptorium"
|
||||
DefaultScriptoriumTimeout = "10m"
|
||||
|
||||
@@ -209,6 +209,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
||||
if sessionCfg == nil {
|
||||
return ResolvedStableInputs{}, fmt.Errorf("session config is required")
|
||||
}
|
||||
if campaignCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(campaignCfg.Inputs.SpellCatalogFile) == "" {
|
||||
return ResolvedStableInputs{}, fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided")
|
||||
}
|
||||
if sessionCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(sessionCfg.Inputs.SpellCatalogFile) == "" {
|
||||
return ResolvedStableInputs{}, fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
|
||||
}
|
||||
|
||||
campaignName := CampaignID(campaignCfg)
|
||||
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
|
||||
@@ -254,6 +260,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
||||
campaignPath,
|
||||
sessionPath,
|
||||
),
|
||||
SpellCatalogFile: selectStableInput(
|
||||
campaignCfg.Inputs.SpellCatalogFile,
|
||||
sessionCfg.Inputs.SpellCatalogFile,
|
||||
campaignPath,
|
||||
sessionPath,
|
||||
),
|
||||
}
|
||||
|
||||
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
|
||||
@@ -261,6 +273,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
||||
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
|
||||
sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path
|
||||
sessionCfg.Inputs.PartyFile = stable.PartyFile.Path
|
||||
sessionCfg.Inputs.SpellCatalogFile = stable.SpellCatalogFile.Path
|
||||
return stable, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -108,6 +109,7 @@ func TestNotariusStrictYAML(t *testing.T) {
|
||||
{name: "unknown output field", yaml: "notarius:\n outputs:\n npc_registry:\n lane_id: npc-registry\n unknown: true\n"},
|
||||
{name: "unsupported session id", yaml: "notarius:\n session_id: forbidden\n"},
|
||||
{name: "unsupported model", yaml: "notarius:\n model: forbidden\n"},
|
||||
{name: "duplicate reference selector", yaml: "notarius:\n references:\n party: narratio.input.party\n party: narratio.input.players\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -122,6 +124,134 @@ func TestNotariusStrictYAML(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusReferenceValidationAndNormalization(t *testing.T) {
|
||||
cfg := validNotariusConfig()
|
||||
cfg.References = map[string]string{
|
||||
" party ": " narratio.input.party ",
|
||||
" chunk . players ": "narratio.input.players",
|
||||
" npc-registry . extract . glossary ": "narratio.input.glossary",
|
||||
"spells": "narratio.input.spell_catalog",
|
||||
}
|
||||
|
||||
if err := validateNotarius(cfg, nil); err != nil {
|
||||
t.Fatalf("validateNotarius() error = %v", err)
|
||||
}
|
||||
want := map[string]string{
|
||||
"party": "narratio.input.party",
|
||||
"chunk.players": "narratio.input.players",
|
||||
"npc-registry.extract.glossary": "narratio.input.glossary",
|
||||
"spells": "narratio.input.spell_catalog",
|
||||
}
|
||||
if len(cfg.References) != len(want) {
|
||||
t.Fatalf("normalized references = %#v, want %#v", cfg.References, want)
|
||||
}
|
||||
for selector, source := range want {
|
||||
if cfg.References[selector] != source {
|
||||
t.Fatalf("references[%q] = %q, want %q", selector, cfg.References[selector], source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusReferenceValidationRejectsInvalidBindings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
references map[string]string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty selector", references: map[string]string{" ": "narratio.input.party"}, wantErr: "selector is required"},
|
||||
{name: "equals in selector", references: map[string]string{"party=x": "narratio.input.party"}, wantErr: "must not contain"},
|
||||
{name: "invalid stage", references: map[string]string{"lane.prepare.party": "narratio.input.party"}, wantErr: "middle component"},
|
||||
{name: "empty source", references: map[string]string{"party": " "}, wantErr: "source is required"},
|
||||
{name: "unsupported source", references: map[string]string{"party": "narratio.input.unknown"}, wantErr: "not a supported prepared input source"},
|
||||
{
|
||||
name: "normalized collision",
|
||||
references: map[string]string{
|
||||
"chunk.party": "narratio.input.party",
|
||||
" chunk . party ": "narratio.input.players",
|
||||
},
|
||||
wantErr: "normalize to",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := validNotariusConfig()
|
||||
cfg.References = tt.references
|
||||
err := validateNotarius(cfg, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("validateNotarius() error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusReferenceLimit(t *testing.T) {
|
||||
for _, count := range []int{MaxNotariusReferenceBindings, MaxNotariusReferenceBindings + 1} {
|
||||
t.Run(fmt.Sprintf("count_%d", count), func(t *testing.T) {
|
||||
cfg := validNotariusConfig()
|
||||
cfg.References = make(map[string]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
cfg.References[fmt.Sprintf("lane-%03d.party", i)] = "narratio.input.party"
|
||||
}
|
||||
err := validateNotarius(cfg, nil)
|
||||
if count == MaxNotariusReferenceBindings {
|
||||
if err != nil {
|
||||
t.Fatalf("validateNotarius() at limit error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "at most 256 bindings") {
|
||||
t.Fatalf("validateNotarius() above limit error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusNilAndEmptyReferencesAreValid(t *testing.T) {
|
||||
for _, references := range []map[string]string{nil, {}} {
|
||||
cfg := validNotariusConfig()
|
||||
cfg.References = references
|
||||
if err := validateNotarius(cfg, nil); err != nil {
|
||||
t.Fatalf("validateNotarius(%#v) error = %v", references, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusSpellCatalogReferenceRequiresEffectiveInput(t *testing.T) {
|
||||
cfg := loadedValidConfig(t)
|
||||
cfg.Pipeline.Notarius = validNotariusConfig()
|
||||
cfg.Pipeline.Notarius.References = map[string]string{"spells": "narratio.input.spell_catalog"}
|
||||
|
||||
err := Validate(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file") {
|
||||
t.Fatalf("Validate() error = %v, want missing spell catalog input", err)
|
||||
}
|
||||
|
||||
cfg.StableInputs.SpellCatalogFile = ResolvedInputFile{Path: "./spells.json", Source: "campaign_config"}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() with spell catalog error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func validNotariusConfig() *NotariusConfig {
|
||||
return &NotariusConfig{
|
||||
Enabled: true,
|
||||
Binary: "notarius",
|
||||
ConfigPath: "./notarius.yml",
|
||||
PipelineID: "dnd-session",
|
||||
Timeout: "45m",
|
||||
WorkingDirectory: ".",
|
||||
Outputs: map[string]NotariusOutputConfig{
|
||||
"npc_registry": {
|
||||
LaneID: "npc-registry",
|
||||
MediaType: "application/json",
|
||||
SchemaID: "notarius.dnd.npc_registry",
|
||||
SchemaVersion: "v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusEnabledValidation(t *testing.T) {
|
||||
valid := `notarius:
|
||||
enabled: true
|
||||
|
||||
@@ -451,7 +451,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
||||
wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.depends_on must not include itself",
|
||||
},
|
||||
{
|
||||
name: "enabled dependency cycle fails validation",
|
||||
name: "configured dependency cycle fails validation",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
@@ -476,7 +476,25 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
||||
source: narratio.artifact.artifact_a
|
||||
required: true
|
||||
`,
|
||||
wantValidateErr: "pipeline.scriptorium.artifacts enabled dependencies must not contain cycles",
|
||||
wantValidateErr: "pipeline.scriptorium.artifacts dependencies must not contain cycles",
|
||||
},
|
||||
{
|
||||
name: "disabled dependency cycle fails validation",
|
||||
scriptoriumYAML: `scriptorium:
|
||||
binary: scriptorium
|
||||
artifacts:
|
||||
artifact_a:
|
||||
enabled: false
|
||||
depends_on:
|
||||
- artifact_b
|
||||
output_path: artifacts/a.md
|
||||
artifact_b:
|
||||
enabled: false
|
||||
depends_on:
|
||||
- artifact_a
|
||||
output_path: artifacts/b.md
|
||||
`,
|
||||
wantValidateErr: "pipeline.scriptorium.artifacts dependencies must not contain cycles",
|
||||
},
|
||||
{
|
||||
name: "artifact source typo fails validation",
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
@@ -39,7 +40,7 @@ func Validate(cfg *Config) error {
|
||||
if err := validateSession(cfg.Session); err != nil {
|
||||
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
|
||||
}
|
||||
if err := validateCrossConfig(cfg.Pipeline, cfg.Session); err != nil {
|
||||
if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
|
||||
return fmt.Errorf("pipeline/session config invalid: %w", err)
|
||||
}
|
||||
|
||||
@@ -71,6 +72,9 @@ func validateCampaign(cfg *CampaignConfig) error {
|
||||
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
||||
return fmt.Errorf("campaign.inputs.party_file is required")
|
||||
}
|
||||
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
|
||||
return fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -547,6 +551,39 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
|
||||
if strings.TrimSpace(cfg.WorkingDirectory) == "" {
|
||||
return fmt.Errorf("pipeline.notarius.working_directory is required when pipeline.notarius.enabled is true")
|
||||
}
|
||||
if len(cfg.References) > MaxNotariusReferenceBindings {
|
||||
return fmt.Errorf("pipeline.notarius.references must contain at most %d bindings", MaxNotariusReferenceBindings)
|
||||
}
|
||||
|
||||
referenceKeys := make([]string, 0, len(cfg.References))
|
||||
for selector := range cfg.References {
|
||||
referenceKeys = append(referenceKeys, selector)
|
||||
}
|
||||
sort.Strings(referenceKeys)
|
||||
var normalizedReferences map[string]string
|
||||
if cfg.References != nil {
|
||||
normalizedReferences = make(map[string]string, len(cfg.References))
|
||||
}
|
||||
referenceOwners := make(map[string]string, len(cfg.References))
|
||||
for _, rawSelector := range referenceKeys {
|
||||
selector, err := notariusref.NormalizeSelector(rawSelector)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pipeline.notarius.references selector %q is invalid: %w", rawSelector, err)
|
||||
}
|
||||
if previous, ok := referenceOwners[selector]; ok {
|
||||
return fmt.Errorf("pipeline.notarius.references selectors %q and %q normalize to %q", previous, rawSelector, selector)
|
||||
}
|
||||
referenceOwners[selector] = rawSelector
|
||||
|
||||
source := strings.TrimSpace(cfg.References[rawSelector])
|
||||
if source == "" {
|
||||
return fmt.Errorf("pipeline.notarius.references.%s source is required", selector)
|
||||
}
|
||||
if _, ok := artifactpolicy.DescribePreparedInputSource(source); !ok {
|
||||
return fmt.Errorf("pipeline.notarius.references.%s source %q is not a supported prepared input source", selector, source)
|
||||
}
|
||||
normalizedReferences[selector] = source
|
||||
}
|
||||
|
||||
reservedSources := map[string]string{}
|
||||
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {
|
||||
@@ -613,6 +650,7 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
|
||||
cfg.PipelineID = strings.TrimSpace(cfg.PipelineID)
|
||||
cfg.Timeout = strings.TrimSpace(cfg.Timeout)
|
||||
cfg.WorkingDirectory = filepath.Clean(cfg.WorkingDirectory)
|
||||
cfg.References = normalizedReferences
|
||||
cfg.Outputs = normalizedOutputs
|
||||
return nil
|
||||
}
|
||||
@@ -723,7 +761,7 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error
|
||||
}
|
||||
}
|
||||
|
||||
if err := validateEnabledArtifactDependencyCycles(cfg.Artifacts); err != nil {
|
||||
if err := ValidateScriptoriumArtifactDependencies(cfg.Artifacts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -762,6 +800,9 @@ func validateSession(cfg *SessionConfig) error {
|
||||
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
||||
return fmt.Errorf("session.inputs.party_file is required")
|
||||
}
|
||||
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
|
||||
return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
|
||||
}
|
||||
|
||||
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
|
||||
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
|
||||
@@ -797,10 +838,17 @@ func validateSessionIdentifier(fieldName, value string, required bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error {
|
||||
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig, stableInputs ResolvedStableInputs) error {
|
||||
if pipeline == nil || session == nil {
|
||||
return nil
|
||||
}
|
||||
if pipeline.Notarius != nil && pipeline.Notarius.Enabled {
|
||||
for selector, source := range pipeline.Notarius.References {
|
||||
if source == artifactpolicy.SourceInputSpellCatalog && strings.TrimSpace(stableInputs.SpellCatalogFile.Path) == "" {
|
||||
return fmt.Errorf("pipeline.notarius.references.%s requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file", selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
audioS3Enabled := session.Inputs.AudioS3 != nil
|
||||
publishUploadEnabled := publishUploadConfiguredForS3(pipeline)
|
||||
@@ -935,38 +983,47 @@ func validatePathWithinRoot(fieldName, value, root string) error {
|
||||
return fmt.Errorf("%s must be under %s/", fieldName, normalizedRoot)
|
||||
}
|
||||
|
||||
func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArtifactConfig) error {
|
||||
// ValidateScriptoriumArtifactDependencies validates the configured dependency
|
||||
// graph independently of execution selection. Disabled artifacts remain valid
|
||||
// prerequisites for explicit selections and therefore participate in cycles.
|
||||
func ValidateScriptoriumArtifactDependencies(artifacts map[string]ScriptoriumArtifactConfig) error {
|
||||
if len(artifacts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabled := make(map[string]struct{}, len(artifacts))
|
||||
graph := make(map[string][]string, len(artifacts))
|
||||
for name, cfg := range artifacts {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
}
|
||||
enabled[name] = struct{}{}
|
||||
}
|
||||
for name, cfg := range artifacts {
|
||||
if !cfg.Enabled {
|
||||
continue
|
||||
if !artifactpolicy.IsConfiguredKey(name) {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(cfg.DependsOn))
|
||||
for _, dep := range cfg.DependsOn {
|
||||
trimmedDep := strings.TrimSpace(dep)
|
||||
if _, ok := enabled[trimmedDep]; ok {
|
||||
if trimmedDep == "" {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on entries must be non-empty", name)
|
||||
}
|
||||
if _, ok := artifacts[trimmedDep]; !ok {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifacts.%s dependency %q is not configured", name, dep)
|
||||
}
|
||||
if trimmedDep == name {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on must not include itself", name)
|
||||
}
|
||||
if _, duplicate := seen[trimmedDep]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[trimmedDep] = struct{}{}
|
||||
graph[name] = append(graph[name], trimmedDep)
|
||||
}
|
||||
}
|
||||
sort.Strings(graph[name])
|
||||
}
|
||||
|
||||
visiting := make(map[string]bool, len(enabled))
|
||||
visited := make(map[string]bool, len(enabled))
|
||||
visiting := make(map[string]bool, len(artifacts))
|
||||
visited := make(map[string]bool, len(artifacts))
|
||||
|
||||
var visit func(node string) error
|
||||
visit = func(node string) error {
|
||||
if visiting[node] {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifacts enabled dependencies must not contain cycles")
|
||||
return fmt.Errorf("pipeline.scriptorium.artifacts dependencies must not contain cycles")
|
||||
}
|
||||
if visited[node] {
|
||||
return nil
|
||||
@@ -982,7 +1039,12 @@ func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArt
|
||||
return nil
|
||||
}
|
||||
|
||||
for node := range enabled {
|
||||
nodes := make([]string, 0, len(artifacts))
|
||||
for node := range artifacts {
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
sort.Strings(nodes)
|
||||
for _, node := range nodes {
|
||||
if err := visit(node); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -22,6 +22,32 @@ func RemoveAllUnderRoot(rootPath, target string) error {
|
||||
return removeConfinedEntry(root, targetName)
|
||||
}
|
||||
|
||||
// RemoveFileUnderRoot removes an exact regular-file target below root without
|
||||
// following symlinked ancestors or the leaf. A missing target is successful;
|
||||
// directories, symlinks, and other non-regular entries are rejected.
|
||||
func RemoveFileUnderRoot(rootPath, target string) error {
|
||||
root, targetName, err := openConfinedCleanupTarget(rootPath, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
|
||||
info, err := root.Lstat(targetName)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect cleanup file %q: %w", targetName, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("refusing to delete non-regular file path %q", targetName)
|
||||
}
|
||||
if err := root.Remove(targetName); err != nil {
|
||||
return fmt.Errorf("remove cleanup file %q: %w", targetName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openConfinedCleanupTarget(rootPath, target string) (*os.Root, string, error) {
|
||||
if strings.TrimSpace(rootPath) == "" {
|
||||
return nil, "", fmt.Errorf("cleanup root is required")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -77,3 +79,47 @@ func TestRemoveAllUnderRootRejectsSymlinkInTree(t *testing.T) {
|
||||
t.Fatalf("outside sentinel was changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveFileUnderRootRemovesOnlyRegularFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "inputs", "spell_catalog.json")
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
if err := RemoveFileUnderRoot(root, target); err != nil {
|
||||
t.Fatalf("RemoveFileUnderRoot() error = %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("Lstat() error = %v, want not exist", err)
|
||||
}
|
||||
if err := RemoveFileUnderRoot(root, target); err != nil {
|
||||
t.Fatalf("RemoveFileUnderRoot(missing) error = %v", err)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
setup func(string) error
|
||||
}{
|
||||
{name: "directory", setup: func(path string) error { return os.Mkdir(path, 0o755) }},
|
||||
{name: "symlink", setup: func(path string) error { return os.Symlink(filepath.Join(root, "outside"), path) }},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tt.setup(target); err != nil {
|
||||
t.Fatalf("setup target: %v", err)
|
||||
}
|
||||
err := RemoveFileUnderRoot(root, target)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-regular") {
|
||||
t.Fatalf("RemoveFileUnderRoot() error = %v, want non-regular rejection", err)
|
||||
}
|
||||
if _, err := os.Lstat(target); err != nil {
|
||||
t.Fatalf("ambiguous target was removed: %v", err)
|
||||
}
|
||||
if err := os.Remove(target); err != nil {
|
||||
t.Fatalf("cleanup target: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,17 +67,29 @@ func CopyFileAtomicWithChecksum(src, dst string, perm os.FileMode) (string, erro
|
||||
}
|
||||
defer func() { _ = in.Close() }()
|
||||
|
||||
return WriteReaderAtomicWithChecksum(dst, in, perm)
|
||||
}
|
||||
|
||||
// WriteReaderAtomicWithChecksum streams src through the durable replacement
|
||||
// sequence and returns the SHA-256 checksum of the installed bytes. The caller
|
||||
// retains ownership of src.
|
||||
func WriteReaderAtomicWithChecksum(dst string, src io.Reader, perm os.FileMode) (string, error) {
|
||||
if strings.TrimSpace(dst) == "" {
|
||||
return "", fmt.Errorf("destination path is required")
|
||||
}
|
||||
if src == nil {
|
||||
return "", fmt.Errorf("source reader is required")
|
||||
}
|
||||
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
|
||||
return "", fmt.Errorf("create destination directory: %w", err)
|
||||
}
|
||||
|
||||
digest := sha256.New()
|
||||
err = replaceFileFromReaderConfined(
|
||||
if err := replaceFileFromReaderConfined(
|
||||
dst,
|
||||
io.TeeReader(in, digest),
|
||||
io.TeeReader(src, digest),
|
||||
ReplaceFileOptions{Mode: perm},
|
||||
)
|
||||
if err != nil {
|
||||
); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(digest.Sum(nil)), nil
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package fileops
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
@@ -74,6 +76,28 @@ func TestCopyFileAtomicWithChecksumMatchesDestination(t *testing.T) {
|
||||
assertNoMatchingTempFiles(t, filepath.Dir(dst), ".copied.txt.tmp-")
|
||||
}
|
||||
|
||||
func TestWriteReaderAtomicWithChecksumMatchesDestination(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dst := filepath.Join(root, "nested", "snapshot.yml")
|
||||
payload := "verified reference bytes\n"
|
||||
|
||||
checksum, err := WriteReaderAtomicWithChecksum(dst, strings.NewReader(payload), WorkspaceFileMode)
|
||||
if err != nil {
|
||||
t.Fatalf("WriteReaderAtomicWithChecksum() error = %v", err)
|
||||
}
|
||||
wantChecksum := sha256.Sum256([]byte(payload))
|
||||
if checksum != hex.EncodeToString(wantChecksum[:]) {
|
||||
t.Fatalf("checksum = %q, want %q", checksum, hex.EncodeToString(wantChecksum[:]))
|
||||
}
|
||||
data, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(data) != payload {
|
||||
t.Fatalf("destination = %q, want %q", data, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyFileAtomicCleansTempFileOnInstallFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := filepath.Join(root, "source.txt")
|
||||
|
||||
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"`
|
||||
Error *ErrorRecord `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||
}
|
||||
|
||||
// CleanupTarget records one root-confined local deletion requested by a
|
||||
|
||||
@@ -34,6 +34,8 @@ type RunStageRecord struct {
|
||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||
Error *ErrorRecord `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||
}
|
||||
|
||||
// RunManifest is the invocation-scoped execution record under runs/{run_id}/manifest.json.
|
||||
|
||||
@@ -110,6 +110,15 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
|
||||
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
|
||||
return fmt.Errorf("save manifest: %w", err)
|
||||
}
|
||||
for name, stage := range m.Stages {
|
||||
if stage == nil {
|
||||
continue
|
||||
}
|
||||
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
|
||||
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||
return fmt.Errorf("save manifest: stages.%s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
m.UpdatedAt = time.Now().UTC()
|
||||
if m.Stages == nil {
|
||||
@@ -216,6 +225,15 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e
|
||||
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
||||
return fmt.Errorf("save run manifest: %w", err)
|
||||
}
|
||||
for name, stage := range m.Stages {
|
||||
if stage == nil {
|
||||
continue
|
||||
}
|
||||
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
|
||||
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||
return fmt.Errorf("save run manifest: stages.%s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
m.UpdatedAt = time.Now().UTC()
|
||||
if m.Stages == nil {
|
||||
@@ -250,6 +268,14 @@ func validateLoadedManifest(m *Manifest) error {
|
||||
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, stage := range m.Stages {
|
||||
if stage == nil {
|
||||
continue
|
||||
}
|
||||
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||
return fmt.Errorf("stages.%s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -266,6 +292,7 @@ func normalizeManifest(m *Manifest) {
|
||||
if stage.Name == "" {
|
||||
stage.Name = name
|
||||
}
|
||||
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +336,14 @@ func validateLoadedRunManifest(m *RunManifest) error {
|
||||
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
||||
return err
|
||||
}
|
||||
for name, stage := range m.Stages {
|
||||
if stage == nil {
|
||||
continue
|
||||
}
|
||||
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||
return fmt.Errorf("stages.%s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -358,6 +393,7 @@ func normalizeRunManifest(m *RunManifest) {
|
||||
if stage.Name == "" {
|
||||
stage.Name = name
|
||||
}
|
||||
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
43
internal/notariusref/selector.go
Normal file
43
internal/notariusref/selector.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Package notariusref owns Narratio's Notarius CLI reference-selector
|
||||
// vocabulary without depending on Notarius implementation packages.
|
||||
package notariusref
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NormalizeSelector validates and normalizes a Notarius v0.6 reference
|
||||
// selector. It intentionally validates selector structure only; Notarius owns
|
||||
// target, slot, and media compatibility.
|
||||
func NormalizeSelector(value string) (string, error) {
|
||||
selector := strings.TrimSpace(value)
|
||||
if selector == "" {
|
||||
return "", fmt.Errorf("reference selector is required")
|
||||
}
|
||||
if strings.Contains(selector, "=") {
|
||||
return "", fmt.Errorf("reference selector must not contain '='")
|
||||
}
|
||||
|
||||
parts := strings.Split(selector, ".")
|
||||
for index := range parts {
|
||||
parts[index] = strings.TrimSpace(parts[index])
|
||||
if parts[index] == "" {
|
||||
return "", fmt.Errorf("reference selector components must not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
switch len(parts) {
|
||||
case 1, 2:
|
||||
return strings.Join(parts, "."), nil
|
||||
case 3:
|
||||
switch parts[1] {
|
||||
case "extract", "merge", "normalize":
|
||||
return strings.Join(parts, "."), nil
|
||||
default:
|
||||
return "", fmt.Errorf("three-component reference selector must use extract, merge, or normalize as its middle component")
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("reference selector must use slot, chunk.slot, lane.slot, or lane.stage.slot")
|
||||
}
|
||||
}
|
||||
58
internal/notariusref/selector_test.go
Normal file
58
internal/notariusref/selector_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package notariusref
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeSelectorAcceptsDocumentedForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{name: "pipeline", value: "party", want: "party"},
|
||||
{name: "chunk", value: "chunk.party", want: "chunk.party"},
|
||||
{name: "lane", value: "npc-registry.party", want: "npc-registry.party"},
|
||||
{name: "extract", value: "npc-registry.extract.party", want: "npc-registry.extract.party"},
|
||||
{name: "merge", value: "npc-registry.merge.party", want: "npc-registry.merge.party"},
|
||||
{name: "normalize", value: "npc-registry.normalize.party", want: "npc-registry.normalize.party"},
|
||||
{name: "whitespace", value: " npc-registry . extract . party ", want: "npc-registry.extract.party"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := NormalizeSelector(test.value)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeSelector(%q) error = %v", test.value, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("NormalizeSelector(%q) = %q, want %q", test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSelectorRejectsInvalidForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty", value: "", wantErr: "required"},
|
||||
{name: "whitespace", value: " ", wantErr: "required"},
|
||||
{name: "empty first", value: ".party", wantErr: "components"},
|
||||
{name: "empty middle", value: "lane..party", wantErr: "components"},
|
||||
{name: "empty final", value: "lane.", wantErr: "components"},
|
||||
{name: "equals", value: "party=/tmp/party.yml", wantErr: "must not contain"},
|
||||
{name: "invalid stage", value: "lane.chunk.party", wantErr: "extract, merge, or normalize"},
|
||||
{name: "too many components", value: "lane.extract.party.extra", wantErr: "must use"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := NormalizeSelector(test.value)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("NormalizeSelector(%q) error = %v, want containing %q", test.value, err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,9 @@ package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
@@ -28,6 +30,8 @@ type analyzeArtifactExecutionPlan struct {
|
||||
|
||||
type analyzeArtifactExecutionResult struct {
|
||||
Output artifacts.Ref
|
||||
OutputSize int64
|
||||
Scriptorium manifest.AnalyzeArtifactProvenance
|
||||
Logs []string
|
||||
GeneratedConfigs []string
|
||||
Metadata map[string]any
|
||||
@@ -44,21 +48,6 @@ type analyzeExecutionContext struct {
|
||||
Catalog *artifacts.ArtifactCatalog
|
||||
}
|
||||
|
||||
type analyzeInputResolutionState uint8
|
||||
|
||||
const (
|
||||
analyzeInputPresent analyzeInputResolutionState = iota
|
||||
analyzeInputAbsent
|
||||
analyzeInputError
|
||||
)
|
||||
|
||||
type analyzeInputResolution struct {
|
||||
State analyzeInputResolutionState
|
||||
Path string
|
||||
Artifact *artifacts.ResolvedSessionArtifact
|
||||
Err error
|
||||
}
|
||||
|
||||
func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
|
||||
if env == nil || env.Config == nil {
|
||||
return nil, fmt.Errorf("analyze: stage environment config is required")
|
||||
@@ -69,10 +58,6 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
if env.Config.Pipeline == nil || env.Config.Session == nil {
|
||||
return nil, fmt.Errorf("analyze: resolved config must include pipeline and session")
|
||||
}
|
||||
if env.Scriptorium == nil {
|
||||
return nil, fmt.Errorf("analyze: scriptorium adapter is required")
|
||||
}
|
||||
|
||||
var sessionID string
|
||||
if m != nil {
|
||||
sessionID = strings.TrimSpace(m.SessionID)
|
||||
@@ -96,6 +81,13 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
"reason": "pipeline.scriptorium is not configured",
|
||||
}}, nil
|
||||
}
|
||||
if len(env.Config.Pipeline.Scriptorium.Artifacts) == 0 {
|
||||
return &StageResult{Metadata: map[string]any{
|
||||
"stage": "analyze",
|
||||
"skipped": true,
|
||||
"reason": "no scriptorium artifacts configured",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
effective := env.EffectiveArtifacts
|
||||
if !effective.Resolved() {
|
||||
@@ -119,15 +111,11 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err)
|
||||
}
|
||||
|
||||
plans, skipReason, err := buildAnalyzeExecutionPlans(env.Config.Pipeline.Scriptorium, effective, runtimeCatalog)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: %w", err)
|
||||
}
|
||||
if skipReason != "" {
|
||||
if len(effective.Keys()) == 0 {
|
||||
return &StageResult{Metadata: map[string]any{
|
||||
"stage": "analyze",
|
||||
"skipped": true,
|
||||
"reason": skipReason,
|
||||
"reason": "no selected scriptorium artifacts to execute",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
@@ -140,21 +128,81 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths),
|
||||
Catalog: runtimeCatalog,
|
||||
}
|
||||
|
||||
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)
|
||||
reconciliation, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, execution)
|
||||
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...)
|
||||
generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...)
|
||||
artifactMetadata = append(artifactMetadata, artifactResult.Metadata)
|
||||
@@ -169,18 +217,64 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
reusedArtifacts = append(reusedArtifacts, reused)
|
||||
}
|
||||
|
||||
record, err := currentAnalyzeArtifactRecord(
|
||||
execution,
|
||||
plan,
|
||||
fingerprint,
|
||||
artifactResult,
|
||||
)
|
||||
if err != nil {
|
||||
recordErr := fmt.Errorf("analyze: record artifact %q: %w", plan.Name, err)
|
||||
return failedAnalyzeResult(
|
||||
execution,
|
||||
item,
|
||||
artifactCfg,
|
||||
fingerprint,
|
||||
recordErr,
|
||||
sessionRecords,
|
||||
invocationRecords,
|
||||
), recordErr
|
||||
}
|
||||
sessionRecords[plan.Name] = record
|
||||
invocationRecords[plan.Name] = record
|
||||
|
||||
sourceID, ok := runtimeCatalog.SourceIDForConfiguredKey(plan.Name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("analyze: source id not found for artifact %q", plan.Name)
|
||||
catalogErr := fmt.Errorf("analyze: source id not found for artifact %q", plan.Name)
|
||||
return failedAnalyzeResult(
|
||||
execution, item, artifactCfg, fingerprint, catalogErr,
|
||||
sessionRecords, invocationRecords,
|
||||
), catalogErr
|
||||
}
|
||||
if err := runtimeCatalog.MarkAvailableGenerated(sourceID, artifactResult.Output.AbsolutePath); err != nil {
|
||||
return nil, fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err)
|
||||
if err := runtimeCatalog.MarkAvailableGeneratedEvidence(
|
||||
sourceID,
|
||||
artifactResult.Output.AbsolutePath,
|
||||
record.ProducerRunID,
|
||||
record.Output.Checksum,
|
||||
record.OutputSize,
|
||||
record.Output.Contract,
|
||||
); err != nil {
|
||||
catalogErr := fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err)
|
||||
return failedAnalyzeResult(
|
||||
execution, item, artifactCfg, fingerprint, catalogErr,
|
||||
sessionRecords, invocationRecords,
|
||||
), catalogErr
|
||||
}
|
||||
if !hadPriorRecord || !sameAnalyzeOutputIdentity(priorRecord, record) {
|
||||
staleUnscheduledAnalyzeDependents(
|
||||
env.Config.Pipeline.Scriptorium.Artifacts,
|
||||
plan.Name,
|
||||
invocationKeys,
|
||||
sessionRecords,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
metadata := map[string]any{
|
||||
"stage": "analyze",
|
||||
"selected_artifacts": extractPlanNames(plans),
|
||||
"selected_artifacts": append([]string(nil), workPlan.ExplicitTargets...),
|
||||
"executed_artifacts": analyzePlanKeysForMetadata(workPlan.ExecutionOrder),
|
||||
"reused_current": analyzePlanKeysForMetadata(workPlan.ReusedCurrent),
|
||||
"generated_artifacts": artifactMetadata,
|
||||
"reused_artifacts": reusedArtifacts,
|
||||
"artifact_count": len(artifactMetadata),
|
||||
@@ -193,133 +287,200 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
}
|
||||
|
||||
return &StageResult{
|
||||
Outputs: outputs,
|
||||
Logs: dedupeAndSortPaths(logs),
|
||||
GeneratedConfigs: dedupeAndSortPaths(generatedConfigs),
|
||||
Metadata: metadata,
|
||||
AnalyzeState: &AnalyzeStateProjection{
|
||||
Session: sessionRecords,
|
||||
Invocation: manifest.CloneAnalyzeArtifactCollection(invocationRecords),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildAnalyzeExecutionPlans(
|
||||
scriptoriumCfg *config.ScriptoriumConfig,
|
||||
effective artifacts.EffectiveArtifactSet,
|
||||
catalog *artifacts.ArtifactCatalog,
|
||||
) ([]analyzeArtifactExecutionPlan, string, error) {
|
||||
if scriptoriumCfg == nil {
|
||||
return nil, "pipeline.scriptorium is not configured", nil
|
||||
func failedAnalyzeResult(
|
||||
execution analyzeExecutionContext,
|
||||
item analyzePlanItem,
|
||||
artifactCfg config.ScriptoriumArtifactConfig,
|
||||
fingerprint string,
|
||||
cause error,
|
||||
sessionRecords map[string]manifest.AnalyzeArtifactRecord,
|
||||
invocationRecords map[string]manifest.AnalyzeArtifactRecord,
|
||||
) *StageResult {
|
||||
record := manifest.AnalyzeArtifactRecord{
|
||||
Key: item.Key,
|
||||
Status: manifest.AnalyzeArtifactFailed,
|
||||
Dependencies: normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn),
|
||||
ProducerRunID: analyzeProducerRunID(execution),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
Error: NonResumable(cause.Error()).Reason,
|
||||
}
|
||||
if len(scriptoriumCfg.Artifacts) == 0 {
|
||||
return nil, "no scriptorium artifacts configured", nil
|
||||
if fingerprint != "" {
|
||||
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
|
||||
record.Fingerprint = fingerprint
|
||||
}
|
||||
|
||||
if len(effective.Keys()) == 0 {
|
||||
return nil, "no selected scriptorium artifacts to execute", nil
|
||||
if artifactCfg.PromptID != "" || artifactCfg.ProfileID != "" {
|
||||
record.Scriptorium = &manifest.AnalyzeArtifactProvenance{
|
||||
PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID,
|
||||
}
|
||||
|
||||
ordered, err := orderSelectedScriptoriumArtifacts(scriptoriumCfg.Artifacts, effective, catalog)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
plans := make([]analyzeArtifactExecutionPlan, 0, len(ordered))
|
||||
for _, name := range ordered {
|
||||
artifactCfg, ok := scriptoriumCfg.Artifacts[name]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("selected artifact %q is not configured", name)
|
||||
}
|
||||
plans = append(plans, analyzeArtifactExecutionPlan{Name: name, Cfg: artifactCfg})
|
||||
}
|
||||
return plans, "", nil
|
||||
sessionRecords[item.Key] = record
|
||||
invocationRecords[item.Key] = record
|
||||
staleAnalyzeDependents(artifactCfgMap(execution), item.Key, sessionRecords)
|
||||
return &StageResult{AnalyzeState: &AnalyzeStateProjection{
|
||||
Session: manifest.CloneAnalyzeArtifactCollection(sessionRecords),
|
||||
Invocation: manifest.CloneAnalyzeArtifactCollection(invocationRecords),
|
||||
}}
|
||||
}
|
||||
|
||||
func orderSelectedScriptoriumArtifacts(
|
||||
artifactsCfg map[string]config.ScriptoriumArtifactConfig,
|
||||
effective artifacts.EffectiveArtifactSet,
|
||||
catalog *artifacts.ArtifactCatalog,
|
||||
) ([]string, error) {
|
||||
selectedSet := map[string]struct{}{}
|
||||
selected := effective.Keys()
|
||||
for _, key := range selected {
|
||||
selectedSet[key] = struct{}{}
|
||||
func artifactCfgMap(execution analyzeExecutionContext) map[string]config.ScriptoriumArtifactConfig {
|
||||
if execution.Env == nil || execution.Env.Config == nil || execution.Env.Config.Pipeline == nil ||
|
||||
execution.Env.Config.Pipeline.Scriptorium == nil {
|
||||
return nil
|
||||
}
|
||||
return execution.Env.Config.Pipeline.Scriptorium.Artifacts
|
||||
}
|
||||
|
||||
dependencyErrors := []string{}
|
||||
for _, selectedKey := range selected {
|
||||
cfg, ok := artifactsCfg[selectedKey]
|
||||
if !ok {
|
||||
dependencyErrors = append(dependencyErrors, fmt.Sprintf("selected artifact %q is not configured", selectedKey))
|
||||
func staleAnalyzeDependents(
|
||||
configured map[string]config.ScriptoriumArtifactConfig,
|
||||
changed string,
|
||||
records map[string]manifest.AnalyzeArtifactRecord,
|
||||
) {
|
||||
staleAnalyzeDependentClosure(configured, changed, records, nil)
|
||||
}
|
||||
|
||||
func currentAnalyzeArtifactRecord(
|
||||
execution analyzeExecutionContext,
|
||||
plan analyzeArtifactExecutionPlan,
|
||||
fingerprint string,
|
||||
result *analyzeArtifactExecutionResult,
|
||||
) (manifest.AnalyzeArtifactRecord, error) {
|
||||
if result == nil {
|
||||
return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("execution result is required")
|
||||
}
|
||||
producerRunID := analyzeProducerRunID(execution)
|
||||
relativePath, err := normalizedAnalyzeOutputIdentity(plan.Cfg.OutputPath)
|
||||
if err != nil {
|
||||
return manifest.AnalyzeArtifactRecord{}, err
|
||||
}
|
||||
if relativePath == "" {
|
||||
return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("configured output path is required")
|
||||
}
|
||||
contract := result.Output.Contract
|
||||
if contract == nil {
|
||||
return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("validated output contract is required")
|
||||
}
|
||||
record := manifest.AnalyzeArtifactRecord{
|
||||
Key: plan.Name,
|
||||
Status: manifest.AnalyzeArtifactCurrent,
|
||||
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
|
||||
Fingerprint: fingerprint,
|
||||
Dependencies: normalizedAnalyzeDependencyKeys(plan.Cfg.DependsOn),
|
||||
Output: &manifest.ArtifactRecord{
|
||||
Kind: "scriptorium_artifact",
|
||||
SourceID: artifacts.ConfiguredArtifactSourceID(plan.Name),
|
||||
LocalPath: relativePath,
|
||||
Contract: cloneAnalyzeOutputContract(contract),
|
||||
ProducerRunID: producerRunID,
|
||||
Checksum: result.Output.Checksum,
|
||||
},
|
||||
OutputSize: result.OutputSize,
|
||||
ProducerRunID: producerRunID,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
Scriptorium: &result.Scriptorium,
|
||||
Logs: dedupeAndSortPaths(result.Logs),
|
||||
GeneratedConfigs: dedupeAndSortPaths(result.GeneratedConfigs),
|
||||
}
|
||||
if err := manifest.ValidateAnalyzeArtifactCollection(
|
||||
manifest.AnalyzeStateContractVersion,
|
||||
map[string]manifest.AnalyzeArtifactRecord{plan.Name: record},
|
||||
); err != nil {
|
||||
return manifest.AnalyzeArtifactRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func analyzeProducerRunID(execution analyzeExecutionContext) string {
|
||||
if execution.Manifest != nil {
|
||||
if runID := strings.TrimSpace(execution.Manifest.RunID); runID != "" {
|
||||
return runID
|
||||
}
|
||||
}
|
||||
// Direct stage callers predate invocation manifests. Application-owned
|
||||
// execution always supplies the actual run identity.
|
||||
return "direct-analyze"
|
||||
}
|
||||
|
||||
func sameAnalyzeOutputIdentity(left, right manifest.AnalyzeArtifactRecord) bool {
|
||||
if left.Status != manifest.AnalyzeArtifactCurrent || right.Status != manifest.AnalyzeArtifactCurrent ||
|
||||
left.Output == nil || right.Output == nil || left.Output.Contract == nil || right.Output.Contract == nil {
|
||||
return false
|
||||
}
|
||||
return left.OutputSize == right.OutputSize &&
|
||||
left.Output.Checksum == right.Output.Checksum &&
|
||||
*left.Output.Contract == *right.Output.Contract
|
||||
}
|
||||
|
||||
func staleUnscheduledAnalyzeDependents(
|
||||
configured map[string]config.ScriptoriumArtifactConfig,
|
||||
changed string,
|
||||
invocationKeys map[string]struct{},
|
||||
records map[string]manifest.AnalyzeArtifactRecord,
|
||||
) {
|
||||
staleAnalyzeDependentClosure(configured, changed, records, func(key string) bool {
|
||||
_, evaluated := invocationKeys[key]
|
||||
return !evaluated
|
||||
})
|
||||
}
|
||||
|
||||
func staleAnalyzeDependentClosure(
|
||||
configured map[string]config.ScriptoriumArtifactConfig,
|
||||
changed string,
|
||||
records map[string]manifest.AnalyzeArtifactRecord,
|
||||
eligible func(string) bool,
|
||||
) {
|
||||
reverse := make(map[string][]string, len(configured))
|
||||
for key, artifactCfg := range configured {
|
||||
for _, dependency := range normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn) {
|
||||
reverse[dependency] = append(reverse[dependency], key)
|
||||
}
|
||||
}
|
||||
for key := range reverse {
|
||||
sort.Strings(reverse[key])
|
||||
}
|
||||
queue := append([]string(nil), reverse[changed]...)
|
||||
seen := make(map[string]struct{}, len(queue))
|
||||
for len(queue) > 0 {
|
||||
key := queue[0]
|
||||
queue = queue[1:]
|
||||
if _, visited := seen[key]; visited {
|
||||
continue
|
||||
}
|
||||
for _, dep := range cfg.DependsOn {
|
||||
trimmedDep := strings.TrimSpace(dep)
|
||||
if trimmedDep == "" {
|
||||
seen[key] = struct{}{}
|
||||
if eligible != nil && !eligible(key) {
|
||||
continue
|
||||
}
|
||||
if _, ok := selectedSet[trimmedDep]; ok {
|
||||
continue
|
||||
}
|
||||
sourceID, ok := catalog.SourceIDForConfiguredKey(trimmedDep)
|
||||
if !ok {
|
||||
dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep))
|
||||
continue
|
||||
}
|
||||
entry, ok := catalog.Lookup(sourceID)
|
||||
if !ok || !entry.Available {
|
||||
dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dependencyErrors) > 0 {
|
||||
return nil, errors.New(strings.Join(dependencyErrors, "; "))
|
||||
staleProjectedAnalyzeRecord(records, key)
|
||||
queue = append(queue, reverse[key]...)
|
||||
}
|
||||
}
|
||||
|
||||
indegree := map[string]int{}
|
||||
edges := map[string][]string{}
|
||||
for _, key := range selected {
|
||||
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]++
|
||||
func analyzePlanKeysForMetadata(items []analyzePlanItem) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
keys = append(keys, item.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
for key := range edges {
|
||||
sort.Strings(edges[key])
|
||||
func cloneAnalyzeOutputContract(value *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ready := make([]string, 0, len(indegree))
|
||||
for key, degree := range indegree {
|
||||
if degree == 0 {
|
||||
ready = append(ready, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(ready)
|
||||
|
||||
order := make([]string, 0, len(selectedSet))
|
||||
for len(ready) > 0 {
|
||||
node := ready[0]
|
||||
ready = ready[1:]
|
||||
order = append(order, node)
|
||||
for _, dep := range edges[node] {
|
||||
indegree[dep]--
|
||||
if indegree[dep] == 0 {
|
||||
ready = append(ready, dep)
|
||||
sort.Strings(ready)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(order) != len(selectedSet) {
|
||||
return nil, fmt.Errorf("selected scriptorium artifacts contain a dependency cycle")
|
||||
}
|
||||
return order, nil
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func executeAnalyzeArtifact(
|
||||
@@ -335,34 +496,25 @@ func executeAnalyzeArtifact(
|
||||
artifactName := plan.Name
|
||||
artifactCfg := plan.Cfg
|
||||
|
||||
inputPaths := map[string]string{}
|
||||
resolvedInputs, err := resolveAnalyzeInputIdentities(artifactCfg.Inputs, execution)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: resolve inputs for artifact %q: %w", artifactName, err)
|
||||
}
|
||||
inputPaths := resolvedInputs.Paths()
|
||||
omittedOptionalInputs := []string{}
|
||||
reusedArtifacts := []map[string]any{}
|
||||
|
||||
inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs)
|
||||
for _, inputName := range inputNames {
|
||||
inputCfg := artifactCfg.Inputs[inputName]
|
||||
resolution := resolveScriptoriumInput(inputCfg, execution)
|
||||
switch resolution.State {
|
||||
case analyzeInputError:
|
||||
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolution.Err)
|
||||
case analyzeInputAbsent:
|
||||
if inputCfg.Required {
|
||||
return nil, fmt.Errorf("analyze: required input %q for artifact %q could not be resolved", inputName, artifactName)
|
||||
}
|
||||
omittedOptionalInputs = append(omittedOptionalInputs, inputName)
|
||||
for _, identity := range resolvedInputs.Ordered {
|
||||
if !identity.Present {
|
||||
omittedOptionalInputs = append(omittedOptionalInputs, identity.Name)
|
||||
continue
|
||||
case analyzeInputPresent:
|
||||
inputPaths[inputName] = resolution.Path
|
||||
default:
|
||||
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: invalid resolution state", inputName, artifactName)
|
||||
}
|
||||
if resolution.Artifact != nil && resolution.Artifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk {
|
||||
resolvedArtifact := resolvedInputs.Artifact(identity.Name)
|
||||
if resolvedArtifact != nil && resolvedArtifact.Provenance == artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
|
||||
reusedArtifacts = append(reusedArtifacts, map[string]any{
|
||||
"name": configuredArtifactNameFromSourceID(resolution.Artifact.ID),
|
||||
"source_id": resolution.Artifact.ID,
|
||||
"path": resolution.Artifact.Path,
|
||||
"provenance": resolution.Artifact.Provenance,
|
||||
"name": configuredArtifactNameFromSourceID(resolvedArtifact.ID),
|
||||
"source_id": resolvedArtifact.ID,
|
||||
"path": resolvedArtifact.Path,
|
||||
"provenance": resolvedArtifact.Provenance,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -528,14 +680,32 @@ func executeAnalyzeArtifact(
|
||||
if err := requireNonEmptyFile(finalOutputPath, artifactName+" output"); err != nil {
|
||||
return nil, fmt.Errorf("analyze: %w", err)
|
||||
}
|
||||
validatedOutput, err := readExternalResult(finalOutputPath, artifactName+" output")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: %w", err)
|
||||
}
|
||||
contract := &artifactmodel.ContractMetadata{
|
||||
MediaType: "text/markdown", SchemaID: "narratio." + artifactName, SchemaVersion: "1",
|
||||
}
|
||||
relativeOutputPath, err := normalizedAnalyzeOutputIdentity(artifactCfg.OutputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: normalize output identity for artifact %q: %w", artifactName, err)
|
||||
}
|
||||
materializedArtifact, err := materializeRunLocalOutput(env.ArtifactStore, finalOutputPath, canonicalOutputPath, artifacts.Ref{
|
||||
Kind: artifactName,
|
||||
SourceID: artifacts.ConfiguredArtifactSourceID(artifactName),
|
||||
Category: "artifacts",
|
||||
SessionID: sessionID,
|
||||
RelativePath: relativeOutputPath,
|
||||
Contract: contract,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("analyze: materialize artifact output for %q: %w", artifactName, err)
|
||||
}
|
||||
expectedDigest := sha256.Sum256(validatedOutput)
|
||||
if materializedArtifact.Checksum != hex.EncodeToString(expectedDigest[:]) {
|
||||
return nil, fmt.Errorf("analyze: materialized artifact output for %q differs from validated run-local bytes", artifactName)
|
||||
}
|
||||
|
||||
logPaths = append(logPaths, stdoutLogPath, stderrLogPath)
|
||||
generatedConfigs = append(generatedConfigs, generatedConfigPath)
|
||||
@@ -562,6 +732,10 @@ func executeAnalyzeArtifact(
|
||||
|
||||
return &analyzeArtifactExecutionResult{
|
||||
Output: materializedArtifact,
|
||||
OutputSize: int64(len(validatedOutput)),
|
||||
Scriptorium: manifest.AnalyzeArtifactProvenance{
|
||||
PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, CommandMode: res.CommandMode,
|
||||
},
|
||||
Logs: logPaths,
|
||||
GeneratedConfigs: generatedConfigs,
|
||||
Metadata: meta,
|
||||
@@ -569,17 +743,6 @@ func executeAnalyzeArtifact(
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractPlanNames(plans []analyzeArtifactExecutionPlan) []string {
|
||||
if len(plans) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
out = append(out, plan.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func configuredArtifactNameFromSourceID(sourceID string) string {
|
||||
name, _ := artifactpolicy.ParseConfiguredSource(sourceID)
|
||||
return name
|
||||
@@ -620,83 +783,6 @@ func discoverAnalyzeArtifactRef(m *manifest.Manifest, paths artifacts.SessionPat
|
||||
return resolved.Path, resolved.Provenance
|
||||
}
|
||||
|
||||
func resolveScriptoriumInput(inputCfg config.ScriptoriumInputConfig, execution analyzeExecutionContext) analyzeInputResolution {
|
||||
source := strings.TrimSpace(inputCfg.Source)
|
||||
descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source)
|
||||
if describeErr != nil {
|
||||
return analyzeInputFailure(describeErr)
|
||||
}
|
||||
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
|
||||
resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, execution.Paths)
|
||||
if err != nil {
|
||||
if inputCfg.Required {
|
||||
return analyzeInputFailure(err)
|
||||
}
|
||||
return analyzeInputMissing()
|
||||
}
|
||||
if !ok {
|
||||
return analyzeInputMissing()
|
||||
}
|
||||
return analyzeInputFound(resolvedPath, nil)
|
||||
}
|
||||
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
||||
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
|
||||
if err == nil {
|
||||
copy := resolved
|
||||
return analyzeInputFound(resolved.Path, ©)
|
||||
}
|
||||
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 {
|
||||
entry, ok := execution.Catalog.Lookup(source)
|
||||
if !ok || strings.TrimSpace(entry.ProducerStage) == "" {
|
||||
@@ -710,36 +796,6 @@ func requiredBuiltInInputError(source string, execution analyzeExecutionContext)
|
||||
)
|
||||
}
|
||||
|
||||
func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) {
|
||||
filename, ok := preparedStableInputFilename(sourceID)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("unsupported prepared input source %q", sourceID)
|
||||
}
|
||||
path := filepath.Join(paths.InputsDir, filename)
|
||||
if err := requireNonEmptyFile(path, "prepared input "+sourceID); err != nil {
|
||||
return "", false, fmt.Errorf(
|
||||
"prepared input source %q is unavailable; run narratio run-stage prepare %s --force: %w",
|
||||
sourceID,
|
||||
paths.SessionID,
|
||||
err,
|
||||
)
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
|
||||
func preparedStableInputFilename(sourceID string) (string, bool) {
|
||||
switch strings.TrimSpace(sourceID) {
|
||||
case artifactpolicy.SourceInputPlayers:
|
||||
return "players.yml", true
|
||||
case artifactpolicy.SourceInputParty:
|
||||
return "party.yml", true
|
||||
case artifactpolicy.SourceInputGlossary:
|
||||
return "glossary.yml", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func buildAnalyzeRuntimeArtifactCatalog(
|
||||
paths artifacts.SessionPaths,
|
||||
m *manifest.Manifest,
|
||||
@@ -759,25 +815,7 @@ func buildAnalyzeRuntimeArtifactCatalog(
|
||||
if notariusCfg != nil && notariusCfg.Enabled {
|
||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||
}
|
||||
|
||||
for _, entry := range catalog.ListConfigured() {
|
||||
if entry.Executable {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(entry.CanonicalRelPath) == "" {
|
||||
continue
|
||||
}
|
||||
resolvedPath, err := resolveScriptoriumOutputPath(paths, entry.CanonicalRelPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := requireNonEmptyFile(resolvedPath, "configured artifact "+entry.SourceID); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := catalog.MarkAvailableFromDisk(entry.SourceID, resolvedPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user