Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cec84a4a7 | |||
| abfbe42d61 | |||
| e7e3bef1e4 | |||
| a68e8e31a4 | |||
| 3a9e60cda9 | |||
| 905ff03ccc | |||
| 495f7bcde4 | |||
| 51e0e8c5d0 | |||
| a2409a1fd1 | |||
| b3363f87d6 | |||
| 5831c0c9e6 | |||
| 42ed81cbe1 |
@@ -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.
|
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
|
||||||
- Pipeline defaults are applied before validation.
|
- Pipeline defaults are applied before validation.
|
||||||
- Campaign and session identities must agree.
|
- 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:
|
- Exactly one audio mode must be configured in session input:
|
||||||
- local (`audio_dir` or `audio_files`), or
|
- local (`audio_dir` or `audio_files`), or
|
||||||
- S3 (`audio_s3.prefix`).
|
- S3 (`audio_s3.prefix`).
|
||||||
@@ -227,6 +230,7 @@ Rules:
|
|||||||
| `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled |
|
| `pipeline.notarius.pipeline_id` | string | Conditional | required when enabled |
|
||||||
| `pipeline.notarius.timeout` | duration | No | `3h`; must be positive |
|
| `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.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.notarius.outputs` | map | Conditional | at least one entry when enabled |
|
||||||
| `pipeline.render.enabled` | bool | No | `true` |
|
| `pipeline.render.enabled` | bool | No | `true` |
|
||||||
| `pipeline.render.format` | string | No | `markdown` (only supported value) |
|
| `pipeline.render.format` | string | No | `markdown` (only supported value) |
|
||||||
@@ -241,6 +245,44 @@ Rules:
|
|||||||
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
||||||
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
|
| `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
|
### Notarius Output Entries
|
||||||
|
|
||||||
For each `pipeline.notarius.outputs.<name>`:
|
For each `pipeline.notarius.outputs.<name>`:
|
||||||
@@ -318,6 +360,7 @@ integration.
|
|||||||
| `inputs.glossary_file` | string | Yes | stable input default |
|
| `inputs.glossary_file` | string | Yes | stable input default |
|
||||||
| `inputs.players_file` | string | Yes | stable input default |
|
| `inputs.players_file` | string | Yes | stable input default |
|
||||||
| `inputs.party_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
|
### Session
|
||||||
|
|
||||||
@@ -333,6 +376,7 @@ integration.
|
|||||||
| `inputs.glossary_file` | string | No | overrides campaign stable input |
|
| `inputs.glossary_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.players_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.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_dir` | string | Conditional | local audio mode |
|
||||||
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
||||||
| `inputs.audio_s3.prefix` | string | Conditional | S3 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
|
safe bundle discovery, lane selection, and its own artifact metadata. Notarius
|
||||||
owns pipeline definitions, lane schemas, the receipt, and bundle formats.
|
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)
|
- [CLI reference](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/cli.md)
|
||||||
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/consumers/dnd-pipeline.md)
|
- [Subprocess consumer contract](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/consumers/subprocess.md)
|
||||||
- [Run-result receipt](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/integrations/run-result.md)
|
- [D&D pipeline and lane contracts](https://gitea.maximumdirect.net/eric/notarius/src/tag/v0.6.0/docs/consumers/dnd-pipeline.md)
|
||||||
- [JSON output bundle](https://gitea.maximumdirect.net/eric/notarius/src/branch/main/docs/integrations/json-output.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)
|
The [complete Narratio example](../../examples/pipeline.full.annotated.yml)
|
||||||
records the exact current constraints for all ten D&D lanes. Treat the linked
|
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,
|
When `pipeline.notarius.enabled` is true, Narratio resolves the executable,
|
||||||
configuration path, input path, output directory, and working directory to
|
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
|
```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
|
Standard output is reserved for the JSON receipt. Standard error is captured
|
||||||
separately as diagnostic output. Narratio applies the configured timeout and
|
separately as diagnostic output. Narratio applies the configured timeout and
|
||||||
does not interpret stdout as a receipt unless the subprocess exits successfully.
|
does not interpret stdout as a receipt unless the subprocess exits successfully.
|
||||||
@@ -38,24 +71,31 @@ environment apply to the subprocess.
|
|||||||
|
|
||||||
## Accepted Result
|
## 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
|
must identify the configured pipeline, and its `index_file` must be exactly
|
||||||
`index.json` beneath the reported bundle root. The production index must name
|
`index.json` beneath the reported bundle root. The production index must name
|
||||||
the management files exactly as `manifest.json`, `rejected.json`, and
|
the management files exactly as `manifest.json`, `rejected.json`,
|
||||||
`warnings.json`. All receipt, index, and lane paths must stay inside that
|
`warnings.json`, and `diagnostics.json`. All receipt, index, and lane paths must
|
||||||
bundle; symlinks and non-regular lane payloads are rejected.
|
stay inside that bundle; symlinks and non-regular lane payloads are rejected.
|
||||||
|
|
||||||
Supported receipt and index shapes tolerate unknown fields for forward
|
Supported receipt and index shapes tolerate unknown fields for forward
|
||||||
compatibility, while required identity, validation, count, manifest,
|
compatibility, while required identity, validation, count, manifest,
|
||||||
rejection, warning, and lane-list fields remain mandatory. Narratio applies
|
rejection, warning, diagnostic, and lane-list fields remain mandatory.
|
||||||
bounded reads to the receipt, index, rejection, and warning documents. Optional
|
Narratio applies bounded reads to the receipt, index, rejection, warning, and
|
||||||
chunk-map and evidence-context descriptors must carry their complete generic
|
diagnostic documents. Warning and diagnostic envelopes, group counts,
|
||||||
contract metadata when present.
|
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
|
For every entry in `pipeline.notarius.outputs`, Narratio requires exactly one
|
||||||
index descriptor with the configured lane ID, media type, schema ID, schema
|
index descriptor with the configured lane ID, media type, schema ID, schema
|
||||||
version, and, when configured, module key. Missing, duplicate, rejected, or
|
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
|
Unconfigured lanes may remain in the preserved bundle but do not become
|
||||||
selectable Narratio sources.
|
selectable Narratio sources.
|
||||||
|
|
||||||
@@ -72,10 +112,13 @@ only explicitly named lane sources; `--artifacts` never selects Notarius lanes.
|
|||||||
staged bundle is promoted to durable storage.
|
staged bundle is promoted to durable storage.
|
||||||
- Contract and external provenance metadata are preserved on lane artifact
|
- Contract and external provenance metadata are preserved on lane artifact
|
||||||
records and through explicit publication.
|
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
|
Rejection, validation, warning, and diagnostic summaries retain bounded stable
|
||||||
reason-code fields for diagnostics without exposing free-form external messages
|
identity, category, origin, reason-code, status, and occurrence fields without
|
||||||
or reading lane payload bodies.
|
copying free-form external messages into Narratio manifest metadata or reading
|
||||||
|
lane payload bodies.
|
||||||
|
|
||||||
Configuration fields and defaults are in [Configuration](../config.md).
|
Configuration fields and defaults are in [Configuration](../config.md).
|
||||||
Operator paths, rerun procedures, and bundle retention are in
|
Operator paths, rerun procedures, and bundle retention are in
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ Adapters do not own:
|
|||||||
- Object store only when required by selected stages/config.
|
- Object store only when required by selected stages/config.
|
||||||
|
|
||||||
Notarius is composed only when extraction is enabled; the extract stage owns
|
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
|
Object-store construction goes through `newCommandObjectStore`, which loads
|
||||||
configured filesystem secrets before adapter initialization.
|
configured filesystem secrets before adapter initialization.
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ unrecognized token into a valid source. Extraction sources are registered only
|
|||||||
from `pipeline.notarius.outputs`; the Notarius index has no selectable source
|
from `pipeline.notarius.outputs`; the Notarius index has no selectable source
|
||||||
ID.
|
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
|
## Runtime Catalog
|
||||||
|
|
||||||
`ArtifactCatalog` tracks:
|
`ArtifactCatalog` tracks:
|
||||||
@@ -72,6 +77,15 @@ Configured sources (`narratio.artifact.*`):
|
|||||||
|
|
||||||
- resolve only through runtime catalog availability.
|
- resolve only through runtime catalog availability.
|
||||||
|
|
||||||
|
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.*`):
|
Extraction sources (`narratio.extraction.*`):
|
||||||
|
|
||||||
- use the shared typed bundle evidence inspection in `extraction_evidence.go`;
|
- use the shared typed bundle evidence inspection in `extraction_evidence.go`;
|
||||||
@@ -199,7 +213,8 @@ physical layout.
|
|||||||
`internal/artifacts/catalog.go`, `internal/artifacts/transcripts.go`,
|
`internal/artifacts/catalog.go`, `internal/artifacts/transcripts.go`,
|
||||||
`internal/artifacts/extraction_catalog.go`,
|
`internal/artifacts/extraction_catalog.go`,
|
||||||
`internal/artifacts/extraction_evidence.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`,
|
- Current state: `internal/artifacts/current_state.go`,
|
||||||
`internal/artifacts/current_state_commit.go`,
|
`internal/artifacts/current_state_commit.go`,
|
||||||
`internal/artifacts/current_state_legacy.go`
|
`internal/artifacts/current_state_legacy.go`
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Execute selected configured Scriptorium artifacts in dependency order and materi
|
|||||||
Supported source families:
|
Supported source families:
|
||||||
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`
|
- built-ins: `narratio.transcript.*`, `narratio.bounds.session`
|
||||||
- prepared stable inputs: `narratio.input.players`, `narratio.input.party`,
|
- prepared stable inputs: `narratio.input.players`, `narratio.input.party`,
|
||||||
`narratio.input.glossary`
|
`narratio.input.glossary`, `narratio.input.spell_catalog`
|
||||||
- configured artifacts: `narratio.artifact.<key>`
|
- configured artifacts: `narratio.artifact.<key>`
|
||||||
- extraction lanes: `narratio.extraction.<key>`
|
- extraction lanes: `narratio.extraction.<key>`
|
||||||
- previous-session cache: `narratio.previous_session.artifact.<key>`
|
- previous-session cache: `narratio.previous_session.artifact.<key>`
|
||||||
@@ -42,7 +42,9 @@ Supported source families:
|
|||||||
- validates selected artifact dependency order (cycle-safe topo ordering).
|
- validates selected artifact dependency order (cycle-safe topo ordering).
|
||||||
- resolves required/optional inputs per artifact source definition.
|
- resolves required/optional inputs per artifact source definition.
|
||||||
- omits an unavailable optional input; an unavailable required input fails.
|
- omits an unavailable optional input; an unavailable required input fails.
|
||||||
- resolves prepared stable input sources from `inputs/*.yml` materialized by `prepare`.
|
- 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.
|
||||||
- resolves previous-session sources from local `previous/` cache only.
|
- resolves previous-session sources from local `previous/` cache only.
|
||||||
- runs optional render-debug, then artifact execution.
|
- runs optional render-debug, then artifact execution.
|
||||||
- validates non-empty output files and materializes canonical outputs.
|
- validates non-empty output files and materializes canonical outputs.
|
||||||
|
|||||||
@@ -17,15 +17,21 @@ procedures belong in [Operations](../operations.md).
|
|||||||
`internal/stage/extract.go`:
|
`internal/stage/extract.go`:
|
||||||
|
|
||||||
1. resolves the final trimmed transcript from the shared artifact catalog;
|
1. resolves the final trimmed transcript from the shared artifact catalog;
|
||||||
2. resolves and fingerprints the Notarius invocation contract;
|
2. resolves every configured prepared reference through the shared
|
||||||
3. creates a run-local staging directory and invokes the injected
|
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`;
|
`notarius.Runner`;
|
||||||
4. validates the successful receipt, confined index, configured required lane
|
6. revalidates the reference snapshots, then validates the v2 successful
|
||||||
descriptors, and regular payload files;
|
receipt, confined index, management documents, configured required lane
|
||||||
5. atomically promotes the complete bundle to its immutable durable location;
|
descriptors, validation summaries, and regular payload files;
|
||||||
6. records one non-selectable `notarius_index` output and one selectable
|
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
|
`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.
|
Scriptorium and publish resolution.
|
||||||
|
|
||||||
Lane records retain checksum, contract, producer run ID, and Notarius system,
|
Lane records retain checksum, contract, producer run ID, and Notarius system,
|
||||||
@@ -34,6 +40,12 @@ root, receipt, diagnostic paths, rejection/warning summaries, producing
|
|||||||
Narratio run ID, the resolved trimmed-input identity, and invocation
|
Narratio run ID, the resolved trimmed-input identity, and invocation
|
||||||
fingerprint. The input identity binds the exact transcript bytes, canonical
|
fingerprint. The input identity binds the exact transcript bytes, canonical
|
||||||
source ID, producer stage/output/run identity, and resolution provenance.
|
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
|
Validation completes before
|
||||||
promotion, so a rejected result cannot expose a partial durable bundle.
|
promotion, so a rejected result cannot expose a partial durable bundle.
|
||||||
|
|
||||||
@@ -46,11 +58,16 @@ with no outputs is stable and does not repeatedly invalidate downstream stages.
|
|||||||
`internal/stage/extract_resume.go` permits a skip only when the existing stage
|
`internal/stage/extract_resume.go` permits a skip only when the existing stage
|
||||||
record succeeded and still matches the current invocation fingerprint. The
|
record succeeded and still matches the current invocation fingerprint. The
|
||||||
fingerprint covers the resolved executable and config paths, pipeline ID,
|
fingerprint covers the resolved executable and config paths, pipeline ID,
|
||||||
timeout, working directory, sorted configured output contracts, and the current
|
timeout, working directory, sorted configured output contracts, the current
|
||||||
direct trimmed-transcript identity. The same identity is resolved again for
|
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
|
artifact evidence, so changing the current transcript bytes or producer
|
||||||
identity makes the prior extraction obsolete.
|
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
|
The validator then checks the producing run identity, canonical immutable
|
||||||
bundle root, path confinement and absence of symlink components, receipt
|
bundle root, path confinement and absence of symlink components, receipt
|
||||||
identity, exactly one canonical index, the exact configured source set,
|
identity, exactly one canonical index, the exact configured source set,
|
||||||
@@ -65,8 +82,10 @@ Operators must force extraction after changing any such input.
|
|||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
Adapter startup, timeout, nonzero exit, receipt decoding, path confinement,
|
Adapter startup, timeout, nonzero exit, receipt decoding, path confinement,
|
||||||
index compatibility, required-lane rejection, payload inspection, checksum, or
|
index compatibility, inconsistent warning or diagnostic envelopes,
|
||||||
promotion errors fail the stage through ordinary manifest transition handling.
|
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
|
Stdout receipt and stderr diagnostics remain separate. Downstream stages are
|
||||||
not given selectable extraction sources unless the complete configured result
|
not given selectable extraction sources unless the complete configured result
|
||||||
has passed validation and promotion.
|
has passed validation and promotion.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Materialize canonical current-session inputs before processing stages.
|
|||||||
|
|
||||||
- resolved campaign, session, and pipeline configuration
|
- resolved campaign, session, and pipeline configuration
|
||||||
- stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
|
- stable input files (`speakers`, `autocorrect`, `glossary`, `players`, `party`)
|
||||||
|
- optional spell-catalog overlay
|
||||||
- one resolved local or S3 audio source
|
- one resolved local or S3 audio source
|
||||||
- enabled configured artifact input requirements for previous-session sources
|
- 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/glossary.yml`
|
||||||
- `inputs/players.yml`
|
- `inputs/players.yml`
|
||||||
- `inputs/party.yml`
|
- `inputs/party.yml`
|
||||||
|
- optional `inputs/spell_catalog.json`
|
||||||
- `audio/*.flac`
|
- `audio/*.flac`
|
||||||
- optional `previous/manifest.json`
|
- optional `previous/manifest.json`
|
||||||
- optional `previous/artifacts/**`
|
- 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
|
- gives distinct local source paths with the same basename deterministic unique
|
||||||
prepared filenames so neither source is overwritten.
|
prepared filenames so neither source is overwritten.
|
||||||
- materializes S3 audio through spool/cache-aware logic.
|
- 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.
|
- scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
|
||||||
- clears managed `previous/` state on every invocation, then, when requirements exist:
|
- clears managed `previous/` state on every invocation, then, when requirements exist:
|
||||||
- resolves the pointer-selected previous source through the shared resolver;
|
- resolves the pointer-selected previous source through the shared resolver;
|
||||||
|
|||||||
@@ -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.
|
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
|
## Standard Session Workflow
|
||||||
|
|
||||||
@@ -135,6 +140,31 @@ The directory is immutable once promoted. Configured lanes become
|
|||||||
the bundle and `index.json` are retained for audit and resume validation but
|
the bundle and `index.json` are retained for audit and resume validation but
|
||||||
are not selectable or published implicitly.
|
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
|
Starting a replacement clears the previous extraction payload from the current
|
||||||
session-stage record. If that replacement fails or self-skips, 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
|
record does not fall back to the earlier outputs. The earlier run manifest and
|
||||||
@@ -181,11 +211,12 @@ To intentionally replace the current extraction result, run:
|
|||||||
narratio run-stage extract 2026-04-04 --force
|
narratio run-stage extract 2026-04-04 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
Narratio automatically reruns extraction when its recorded invocation contract
|
Narratio automatically reruns extraction when its recorded invocation contract,
|
||||||
or durable output validation changes. It cannot fingerprint configuration
|
prepared Narratio reference identities, or durable output validation changes.
|
||||||
files, profiles, prompts, modules, or references loaded transitively by
|
It cannot fingerprint configuration files, profiles, prompts, modules, or
|
||||||
Notarius. Force extraction after changing any of those inputs, even when the
|
other references loaded transitively by Notarius itself. Force extraction after
|
||||||
top-level Narratio and Notarius config paths remain the same. A forced extract
|
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
|
marks successful downstream stages stale. Ordinary extraction failures or
|
||||||
outcome changes also stale affected downstream stages, while an identical
|
outcome changes also stale affected downstream stages, while an identical
|
||||||
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
|
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
|
||||||
|
|||||||
546
docs/roadmap/implementation.md
Normal file
546
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
# Notarius v0.6 CLI References Implementation Plan
|
||||||
|
|
||||||
|
## Purpose And Status
|
||||||
|
|
||||||
|
This is the executable implementation plan for the accepted target state in
|
||||||
|
[Notarius v0.6 CLI Reference Integration](notarius-v0.6-cli-references.md). It
|
||||||
|
is written for a `gpt-5.6-terra` coding agent that will implement exactly one
|
||||||
|
pending stage per prompt, in order.
|
||||||
|
|
||||||
|
The feature roadmap owns user intent, architectural boundaries, settled policy,
|
||||||
|
and the target end state. This document owns delivery order, concrete changes,
|
||||||
|
test allocation, and implementation status. Do not restate or change a roadmap
|
||||||
|
decision here during implementation; if current Notarius v0.6.0 evidence
|
||||||
|
contradicts the roadmap, stop and record the conflict instead of inventing a
|
||||||
|
different contract.
|
||||||
|
|
||||||
|
| Stage | Outcome | Status |
|
||||||
|
| ---: | --- | --- |
|
||||||
|
| 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Completed |
|
||||||
|
| 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Completed |
|
||||||
|
| 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Completed |
|
||||||
|
| 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Completed |
|
||||||
|
| 5 | Resolve references in extract and bind fingerprints, resume, and metadata to their identities. | Completed |
|
||||||
|
| 6 | Prove assembled extraction lifecycle and downstream invalidation behavior. | Completed |
|
||||||
|
| 7 | Update canonical documentation and maintained examples for the completed feature. | Completed |
|
||||||
|
| 8 | Perform compatibility, quality, and repository-wide closure validation. | Completed |
|
||||||
|
|
||||||
|
## Governing Decisions
|
||||||
|
|
||||||
|
The following requirements are settled and are not questions for the
|
||||||
|
implementing agent:
|
||||||
|
|
||||||
|
1. `pipeline.notarius.references` is a map whose key is a Notarius v0.6 CLI
|
||||||
|
reference selector and whose value is a prepared Narratio source ID. It is
|
||||||
|
not a map of paths.
|
||||||
|
2. Every configured binding is required. There is no per-entry `required`
|
||||||
|
field. An optional Notarius reference is omitted by omitting the map entry.
|
||||||
|
An empty or omitted map remains valid for custom pipelines and backward
|
||||||
|
compatibility.
|
||||||
|
The map is limited by the centrally declared configuration constant
|
||||||
|
`MaxNotariusReferenceBindings = 256`, which is far above the four-entry
|
||||||
|
maintained D&D case while bounding argv and manifest growth.
|
||||||
|
3. The supported prepared reference sources are
|
||||||
|
`narratio.input.party`, `narratio.input.players`,
|
||||||
|
`narratio.input.glossary`, and `narratio.input.spell_catalog`.
|
||||||
|
Arbitrary Notarius slot names and qualified selectors may bind those sources;
|
||||||
|
direct paths and later-stage artifacts may not.
|
||||||
|
4. Campaign and session `spell_catalog_file` are optional. A session value
|
||||||
|
overrides the campaign value; an empty session value inherits the campaign
|
||||||
|
value. A `narratio.input.spell_catalog` reference binding requires an
|
||||||
|
effective configured file.
|
||||||
|
5. Prepared sources are authoritative only when the current session manifest
|
||||||
|
records the matching canonical input and checksum. Consumers do not fall
|
||||||
|
back to campaign/session source paths or accept an incidental workspace file.
|
||||||
|
6. Narratio passes absolute prepared-file paths to Notarius. Fingerprints and
|
||||||
|
metadata use the canonical workspace-relative path identity together with
|
||||||
|
selector, source ID, SHA-256 checksum, and size so workspace relocation does
|
||||||
|
not become the only identity signal.
|
||||||
|
7. External CLI references are sorted by normalized selector. They override
|
||||||
|
matching external references in the Notarius configuration. Narratio never
|
||||||
|
emits `--without-reference`, the deprecated `roster` alias, or CLI bindings
|
||||||
|
for generated D&D artifact handoffs.
|
||||||
|
8. Notarius remains authoritative for whether a selected target declares a
|
||||||
|
slot, accepted reference media types and sizes, generated-handoff collisions,
|
||||||
|
pipeline topology, and D&D payload schemas. Narratio validates selector
|
||||||
|
structure and its own source contract only.
|
||||||
|
9. Notarius v0.6.0 is the minimum supported CLI contract when references are
|
||||||
|
configured. Do not add version-string parsing or an automatic per-session
|
||||||
|
`notarius config validate` subprocess.
|
||||||
|
10. The existing receipt-v2, bundle-confinement, diagnostic, ten-lane selection,
|
||||||
|
immutable promotion, and analysis-source behavior must remain intact.
|
||||||
|
11. The default test suite remains offline, deterministic, and independent of
|
||||||
|
a sibling checkout or installed Notarius binary. A real v0.6.0 smoke run is
|
||||||
|
useful supplementary evidence when locally available, not a default-suite
|
||||||
|
dependency.
|
||||||
|
12. Add no external Go dependency for this feature. Use narrow owner-specific
|
||||||
|
types and existing file, path, artifact, manifest, adapter, and stage
|
||||||
|
facilities.
|
||||||
|
|
||||||
|
## Instructions For Every Stage
|
||||||
|
|
||||||
|
For each implementation prompt, the coding agent must:
|
||||||
|
|
||||||
|
1. Read `docs/development.md`, all three files under `docs/policy/`, the feature
|
||||||
|
roadmap, this plan, and the stage-specific documents and source named below.
|
||||||
|
Inspect the current tree because earlier stages may have changed names or
|
||||||
|
ownership boundaries.
|
||||||
|
2. Use the repository knowledge graph first for code discovery and call tracing;
|
||||||
|
use text search for documentation, configuration, examples, string literals,
|
||||||
|
and evidence the graph cannot supply.
|
||||||
|
3. Confirm the worktree state and preserve unrelated changes. Implement only the
|
||||||
|
current stage. Do not begin a later stage merely because an adjacent file is
|
||||||
|
open.
|
||||||
|
4. Keep production code, focused tests, fakes, and fixtures consistent within
|
||||||
|
the stage. Remove superseded helpers when their final caller migrates. Do not
|
||||||
|
retain two competing source maps, path resolvers, fingerprint paths, or
|
||||||
|
subprocess argument builders.
|
||||||
|
5. Follow the testing policy's ownership rule. Parser/config tests own selector
|
||||||
|
and configuration cases; artifact tests own prepared-file identity and
|
||||||
|
integrity; adapter tests own exact arguments; stage tests own orchestration and
|
||||||
|
resume; application tests own lifecycle invalidation. Do not repeat every
|
||||||
|
lower-level case at higher levels.
|
||||||
|
6. Keep errors actionable and content-free. They may identify a selector,
|
||||||
|
Narratio source ID, canonical path, or checksum mismatch, but must not include
|
||||||
|
reference contents. Preserve ordinary group-workspace permissions and
|
||||||
|
restrictive API-key handling.
|
||||||
|
7. Run `gofmt` on changed Go files and focused tests while iterating. Before
|
||||||
|
marking any stage complete, run at minimum:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
go test ./internal/doccheck
|
||||||
|
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||||
|
```
|
||||||
|
|
||||||
|
Default tests must not contact live services or require credentials.
|
||||||
|
8. Compare the final diff against the stage goal and exit criteria. Update only
|
||||||
|
the current stage's status row from `Pending` to `Completed`. Do not mark a
|
||||||
|
stage complete while a required check fails or required behavior is absent.
|
||||||
|
Intermediate commits are implementation-branch state and must not be released
|
||||||
|
before Stage 7 has reconciled current-behavior documentation.
|
||||||
|
|
||||||
|
## Stage 1 — Reference And Configuration Vocabulary
|
||||||
|
|
||||||
|
**Read first:** `docs/config.md`, `docs/integrations/notarius.md`,
|
||||||
|
`internal/config/config.go`, `internal/config/defaults.go`,
|
||||||
|
`internal/config/load.go`, `internal/config/validate.go`,
|
||||||
|
`internal/config/notarius_test.go`, `internal/config/campaign_config_test.go`,
|
||||||
|
and `internal/artifactpolicy/policy.go` and its tests. Read the tagged Notarius
|
||||||
|
v0.6.0 `docs/cli.md` reference-selector section from `../notarius` when that
|
||||||
|
checkout is available; otherwise use the canonical link from the feature
|
||||||
|
roadmap.
|
||||||
|
|
||||||
|
**Depends on:** None.
|
||||||
|
|
||||||
|
**Goal:** Establish one normalized reference-selector grammar and the strict
|
||||||
|
configuration model needed by later stages, without adding path-valued Notarius
|
||||||
|
configuration or making spell catalogs mandatory for every campaign.
|
||||||
|
|
||||||
|
**Work:**
|
||||||
|
|
||||||
|
- Add a small dependency-free `internal/notariusref` package as the contract
|
||||||
|
owner for Notarius reference selector normalization. Its exported normalizer
|
||||||
|
must trim the selector and each component, reject empty components and `=`,
|
||||||
|
and accept only the v0.6 forms `slot`,
|
||||||
|
`chunk.slot`, `lane.slot`, `lane.extract.slot`, `lane.merge.slot`, and
|
||||||
|
`lane.normalize.slot`. A three-component selector accepts only `extract`,
|
||||||
|
`merge`, or `normalize` in its middle component. Do not check the selector
|
||||||
|
against a Notarius module or lane registry.
|
||||||
|
- Add `References map[string]string` with YAML key `references` to
|
||||||
|
`config.NotariusConfig`. During enabled Notarius validation, sort raw keys,
|
||||||
|
normalize each selector through the shared contract, trim each source value,
|
||||||
|
reject empty values and normalized-selector collisions, require each value to
|
||||||
|
be one of the four prepared reference sources, and replace the config map with
|
||||||
|
its normalized form. Enforce the named, centrally discoverable
|
||||||
|
`config.MaxNotariusReferenceBindings` limit of 256 entries with an error that
|
||||||
|
identifies the field and limit. Keep a nil/empty map valid. Rely on strict YAML
|
||||||
|
decoding to reject duplicate identical keys, but explicitly reject distinct
|
||||||
|
raw keys that normalize to one selector.
|
||||||
|
- Add `SpellCatalogFile string` with YAML key `spell_catalog_file` to campaign
|
||||||
|
inputs and session inputs, plus `SpellCatalogFile ResolvedInputFile` to
|
||||||
|
resolved stable inputs. Merge it with the existing session-over-campaign
|
||||||
|
helper. It is not part of the campaign-required input set. Reject a non-empty
|
||||||
|
configured scalar that becomes empty after trimming.
|
||||||
|
- Add `artifactpolicy.SourceInputSpellCatalog` and make the artifact-policy
|
||||||
|
owner describe all four prepared reference sources, including their canonical
|
||||||
|
manifest kind and filename. Use that owner for stable-source recognition
|
||||||
|
instead of adding a second switch in configuration validation. Preserve the
|
||||||
|
existing three source IDs and their behavior.
|
||||||
|
- Extend cross-configuration validation so a normalized reference to
|
||||||
|
`narratio.input.spell_catalog` requires a non-empty effective resolved
|
||||||
|
`spell_catalog_file`. Existing campaign requirements already guarantee party,
|
||||||
|
players, and glossary declarations. Do not check filesystem existence during
|
||||||
|
configuration validation.
|
||||||
|
- If analyze's current private filename switch must change to keep the tree
|
||||||
|
behaviorally coherent, make it delegate to the artifact-policy descriptor and
|
||||||
|
recognize spell catalog; Stage 3 will replace the filesystem-only resolver.
|
||||||
|
|
||||||
|
**Tests and exit criteria:** At the contract/config owners, cover every accepted
|
||||||
|
selector shape; zero, empty, excess, invalid-stage, and `=` forms; whitespace
|
||||||
|
normalization; normalized collisions; unsupported and empty source IDs; nil and
|
||||||
|
empty maps; exactly the configured binding limit and limit plus one; strict
|
||||||
|
unknown fields; campaign inheritance and session override; optional omission;
|
||||||
|
and the cross-config missing-spell-catalog failure. Prefer table-driven parser
|
||||||
|
and validator tests over assertions against private helper structure. Existing
|
||||||
|
configuration and example tests must still pass without adding spell catalogs
|
||||||
|
to every campaign. The codebase has one selector grammar owner and one
|
||||||
|
prepared-source descriptor owner.
|
||||||
|
|
||||||
|
## Stage 2 — Spell Catalog Prepare And Operator Lifecycle
|
||||||
|
|
||||||
|
**Read first:** `docs/internal/stage-prepare.md`, `docs/internal/workspace.md`,
|
||||||
|
`docs/operations.md`, `internal/stage/prepare.go` and its tests,
|
||||||
|
`internal/app/operator_inspection.go`, `internal/app/operator_findings.go` and
|
||||||
|
their focused tests, `internal/manifest/manifest.go`, and the relevant confined
|
||||||
|
file-operation helpers.
|
||||||
|
|
||||||
|
**Depends on:** Stage 1.
|
||||||
|
|
||||||
|
**Goal:** Make the effective optional spell catalog a normal prepared session
|
||||||
|
input with canonical storage, checksum/provenance, safe stale-file handling, and
|
||||||
|
operator visibility.
|
||||||
|
|
||||||
|
**Work:**
|
||||||
|
|
||||||
|
- Resolve `StableInputs.SpellCatalogFile` with the same origin-preserving
|
||||||
|
campaign/session behavior as the five existing stable inputs. When configured,
|
||||||
|
require a regular readable source, copy it atomically to
|
||||||
|
`inputs/spell_catalog.json`, preserve ordinary workspace permissions, and add
|
||||||
|
one manifest input record with kind `spell_catalog`, canonical destination,
|
||||||
|
checksum, and `campaign_config` or `session_config` source provenance.
|
||||||
|
- Treat the input as optional when no effective path is configured. Do not call
|
||||||
|
the required-input path resolver with an empty value and do not create a
|
||||||
|
manifest record. Remove an obsolete canonical `inputs/spell_catalog.json`
|
||||||
|
without following it when a forced prepare transitions from configured to
|
||||||
|
absent; refuse to recursively remove a directory or other ambiguous object at
|
||||||
|
that exact file path.
|
||||||
|
- Include a configured spell catalog in operator inspection and validation
|
||||||
|
findings. Omission is not an error unless Stage 1 cross-configuration policy
|
||||||
|
says the enabled Notarius reference requires it. Reuse the common resolved
|
||||||
|
stable-input enumeration where practical instead of extending parallel
|
||||||
|
hand-written lists in several functions.
|
||||||
|
- Adjust input-slice capacity, deterministic ordering, test fixtures, and any
|
||||||
|
manifest assumptions affected by the optional sixth stable file. Do not parse
|
||||||
|
or schema-validate the JSON payload in Narratio; Notarius owns that contract.
|
||||||
|
|
||||||
|
**Tests and exit criteria:** Through prepare and operator package behavior, cover
|
||||||
|
campaign and session source provenance, canonical destination bytes and
|
||||||
|
checksum, optional omission, missing configured source, replacement after source
|
||||||
|
change, safe removal when configuration is removed, rejection of an ambiguous
|
||||||
|
destination object, deterministic manifest ordering, and operator reporting.
|
||||||
|
Do not duplicate selector-validation cases from Stage 1. Existing sessions with
|
||||||
|
no spell catalog remain valid and produce no stale manifest entry.
|
||||||
|
|
||||||
|
## Stage 3 — Manifest-Authoritative Prepared Input Resolution
|
||||||
|
|
||||||
|
**Read first:** `docs/internal/artifacts.md`, `docs/internal/manifest.md`,
|
||||||
|
`docs/internal/stage-analyze.md`, `internal/artifacts/artifact_resolver.go`,
|
||||||
|
`internal/artifacts/resolve.go`, `internal/artifacts/checksum.go`, their tests,
|
||||||
|
and the prepared stable-input resolution path in `internal/stage/analyze.go` and
|
||||||
|
`internal/stage/analyze_test.go`.
|
||||||
|
|
||||||
|
**Depends on:** Stage 2.
|
||||||
|
|
||||||
|
**Goal:** Give analyze and extract one integrity-checked resolver for prepared
|
||||||
|
stable sources so neither stage trusts incidental files or reconstructs its own
|
||||||
|
source-to-filename table.
|
||||||
|
|
||||||
|
**Work:**
|
||||||
|
|
||||||
|
- Add an artifacts-owned `PreparedInputIdentity` contract containing source ID,
|
||||||
|
manifest kind, absolute canonical path, slash-separated path relative to the
|
||||||
|
session root, SHA-256 checksum, and byte size. Add one resolver that accepts
|
||||||
|
session paths, the current session manifest, and a stable source ID. Provide a
|
||||||
|
typed or sentinel absence classification so callers can distinguish no current
|
||||||
|
manifest record from corrupt or unsafe recorded evidence.
|
||||||
|
- Derive kind and filename exclusively from the artifact-policy descriptor. The
|
||||||
|
resolver must require exactly one current manifest input record with the
|
||||||
|
expected kind and canonical path; resolve/rebase recorded local paths through
|
||||||
|
existing session-local path safety helpers; require the result to equal the
|
||||||
|
canonical file below `inputs/`; reject escapes, symlinks, non-regular files,
|
||||||
|
empty files, missing checksums, duplicate records, and checksum mismatches; and
|
||||||
|
calculate size without loading the complete file into memory. Do not fall back
|
||||||
|
to the configured campaign/session path or accept canonical file presence
|
||||||
|
without manifest evidence. Zero matching manifest records is the typed absent
|
||||||
|
case; once a record exists, a missing or invalid file is an integrity error,
|
||||||
|
not optional absence.
|
||||||
|
- Return owner-neutral errors from `internal/artifacts`. At stage boundaries,
|
||||||
|
wrap unavailable or stale prepared inputs with the source ID and actionable
|
||||||
|
`narratio run-stage prepare <session_id> --force` guidance. Do not include file
|
||||||
|
contents.
|
||||||
|
- Replace analyze's private prepared-source filename switch and filesystem-only
|
||||||
|
resolver with the shared artifact resolver. Preserve required-versus-optional
|
||||||
|
Scriptorium input behavior: a typed absent optional source is omitted, an
|
||||||
|
absent required source fails with prepare guidance, and invalid recorded
|
||||||
|
evidence fails regardless of optionality. Make `narratio.input.spell_catalog`
|
||||||
|
usable wherever another prepared Scriptorium source is accepted.
|
||||||
|
|
||||||
|
**Tests and exit criteria:** Artifact-package tests own valid resolution and the
|
||||||
|
missing-record, duplicate-record, wrong-kind/path, traversal/rebase, symlink,
|
||||||
|
non-regular, empty, missing-checksum, and checksum-mismatch boundaries. Analyze
|
||||||
|
tests need only prove required/optional stage behavior and successful use of the
|
||||||
|
shared source, including spell catalog; do not repeat the artifact resolver's
|
||||||
|
full matrix. Remove the old filename/path resolver after its final caller moves.
|
||||||
|
|
||||||
|
## Stage 4 — Notarius Adapter Reference Arguments
|
||||||
|
|
||||||
|
**Read first:** `docs/internal/adapters.md`, `docs/integrations/notarius.md`,
|
||||||
|
`internal/adapters/notarius/runner.go`, `fake.go`, `subprocess.go`, and focused
|
||||||
|
adapter tests. Re-read the Notarius v0.6.0 subprocess and CLI reference-selector
|
||||||
|
contracts from the tagged sibling checkout when available.
|
||||||
|
|
||||||
|
**Depends on:** Stage 1.
|
||||||
|
|
||||||
|
**Goal:** Extend the transport-neutral Notarius request and production adapter
|
||||||
|
to emit safe, exact, repeatable v0.6 `--reference` arguments without changing
|
||||||
|
receipt or bundle ingestion.
|
||||||
|
|
||||||
|
**Work:**
|
||||||
|
|
||||||
|
- Add a transport-neutral reference binding containing normalized selector and
|
||||||
|
absolute path, and add an ordered slice of those bindings to `RunRequest`.
|
||||||
|
Keep source IDs and manifest identities out of the adapter contract; those are
|
||||||
|
stage policy.
|
||||||
|
- Validate each adapter binding before process launch: normalize/validate the
|
||||||
|
selector through the shared Stage 1 contract, require a non-empty absolute
|
||||||
|
path, reject duplicate normalized selectors, and avoid mutating the caller's
|
||||||
|
slice. Do not open or parse the reference file in the adapter.
|
||||||
|
- Build arguments as repeated pairs `--reference`,
|
||||||
|
`<normalized-selector>=<absolute-path>` after `--output-dir` and before
|
||||||
|
`--json`. Preserve one argument for the combined selector/path value so spaces,
|
||||||
|
additional `=` characters within the path portion, and platform separators do
|
||||||
|
not involve shell interpretation. The request order is authoritative; Stage 5
|
||||||
|
will supply sorted bindings.
|
||||||
|
- Preserve current executable, environment, timeout, cancellation, diagnostic,
|
||||||
|
receipt-v2, bounded-read, confinement, and bundle-discovery behavior. Do not
|
||||||
|
add `--without-reference`, generated reference arguments, version probing, or
|
||||||
|
configuration preflight.
|
||||||
|
- Update the fake only as required to retain and expose the extended request.
|
||||||
|
|
||||||
|
**Tests and exit criteria:** Adapter tests own exact argv with zero and multiple
|
||||||
|
references, position before `--json`, spaces and `=` in paths, selector
|
||||||
|
normalization, duplicate/invalid selector rejection, relative/empty path
|
||||||
|
rejection, and no subprocess start after request-validation failure. Existing
|
||||||
|
receipt-v2 and bundle fixture tests must remain unchanged in meaning and pass.
|
||||||
|
Do not assert stage-level source sorting here beyond preserving the request
|
||||||
|
order.
|
||||||
|
|
||||||
|
## Stage 5 — Extract Reference Identity, Invocation, And Resume
|
||||||
|
|
||||||
|
**Read first:** `docs/internal/stage-extract.md`,
|
||||||
|
`docs/integrations/notarius.md`, `docs/internal/manifest.md`,
|
||||||
|
`internal/stage/extract.go`, `internal/stage/extract_resume.go`, their focused
|
||||||
|
tests, the Stage 3 prepared-input identity contract, and the Stage 4 Notarius
|
||||||
|
request contract.
|
||||||
|
|
||||||
|
**Depends on:** Stages 3 and 4.
|
||||||
|
|
||||||
|
**Goal:** Make configured references part of the actual extraction invocation
|
||||||
|
and durable reuse contract, using one resolution path for initial execution and
|
||||||
|
resume validation.
|
||||||
|
|
||||||
|
**Work:**
|
||||||
|
|
||||||
|
- Add one extract-owned reference-resolution helper used by both `Run` and
|
||||||
|
`ValidateResume`. Iterate normalized config bindings in lexical selector
|
||||||
|
order, resolve each source through the Stage 3 manifest-authoritative resolver,
|
||||||
|
and produce both adapter bindings and immutable reference identities. Resolve
|
||||||
|
every reference before creating run-local receipt, log, output, or promotion
|
||||||
|
directories and before invoking the adapter.
|
||||||
|
- Define the fingerprint/metadata identity as normalized selector, source ID,
|
||||||
|
canonical session-relative slash path, SHA-256 checksum, and byte size. Do not
|
||||||
|
include contents or original campaign/session absolute paths. Pass only
|
||||||
|
selector and absolute prepared path to the adapter.
|
||||||
|
- Extend the extraction fingerprint document with the sorted reference
|
||||||
|
identities. Keep all existing binary, config path, pipeline, timeout, working
|
||||||
|
directory, trimmed-transcript identity, and required-output identities. The
|
||||||
|
result must be independent of YAML map iteration order and must change for a
|
||||||
|
selector, source, relative path, checksum, or size change.
|
||||||
|
- Persist `reference_count` and a bounded deterministic `references` metadata
|
||||||
|
list on successful extraction. Each entry contains exactly `selector`,
|
||||||
|
`source_id`, `path`, `checksum`, and `size_bytes`. Empty bindings produce count
|
||||||
|
zero and an empty list. Do not duplicate Notarius reference payloads or
|
||||||
|
downstream error messages.
|
||||||
|
- Make resume recompute current reference identities through the same helper
|
||||||
|
before comparing the configuration fingerprint. A valid changed prepared
|
||||||
|
input yields a fingerprint mismatch and a non-resumable result so extraction
|
||||||
|
reruns. Missing, unsafe, or checksum-inconsistent current input is an error
|
||||||
|
with prepare-force guidance because immediately rerunning extract cannot
|
||||||
|
succeed. Do not silently reuse the old bundle.
|
||||||
|
- Preserve explicit disabled-stage skip without resolving references. Preserve
|
||||||
|
required lane selection, immutable promotion, receipt identity, and bundle
|
||||||
|
evidence behavior.
|
||||||
|
|
||||||
|
**Tests and exit criteria:** Stage tests own sorted request construction for all
|
||||||
|
four D&D bindings, zero bindings, failure before adapter invocation for an
|
||||||
|
unavailable source, content-free metadata, and fingerprint changes for each
|
||||||
|
identity field while remaining stable across map order. Resume tests must prove
|
||||||
|
reuse with unchanged references, non-reuse after a valid prepared-reference
|
||||||
|
change, hard failure for missing or checksum-invalid current evidence, and no
|
||||||
|
reference resolution when disabled. Use the fake adapter; do not duplicate exact
|
||||||
|
subprocess argv cases from Stage 4.
|
||||||
|
|
||||||
|
## Stage 6 — Assembled Lifecycle And Invalidation Coverage
|
||||||
|
|
||||||
|
**Read first:** `docs/internal/overview.md`, `docs/internal/manifest.md`,
|
||||||
|
`docs/internal/stage-extract.md`, `docs/internal/stage-prepare.md`,
|
||||||
|
`internal/app/runner.go`,
|
||||||
|
`internal/app/extract_lifecycle_test.go`, and representative full pipeline and
|
||||||
|
stage fixtures. Inspect existing downstream invalidation tests before adding
|
||||||
|
new cases.
|
||||||
|
|
||||||
|
**Depends on:** Stage 5.
|
||||||
|
|
||||||
|
**Goal:** Prove at the application boundary that prepared campaign context
|
||||||
|
reaches Notarius and that reference changes cannot leave extraction or later
|
||||||
|
analysis falsely current.
|
||||||
|
|
||||||
|
**Work:**
|
||||||
|
|
||||||
|
- Extend the smallest existing assembled runner fixture to execute prepare and
|
||||||
|
extract with party, players, glossary, and spell catalog bindings. Assert that
|
||||||
|
the fake Notarius request receives the four canonical prepared absolute paths,
|
||||||
|
not the original campaign/session source paths, and that the successful
|
||||||
|
manifest records bounded reference identity.
|
||||||
|
- Add one lifecycle regression covering a valid reference-content change:
|
||||||
|
rerun/force prepare so the manifest and prepared checksum change, then verify
|
||||||
|
extract resume is rejected, Notarius runs again, and succeeded canonical
|
||||||
|
downstream stages are invalidated according to the existing stage-order
|
||||||
|
policy. Assert outcomes, not private runner call choreography.
|
||||||
|
- Add one representative session override case to prove the overridden prepared
|
||||||
|
bytes/checksum reach extraction. Do not repeat all four configuration merge
|
||||||
|
cases or artifact-integrity failures already owned by earlier stages.
|
||||||
|
- Confirm an empty reference map preserves the pre-v0.6 invocation behavior and
|
||||||
|
that all ten configured D&D lanes remain registered as the same
|
||||||
|
`narratio.extraction.<key>` sources available to analyze.
|
||||||
|
- Fix production integration defects exposed by these assembled tests without
|
||||||
|
broadening the feature or adding a DAG, generic reference workflow, or direct
|
||||||
|
Notarius payload parsing.
|
||||||
|
|
||||||
|
**Tests and exit criteria:** The application-level tests must be deterministic,
|
||||||
|
offline, and fake only the external Notarius boundary. They must credibly fail
|
||||||
|
if Narratio passes original paths, omits one configured reference, reuses stale
|
||||||
|
extraction, or loses a configured lane, while remaining insensitive to private
|
||||||
|
helper structure and exact non-contractual diagnostics. Earlier focused suites
|
||||||
|
and the repository baseline remain green.
|
||||||
|
|
||||||
|
## Stage 7 — Canonical Documentation And Maintained Examples
|
||||||
|
|
||||||
|
**Read first:** `docs/policy/documentation.md`, `docs/config.md`,
|
||||||
|
`docs/operations.md`, `docs/troubleshooting.md`,
|
||||||
|
`docs/integrations/notarius.md`, `docs/internal/overview.md`,
|
||||||
|
`docs/internal/adapters.md`, `docs/internal/artifacts.md`,
|
||||||
|
`docs/internal/stage-prepare.md`, `docs/internal/stage-extract.md`,
|
||||||
|
`docs/internal/stage-analyze.md`, `examples/README.md`, and all maintained
|
||||||
|
pipeline, campaign, and session examples affected by the new fields.
|
||||||
|
|
||||||
|
**Depends on:** Stage 6.
|
||||||
|
|
||||||
|
**Goal:** Move the completed behavior from roadmap-only future state into its
|
||||||
|
canonical current-behavior owners and provide valid copyable D&D examples
|
||||||
|
without duplicating volatile Notarius contracts.
|
||||||
|
|
||||||
|
**Work:**
|
||||||
|
|
||||||
|
- Update `docs/config.md` with `pipeline.notarius.references`, its selector-to-
|
||||||
|
source shape, normalization/validation rules, required-by-presence behavior,
|
||||||
|
supported stable source IDs, and campaign/session `spell_catalog_file`
|
||||||
|
precedence and optionality. Keep complete copyable YAML in `examples/`.
|
||||||
|
- Update `docs/integrations/notarius.md` to the v0.6.0 baseline and exact
|
||||||
|
repeatable-reference invocation boundary. Explain absolute CLI paths,
|
||||||
|
precedence over configured external paths, the four maintained external D&D
|
||||||
|
slots, the generated-handoff exclusion, and unchanged receipt-v2/ten-lane
|
||||||
|
output compatibility. Link to Notarius's canonical v0.6 CLI and D&D consumer
|
||||||
|
docs instead of copying its target/module matrix.
|
||||||
|
- Update operations and troubleshooting with prepared input location,
|
||||||
|
fingerprint/rerun consequences, operator inspection, missing-reference
|
||||||
|
diagnosis, and Notarius undeclared-slot/generated-collision failures. Update
|
||||||
|
internal component documents only with implemented ownership and flow; do not
|
||||||
|
duplicate configuration field definitions there.
|
||||||
|
- Add a valid, secret-free sample spell catalog following Notarius v0.6's
|
||||||
|
published overlay schema, add `spell_catalog_file` to the sample campaign, and
|
||||||
|
configure all four external reference bindings in the complete annotated D&D
|
||||||
|
pipeline. Add the same bindings to other Notarius-enabled maintained examples
|
||||||
|
only when their selected pipeline declares them; do not add a Notarius section
|
||||||
|
to examples that intentionally omit extraction.
|
||||||
|
- Ensure the maintained command snippets place repeated `--reference` arguments
|
||||||
|
before `--json`, use `party` rather than `roster`, and never show generated
|
||||||
|
handoffs on the CLI. Remove stale v0.5 compatibility wording where it refers to
|
||||||
|
the supported invocation baseline.
|
||||||
|
|
||||||
|
**Tests and exit criteria:** Run documentation-link checks and the example
|
||||||
|
loader explicitly. Verify every changed example is accepted by strict config
|
||||||
|
validation, contains no credentials or private infrastructure values, and has
|
||||||
|
one canonical owner for each volatile fact. Search current-behavior docs and
|
||||||
|
examples for stale v0.5 invocation wording, deprecated `roster` emission, and
|
||||||
|
generated D&D CLI handoff examples. Do not mark the roadmap itself implemented;
|
||||||
|
its status remains target-state context until the implementation sprint is
|
||||||
|
reviewed and closed.
|
||||||
|
|
||||||
|
## Stage 8 — Compatibility And Quality Closure
|
||||||
|
|
||||||
|
**Read first:** The feature roadmap, every completed stage diff, the final
|
||||||
|
current-behavior docs, `.woodpecker/verify.yml`, `.woodpecker/release.yml`, and
|
||||||
|
`.woodpecker/shuffle.yml`. Re-read the tagged Notarius v0.6.0
|
||||||
|
`docs/consumers/dnd-pipeline.md`, `docs/cli.md`, and linked spell-catalog overlay
|
||||||
|
contract when the sibling checkout is available.
|
||||||
|
|
||||||
|
**Depends on:** Stage 7.
|
||||||
|
|
||||||
|
**Goal:** Verify the delivered code matches the accepted boundary, remains
|
||||||
|
compatible with all ten default D&D artifacts, and is ready for review without
|
||||||
|
dead compatibility paths or duplicated policy.
|
||||||
|
|
||||||
|
**Work:**
|
||||||
|
|
||||||
|
- Audit the final diff against every target-state and out-of-scope statement in
|
||||||
|
the feature roadmap. Confirm only four external prepared sources are exposed,
|
||||||
|
custom selectors remain possible, every configured binding is required, and
|
||||||
|
no Notarius pipeline topology or generated-handoff logic moved into Narratio.
|
||||||
|
- Trace initial extract and resume paths to confirm both use the same prepared
|
||||||
|
identity and reference resolution, the adapter is the sole argv builder, and
|
||||||
|
artifact policy is the sole source-to-kind/filename vocabulary. Remove dead
|
||||||
|
helpers, redundant switches, stale fixtures, and low-value duplicate tests
|
||||||
|
found during this review.
|
||||||
|
- Confirm the complete D&D example still declares and validates the exact ten
|
||||||
|
output lanes and that analyze can consume those sources after reference-
|
||||||
|
enabled extraction. Confirm empty-reference custom pipelines remain supported.
|
||||||
|
- If a local Notarius v0.6.0 binary and its required offline/test configuration
|
||||||
|
are already available, perform a non-credentialed smoke invocation with all
|
||||||
|
four reference flags and record the result in the implementation handoff. Do
|
||||||
|
not download tools, contact paid providers, add a default test dependency, or
|
||||||
|
block completion solely because this supplementary environment is absent.
|
||||||
|
- Run the repository baseline plus the scheduled shuffled suite and release
|
||||||
|
cross-build commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
go test -race ./...
|
||||||
|
go test -race -shuffle=on -count=3 ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
go test ./internal/doccheck
|
||||||
|
go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||||
|
narratio_cross_dir="$(mktemp -d)"
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-linux-amd64" ./cmd/narratio
|
||||||
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-darwin-amd64" ./cmd/narratio
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o "$narratio_cross_dir/narratio-windows-amd64.exe" ./cmd/narratio
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-builds are compilation evidence only; do not claim native macOS or
|
||||||
|
Windows runtime validation.
|
||||||
|
|
||||||
|
**Tests and exit criteria:** Every required command passes, `git diff --check`
|
||||||
|
is clean, the worktree contains no unintended generated test artifacts, and the
|
||||||
|
implementation is traceably complete against the roadmap. Summarize any
|
||||||
|
unavailable supplementary smoke evidence without treating it as a product
|
||||||
|
question or silently weakening the default suite.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None. The feature roadmap and governing decisions above are sufficient to
|
||||||
|
implement the plan without additional product or architecture choices.
|
||||||
274
docs/roadmap/notarius-v0.6-cli-references.md
Normal file
274
docs/roadmap/notarius-v0.6-cli-references.md
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
# Notarius v0.6 CLI Reference Integration
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted target state. Delivery sequencing and implementation status are owned
|
||||||
|
by [implementation.md](implementation.md).
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Upgrade Narratio's extraction boundary to the Notarius v0.6.0 subprocess
|
||||||
|
contract and supply session reference documents explicitly with repeatable
|
||||||
|
`--reference selector=path` arguments.
|
||||||
|
|
||||||
|
The maintained D&D integration must make the prepared party roster, player
|
||||||
|
context, glossary, and optional spell catalog available to every compatible
|
||||||
|
Notarius target. Notarius must continue to own pipeline topology, reference-slot
|
||||||
|
compatibility, generated artifact handoffs, prompts, and D&D schemas. Narratio
|
||||||
|
owns selection and preparation of its external reference files, exact CLI
|
||||||
|
invocation, provenance, and extraction reuse correctness.
|
||||||
|
|
||||||
|
## Current State And Gap
|
||||||
|
|
||||||
|
Narratio currently invokes Notarius as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notarius run <pipeline_id> --config <config_path> --input <transcript> --output-dir <staging_dir> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
The `prepare` stage already materializes campaign/session party, players, and
|
||||||
|
glossary files under the session `inputs/` directory, but `extract` does not
|
||||||
|
pass them to Notarius. Narratio also has no stable spell-catalog input. As a
|
||||||
|
result, a Notarius deployment must duplicate these paths in its own
|
||||||
|
configuration, cannot reliably receive session overrides, and may extract
|
||||||
|
without the same campaign context supplied to Narratio's analysis stage.
|
||||||
|
|
||||||
|
Notarius v0.6.0 makes an unqualified CLI selector pipeline-scoped. For example,
|
||||||
|
`--reference party=/absolute/path/party.yml` supplies the file to every
|
||||||
|
selected target that declares `party`. Scoped selectors remain available for
|
||||||
|
exceptional overrides. CLI paths are resolved from the Notarius process working
|
||||||
|
directory, so subprocess callers are expected to provide absolute paths.
|
||||||
|
|
||||||
|
The v0.6.0 receipt, index, warning, diagnostic, and ten-lane D&D artifact
|
||||||
|
contracts remain compatible with Narratio's current v0.5 integration. This
|
||||||
|
feature changes the invocation and input-provenance contract rather than the
|
||||||
|
accepted output inventory.
|
||||||
|
|
||||||
|
## User Outcome
|
||||||
|
|
||||||
|
With the maintained complete D&D configuration, an operator can declare the
|
||||||
|
campaign reference sources once in Narratio. For each extraction Narratio will:
|
||||||
|
|
||||||
|
1. materialize the effective campaign/session files during `prepare`;
|
||||||
|
2. resolve those prepared files by stable Narratio source ID;
|
||||||
|
3. pass absolute paths for `party`, `players`, `glossary`, and, when configured,
|
||||||
|
`spell_catalog` to Notarius through repeatable CLI arguments;
|
||||||
|
4. fail before launching Notarius when a configured reference is unavailable;
|
||||||
|
5. rerun extraction when a selector, source binding, or reference file changes;
|
||||||
|
and
|
||||||
|
6. retain bounded reference identities and checksums for diagnosis and
|
||||||
|
provenance without copying reference contents into manifest metadata.
|
||||||
|
|
||||||
|
Session-level stable-input overrides must flow through the same mechanism. A
|
||||||
|
custom Notarius pipeline may bind different external slots without requiring a
|
||||||
|
Narratio code change.
|
||||||
|
|
||||||
|
## Chosen Architecture
|
||||||
|
|
||||||
|
### Explicit Reference Bindings
|
||||||
|
|
||||||
|
Extend `pipeline.notarius` with an explicit map from a Notarius CLI selector to
|
||||||
|
a prepared Narratio input source:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
notarius:
|
||||||
|
enabled: true
|
||||||
|
binary: notarius
|
||||||
|
config_path: /usr/local/etc/notarius/config.yml
|
||||||
|
pipeline_id: dnd-session
|
||||||
|
working_directory: /usr/local/etc/notarius
|
||||||
|
references:
|
||||||
|
party: narratio.input.party
|
||||||
|
players: narratio.input.players
|
||||||
|
glossary: narratio.input.glossary
|
||||||
|
spell_catalog: narratio.input.spell_catalog
|
||||||
|
outputs:
|
||||||
|
# Existing required lane contracts remain unchanged.
|
||||||
|
```
|
||||||
|
|
||||||
|
Each configured binding is required. An operator who does not maintain an
|
||||||
|
optional Notarius reference, such as a spell catalog, omits that binding. This
|
||||||
|
keeps missing-input behavior explicit and avoids a second required/optional
|
||||||
|
policy inside each entry.
|
||||||
|
|
||||||
|
The maintained complete D&D example will show all four external reference
|
||||||
|
slots. The three existing campaign context bindings use the canonical `party`,
|
||||||
|
`players`, and `glossary` spellings. Narratio will not emit the deprecated
|
||||||
|
`roster` alias.
|
||||||
|
|
||||||
|
The binding is deliberately source-based rather than path-based. Pipeline
|
||||||
|
configuration should not reconstruct session workspace paths or bypass
|
||||||
|
`prepare`; it names the stable input whose effective campaign/session value is
|
||||||
|
already owned by Narratio. The map also avoids hard-coded behavior keyed to the
|
||||||
|
literal `dnd-session` pipeline ID, preserving custom-pipeline support.
|
||||||
|
|
||||||
|
Narratio accepts the selector forms published by Notarius v0.6.0:
|
||||||
|
|
||||||
|
- `slot`;
|
||||||
|
- `chunk.slot`;
|
||||||
|
- `lane.slot`; and
|
||||||
|
- `lane.extract.slot`, `lane.merge.slot`, or `lane.normalize.slot`.
|
||||||
|
|
||||||
|
Configuration validation will reject empty or structurally invalid selectors,
|
||||||
|
selectors containing `=`, unsupported source IDs, and duplicate YAML keys.
|
||||||
|
Notarius remains authoritative for whether a selected target actually declares
|
||||||
|
the slot and whether a file satisfies that slot's media type and size contract.
|
||||||
|
Narratio will not duplicate the Notarius module registry.
|
||||||
|
|
||||||
|
### Stable Reference Inputs
|
||||||
|
|
||||||
|
Continue to use the existing prepared sources and canonical files:
|
||||||
|
|
||||||
|
| Narratio source | Prepared file | Notarius slot |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `narratio.input.party` | `inputs/party.yml` | `party` |
|
||||||
|
| `narratio.input.players` | `inputs/players.yml` | `players` |
|
||||||
|
| `narratio.input.glossary` | `inputs/glossary.yml` | `glossary` |
|
||||||
|
| `narratio.input.spell_catalog` | `inputs/spell_catalog.json` | `spell_catalog` |
|
||||||
|
|
||||||
|
Add optional `spell_catalog_file` fields to campaign and session inputs, with
|
||||||
|
the existing campaign-default/session-override resolution behavior. When
|
||||||
|
provided, `prepare` copies it into the session input area and records its
|
||||||
|
origin and checksum consistently with the other stable inputs. The prepared
|
||||||
|
filename remains JSON so Notarius can apply its published spell-catalog media
|
||||||
|
contract.
|
||||||
|
|
||||||
|
The new source must be added everywhere stable inputs are enumerated: strict
|
||||||
|
configuration decoding and merging, validation, prepare materialization,
|
||||||
|
artifact policy/source descriptions, operator inspection, manifest input
|
||||||
|
records, examples, and canonical documentation. It remains optional at the
|
||||||
|
campaign level; a configured Notarius binding makes it mandatory for that
|
||||||
|
extraction.
|
||||||
|
|
||||||
|
Extract and analyze should use one shared prepared-input source resolver rather
|
||||||
|
than maintain separate source-to-filename tables. The resolver must return an
|
||||||
|
absolute, regular, non-empty file beneath the current session workspace and
|
||||||
|
produce actionable `prepare --force` guidance when a configured source is
|
||||||
|
missing. It must not fall back to the original campaign path after preparation.
|
||||||
|
|
||||||
|
### Adapter Request And CLI Construction
|
||||||
|
|
||||||
|
Extend the transport-neutral Notarius run request with an ordered collection of
|
||||||
|
resolved reference bindings. Each binding contains only its selector and
|
||||||
|
absolute prepared-file path. The extraction stage resolves source IDs and file
|
||||||
|
identity; the subprocess adapter validates and serializes the request.
|
||||||
|
|
||||||
|
The production command becomes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
notarius run <pipeline_id>
|
||||||
|
--config <config_path>
|
||||||
|
--input <trimmed_json>
|
||||||
|
--output-dir <staging_dir>
|
||||||
|
--reference party=<absolute_prepared_party_path>
|
||||||
|
--reference players=<absolute_prepared_players_path>
|
||||||
|
--reference glossary=<absolute_prepared_glossary_path>
|
||||||
|
--reference spell_catalog=<absolute_prepared_spell_catalog_path>
|
||||||
|
--json
|
||||||
|
```
|
||||||
|
|
||||||
|
Only configured bindings are emitted. Selectors are sorted before request
|
||||||
|
construction so argument order, tests, logs, and fingerprints are deterministic.
|
||||||
|
Arguments are passed directly to the subprocess without shell interpretation;
|
||||||
|
paths containing spaces or platform-specific separators remain one argument.
|
||||||
|
|
||||||
|
CLI bindings intentionally override matching external paths in the deployed
|
||||||
|
Notarius configuration. Narratio must not pass `--without-reference` and must
|
||||||
|
not synthesize CLI bindings for `location_registry`, `item_registry`,
|
||||||
|
`npc_registry`, `scene_descriptions`, `combat_turns`, or `npc_occurrences`.
|
||||||
|
Those are generated same-run artifact handoffs in the complete D&D pipeline and
|
||||||
|
remain entirely under Notarius configuration and execution control. A custom
|
||||||
|
configuration that collides an external CLI binding with a generated handoff is
|
||||||
|
expected to fail with Notarius's normal resolution error.
|
||||||
|
|
||||||
|
### Fingerprints, Resume, And Provenance
|
||||||
|
|
||||||
|
Reference identity is part of the extraction input contract. The extraction
|
||||||
|
fingerprint and resume validator must include, in deterministic selector order:
|
||||||
|
|
||||||
|
- the selector;
|
||||||
|
- the configured Narratio source ID;
|
||||||
|
- the resolved prepared path identity; and
|
||||||
|
- the prepared file's content checksum and size.
|
||||||
|
|
||||||
|
This is required even though Notarius generates a prompt session ID from the
|
||||||
|
input module and transcript bytes: Notarius intentionally does not include
|
||||||
|
references in that identifier. Narratio must therefore prevent an old
|
||||||
|
extraction from being reused after a roster, player list, glossary, spell
|
||||||
|
catalog, selector, or source mapping changes.
|
||||||
|
|
||||||
|
A changed reference makes the prior `extract` result non-reusable and follows
|
||||||
|
Narratio's normal downstream invalidation rules. A failed reference-resolution
|
||||||
|
or checksum check also prevents reuse; it must not silently accept the prior
|
||||||
|
bundle.
|
||||||
|
|
||||||
|
Successful extract metadata should record a bounded, deterministic list of
|
||||||
|
selector, source ID, workspace-relative path, checksum, and size. It must not
|
||||||
|
record reference contents, original absolute operator paths, or values from the
|
||||||
|
files. Existing receipt and bundle provenance behavior remains unchanged.
|
||||||
|
|
||||||
|
### Error And Compatibility Behavior
|
||||||
|
|
||||||
|
Narratio's documented minimum supported Notarius version becomes v0.6.0 for an
|
||||||
|
enabled reference binding. Compatibility remains contract-based rather than
|
||||||
|
dependent on parsing `notarius --version`: an older or incompatible executable
|
||||||
|
will fail at the CLI boundary with captured diagnostics.
|
||||||
|
|
||||||
|
Errors must identify the responsible selector and Narratio source without
|
||||||
|
including file contents. Configuration errors are reported before pipeline
|
||||||
|
execution. Missing, empty, non-regular, unsafe, or unreadable prepared files
|
||||||
|
fail extraction before the Notarius subprocess starts. Notarius continues to
|
||||||
|
report undeclared slots, media incompatibility, size limits, required-slot
|
||||||
|
failures, and generated-handoff collisions.
|
||||||
|
|
||||||
|
When Notarius is disabled, extraction retains its current explicit skip
|
||||||
|
behavior and does not resolve reference inputs. Receipt v2 ingestion, bundle
|
||||||
|
confinement, ten-lane selection, and downstream artifact source IDs are not
|
||||||
|
otherwise changed by this feature.
|
||||||
|
|
||||||
|
## Target End State
|
||||||
|
|
||||||
|
Narratio and Notarius have a clear orchestration boundary:
|
||||||
|
|
||||||
|
- `prepare` owns the effective, immutable session copies of external campaign
|
||||||
|
context;
|
||||||
|
- `extract` maps configured stable source IDs to Notarius v0.6 CLI selectors,
|
||||||
|
supplies absolute file paths, and owns reuse/provenance policy;
|
||||||
|
- the Notarius adapter owns exact subprocess serialization and supported result
|
||||||
|
decoding;
|
||||||
|
- Notarius owns slot compatibility, reference precedence within its pipeline,
|
||||||
|
generated artifact handoffs, and output schemas; and
|
||||||
|
- `analyze` consumes the resulting ten structured lane artifacts exactly as it
|
||||||
|
does today.
|
||||||
|
|
||||||
|
The maintained complete D&D workflow passes party, players, glossary, and spell
|
||||||
|
catalog context from the same prepared session inputs used elsewhere in
|
||||||
|
Narratio. Updating any of those documents deterministically causes fresh
|
||||||
|
extraction, and operators can diagnose the effective bindings without exposing
|
||||||
|
file contents.
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
- Reproducing Notarius pipeline, lane, binding, or media-type validation in
|
||||||
|
Narratio.
|
||||||
|
- Passing or overriding Notarius generated artifact handoffs.
|
||||||
|
- Adding `--without-reference`, Notarius resume/recompute controls, lane
|
||||||
|
selection, model selection, profile selection, or session-ID overrides.
|
||||||
|
- Changing the ten accepted D&D lane contracts or the Scriptorium analysis
|
||||||
|
design.
|
||||||
|
- Reading reference payloads into Narratio manifests or logs.
|
||||||
|
- Automatically running `notarius config validate` for every session.
|
||||||
|
|
||||||
|
## Settled Policy Choices
|
||||||
|
|
||||||
|
The implementation must preserve these choices unless implementation evidence
|
||||||
|
shows a contract conflict:
|
||||||
|
|
||||||
|
- explicit selector-to-source mappings are preferred over pipeline-ID-specific
|
||||||
|
defaults;
|
||||||
|
- every configured mapping is required;
|
||||||
|
- `spell_catalog_file` is optional until a mapping requests its prepared
|
||||||
|
source;
|
||||||
|
- the complete D&D example demonstrates all four external references; and
|
||||||
|
- Notarius v0.6.0 is the minimum supported CLI contract for reference-enabled
|
||||||
|
extraction.
|
||||||
@@ -158,6 +158,66 @@ is expected audit state, not a signal to relink the old bundle manually.
|
|||||||
|
|
||||||
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
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
|
## Atomic Notarius promotion unsupported
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
@@ -198,7 +258,7 @@ Safe fix:
|
|||||||
|
|
||||||
- compare installed Notarius output with the canonical Notarius contracts,
|
- compare installed Notarius output with the canonical Notarius contracts,
|
||||||
including receipt `index_file: index.json` and index management names
|
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
|
`pipeline.notarius` constraints and rerun. Do not bypass confinement or schema
|
||||||
checks.
|
checks.
|
||||||
|
|
||||||
@@ -230,6 +290,8 @@ Likely causes:
|
|||||||
|
|
||||||
- the executable/config path, pipeline ID, timeout, working directory, or
|
- the executable/config path, pipeline ID, timeout, working directory, or
|
||||||
configured output contracts changed;
|
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
|
- the durable bundle, index, lane set, provenance, regular-file status, or
|
||||||
checksum no longer validates.
|
checksum no longer validates.
|
||||||
|
|
||||||
@@ -252,8 +314,9 @@ Safe fix:
|
|||||||
narratio run-stage extract 2026-04-04 --force
|
narratio run-stage extract 2026-04-04 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
Narratio fingerprints its invocation contract, not the contents of transitive
|
Narratio fingerprints its invocation contract and prepared Narratio reference
|
||||||
Notarius inputs. Always force extraction after changing them; downstream
|
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.
|
successful stages are then marked stale normally.
|
||||||
|
|
||||||
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ with the sample campaign and a compatible local- or S3-audio session.
|
|||||||
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
|
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
|
||||||
[glossary](campaigns/sample-campaign/glossary.yml),
|
[glossary](campaigns/sample-campaign/glossary.yml),
|
||||||
[players](campaigns/sample-campaign/players.yml), and
|
[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
|
- [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
|
||||||
reserves the expected filename and directory shape. Replace it with a real
|
reserves the expected filename and directory shape. Replace it with a real
|
||||||
FLAC file before running transcription.
|
FLAC file before running transcription.
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ inputs:
|
|||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
players_file: ./players.yml
|
||||||
party_file: ./party.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
|
config_path: /usr/local/etc/notarius/config.yml
|
||||||
pipeline_id: dnd-session
|
pipeline_id: dnd-session
|
||||||
timeout: 3h
|
timeout: 3h
|
||||||
|
references:
|
||||||
|
glossary: narratio.input.glossary
|
||||||
|
party: narratio.input.party
|
||||||
|
players: narratio.input.players
|
||||||
|
spell_catalog: narratio.input.spell_catalog
|
||||||
outputs:
|
outputs:
|
||||||
npc_registry:
|
npc_registry:
|
||||||
lane_id: npc-registry
|
lane_id: npc-registry
|
||||||
@@ -52,4 +57,3 @@ scriptorium:
|
|||||||
scenes:
|
scenes:
|
||||||
source: narratio.extraction.scene_descriptions
|
source: narratio.extraction.scene_descriptions
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,13 @@ notarius:
|
|||||||
pipeline_id: dnd-session
|
pipeline_id: dnd-session
|
||||||
timeout: 3h
|
timeout: 3h
|
||||||
working_directory: /usr/local/etc/notarius
|
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
|
# Each key creates source narratio.extraction.<key>. These constraints match
|
||||||
# the current Notarius D&D lane contracts; update them with Notarius.
|
# the current Notarius D&D lane contracts; update them with Notarius.
|
||||||
outputs:
|
outputs:
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ func (f *FakeRunner) Run(ctx context.Context, req RunRequest) (RunResult, error)
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return RunResult{}, err
|
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 {
|
if f.Err != nil {
|
||||||
return RunResult{}, f.Err
|
return RunResult{}, f.Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const ReceiptSchemaVersion = "notarius.run-result.v1"
|
const ReceiptSchemaVersion = "notarius.run-result.v2"
|
||||||
|
|
||||||
// Runner is the adapter boundary for a complete Notarius pipeline invocation.
|
// Runner is the adapter boundary for a complete Notarius pipeline invocation.
|
||||||
type Runner interface {
|
type Runner interface {
|
||||||
Run(ctx context.Context, req RunRequest) (RunResult, error)
|
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.
|
// RunRequest contains the resolved inputs and diagnostic destinations for one invocation.
|
||||||
type RunRequest struct {
|
type RunRequest struct {
|
||||||
Binary string
|
Binary string
|
||||||
@@ -24,6 +31,7 @@ type RunRequest struct {
|
|||||||
ReceiptPath string
|
ReceiptPath string
|
||||||
LogPath string
|
LogPath string
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
|
References []ReferenceBinding
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receipt is the transport-neutral successful run receipt.
|
// Receipt is the transport-neutral successful run receipt.
|
||||||
@@ -35,11 +43,31 @@ type Receipt struct {
|
|||||||
IndexFile string
|
IndexFile string
|
||||||
NormalizedOutputCount int
|
NormalizedOutputCount int
|
||||||
RejectedOutputCount int
|
RejectedOutputCount int
|
||||||
WarningCount int
|
WarningGroupCount int
|
||||||
|
WarningOccurrenceCount int
|
||||||
|
DiagnosticGroupCount int
|
||||||
|
DiagnosticOccurrenceCount int
|
||||||
|
DiagnosticsTruncated bool
|
||||||
ValidationStatus string
|
ValidationStatus string
|
||||||
|
ValidationSummaries []ValidationSummary
|
||||||
DebugDirectory string
|
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.
|
// LaneDescriptor identifies one normalized lane payload discovered through the index.
|
||||||
type LaneDescriptor struct {
|
type LaneDescriptor struct {
|
||||||
LaneID string
|
LaneID string
|
||||||
@@ -72,6 +100,8 @@ type Index struct {
|
|||||||
RejectedPath string
|
RejectedPath string
|
||||||
WarningsFile string
|
WarningsFile string
|
||||||
WarningsPath string
|
WarningsPath string
|
||||||
|
DiagnosticsFile string
|
||||||
|
DiagnosticsPath string
|
||||||
Lanes []LaneDescriptor
|
Lanes []LaneDescriptor
|
||||||
ChunkMap *PipelineDescriptor
|
ChunkMap *PipelineDescriptor
|
||||||
EvidenceContext *PipelineDescriptor
|
EvidenceContext *PipelineDescriptor
|
||||||
@@ -90,8 +120,29 @@ type RejectionSummary struct {
|
|||||||
|
|
||||||
// WarningSummary retains structured warning identity without free-form messages.
|
// WarningSummary retains structured warning identity without free-form messages.
|
||||||
type WarningSummary struct {
|
type WarningSummary struct {
|
||||||
Scope string
|
Disposition string
|
||||||
|
Category string
|
||||||
ReasonCode 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.
|
// RunResult describes a successfully decoded and validated Notarius bundle.
|
||||||
@@ -105,4 +156,5 @@ type RunResult struct {
|
|||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
Rejections []RejectionSummary
|
Rejections []RejectionSummary
|
||||||
Warnings []WarningSummary
|
Warnings []WarningSummary
|
||||||
|
Diagnostics []DiagnosticSummary
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/subprocess"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +23,12 @@ const (
|
|||||||
canonicalManifestFile = "manifest.json"
|
canonicalManifestFile = "manifest.json"
|
||||||
canonicalRejectedFile = "rejected.json"
|
canonicalRejectedFile = "rejected.json"
|
||||||
canonicalWarningsFile = "warnings.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)
|
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 {
|
if r == nil || r.run == nil {
|
||||||
return RunResult{}, fmt.Errorf("notarius subprocess runner is 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
|
return RunResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,8 +58,11 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
|||||||
"--config", req.ConfigPath,
|
"--config", req.ConfigPath,
|
||||||
"--input", req.InputPath,
|
"--input", req.InputPath,
|
||||||
"--output-dir", req.OutputRoot,
|
"--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{
|
processResult, err := r.run(ctx, subprocess.RunRequest{
|
||||||
Executable: req.Binary,
|
Executable: req.Binary,
|
||||||
Args: args,
|
Args: args,
|
||||||
@@ -95,24 +106,42 @@ func (r *SubprocessRunner) Run(ctx context.Context, req RunRequest) (RunResult,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return baseResult, err
|
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.Receipt = receipt
|
||||||
baseResult.Index = index
|
baseResult.Index = index
|
||||||
baseResult.BundleRoot = bundleRoot
|
baseResult.BundleRoot = bundleRoot
|
||||||
baseResult.Rejections = rejections
|
baseResult.Rejections = rejections
|
||||||
baseResult.Warnings = warnings
|
baseResult.Warnings = warnings
|
||||||
|
baseResult.Diagnostics = diagnostics
|
||||||
return baseResult, nil
|
return baseResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRunRequest(req RunRequest) error {
|
func validateRunRequest(req RunRequest) ([]ReferenceBinding, error) {
|
||||||
if strings.TrimSpace(req.Binary) == "" {
|
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) == "" {
|
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 {
|
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{
|
for label, path := range map[string]string{
|
||||||
"config": req.ConfigPath,
|
"config": req.ConfigPath,
|
||||||
@@ -123,34 +152,53 @@ func validateRunRequest(req RunRequest) error {
|
|||||||
"log": req.LogPath,
|
"log": req.LogPath,
|
||||||
} {
|
} {
|
||||||
if strings.TrimSpace(path) == "" {
|
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) {
|
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) {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
type receiptDocument struct {
|
||||||
@@ -161,11 +209,69 @@ type receiptDocument struct {
|
|||||||
IndexFile string `json:"index_file"`
|
IndexFile string `json:"index_file"`
|
||||||
NormalizedOutputCount *int `json:"normalized_output_count"`
|
NormalizedOutputCount *int `json:"normalized_output_count"`
|
||||||
RejectedOutputCount *int `json:"rejected_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"`
|
ValidationStatus string `json:"validation_status"`
|
||||||
|
ValidationSummaries []validationSummaryDocument `json:"validation_summaries"`
|
||||||
DebugDirectory string `json:"debug_directory"`
|
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) {
|
func loadReceipt(path, pipelineID string) (Receipt, error) {
|
||||||
var document receiptDocument
|
var document receiptDocument
|
||||||
if err := decodeBoundedJSON(path, maxReceiptBytes, &document); err != nil {
|
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) == "" ||
|
if strings.TrimSpace(document.RunID) == "" || strings.TrimSpace(document.PipelineID) == "" ||
|
||||||
strings.TrimSpace(document.OutputDirectory) == "" || strings.TrimSpace(document.ValidationStatus) == "" ||
|
strings.TrimSpace(document.OutputDirectory) == "" || strings.TrimSpace(document.ValidationStatus) == "" ||
|
||||||
document.NormalizedOutputCount == nil ||
|
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")
|
return Receipt{}, fmt.Errorf("notarius receipt is missing required fields")
|
||||||
}
|
}
|
||||||
if document.IndexFile != canonicalIndexFile {
|
if document.IndexFile != canonicalIndexFile {
|
||||||
@@ -186,9 +294,18 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
|
|||||||
if document.PipelineID != pipelineID {
|
if document.PipelineID != pipelineID {
|
||||||
return Receipt{}, fmt.Errorf("notarius receipt pipeline id %q does not match requested pipeline %q", 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")
|
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) {
|
if !filepath.IsAbs(document.OutputDirectory) {
|
||||||
return Receipt{}, fmt.Errorf("notarius receipt output directory must be absolute")
|
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,
|
IndexFile: document.IndexFile,
|
||||||
NormalizedOutputCount: *document.NormalizedOutputCount,
|
NormalizedOutputCount: *document.NormalizedOutputCount,
|
||||||
RejectedOutputCount: *document.RejectedOutputCount,
|
RejectedOutputCount: *document.RejectedOutputCount,
|
||||||
WarningCount: *document.WarningCount,
|
WarningGroupCount: *document.WarningGroupCount,
|
||||||
|
WarningOccurrenceCount: *document.WarningOccurrenceCount,
|
||||||
|
DiagnosticGroupCount: *document.DiagnosticGroupCount,
|
||||||
|
DiagnosticOccurrenceCount: *document.DiagnosticOccurrenceCount,
|
||||||
|
DiagnosticsTruncated: *document.DiagnosticsTruncated,
|
||||||
ValidationStatus: document.ValidationStatus,
|
ValidationStatus: document.ValidationStatus,
|
||||||
|
ValidationSummaries: validationSummaries,
|
||||||
DebugDirectory: document.DebugDirectory,
|
DebugDirectory: document.DebugDirectory,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -214,6 +336,7 @@ type indexDocument struct {
|
|||||||
OutputFiles *[]laneDocument `json:"output_files"`
|
OutputFiles *[]laneDocument `json:"output_files"`
|
||||||
RejectedFile string `json:"rejected_file"`
|
RejectedFile string `json:"rejected_file"`
|
||||||
WarningsFile string `json:"warnings_file"`
|
WarningsFile string `json:"warnings_file"`
|
||||||
|
DiagnosticsFile string `json:"diagnostics_file"`
|
||||||
ChunkMap *pipelineDocument `json:"chunk_map"`
|
ChunkMap *pipelineDocument `json:"chunk_map"`
|
||||||
EvidenceContext *pipelineDocument `json:"evidence_context"`
|
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: "manifest_file", got: document.ManifestFile, want: canonicalManifestFile},
|
||||||
{name: "rejected_file", got: document.RejectedFile, want: canonicalRejectedFile},
|
{name: "rejected_file", got: document.RejectedFile, want: canonicalRejectedFile},
|
||||||
{name: "warnings_file", got: document.WarningsFile, want: canonicalWarningsFile},
|
{name: "warnings_file", got: document.WarningsFile, want: canonicalWarningsFile},
|
||||||
|
{name: "diagnostics_file", got: document.DiagnosticsFile, want: canonicalDiagnosticsFile},
|
||||||
} {
|
} {
|
||||||
if field.got != field.want {
|
if field.got != field.want {
|
||||||
return Index{}, fmt.Errorf("notarius index %s %q is incompatible; want %q", field.name, 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,
|
ManifestFile: document.ManifestFile,
|
||||||
RejectedFile: document.RejectedFile,
|
RejectedFile: document.RejectedFile,
|
||||||
WarningsFile: document.WarningsFile,
|
WarningsFile: document.WarningsFile,
|
||||||
|
DiagnosticsFile: document.DiagnosticsFile,
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
if index.ManifestPath, err = resolveRegularFile(bundleRoot, index.ManifestFile); err != nil {
|
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 {
|
if index.WarningsPath, err = resolveRegularFile(bundleRoot, index.WarningsFile); err != nil {
|
||||||
return Index{}, fmt.Errorf("resolve notarius warning file: %w", err)
|
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))
|
seenLanes := make(map[string]struct{}, len(*document.OutputFiles))
|
||||||
for _, lane := range *document.OutputFiles {
|
for _, lane := range *document.OutputFiles {
|
||||||
@@ -362,12 +490,34 @@ func loadRejections(path string) ([]RejectionSummary, error) {
|
|||||||
return summaries, nil
|
return summaries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type warningDocument struct {
|
type findingGroupDocument struct {
|
||||||
Warnings *[]struct {
|
Disposition string `json:"disposition"`
|
||||||
Scope string `json:"scope"`
|
Category string `json:"category"`
|
||||||
ReasonCode string `json:"reason_code"`
|
ReasonCode string `json:"reason_code"`
|
||||||
|
Origin diagnosticOriginDocument `json:"origin"`
|
||||||
|
OccurrenceCount *int `json:"occurrence_count"`
|
||||||
|
Samples *[]struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
Message string `json:"message"`
|
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) {
|
func loadWarnings(path string) ([]WarningSummary, error) {
|
||||||
@@ -375,19 +525,151 @@ func loadWarnings(path string) ([]WarningSummary, error) {
|
|||||||
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
|
if err := decodeBoundedJSON(path, maxSummaryBytes, &document); err != nil {
|
||||||
return nil, fmt.Errorf("decode notarius warnings: %w", err)
|
return nil, fmt.Errorf("decode notarius warnings: %w", err)
|
||||||
}
|
}
|
||||||
if document.Warnings == nil {
|
if document.SchemaVersion != warningsSchemaVersion || document.GroupCount == nil ||
|
||||||
return nil, fmt.Errorf("notarius warning document is missing warnings array")
|
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))
|
if *document.GroupCount < 0 || *document.GroupCount > maxWarningGroups || *document.OccurrenceCount < 0 ||
|
||||||
for _, item := range *document.Warnings {
|
*document.GroupCount != len(*document.Groups) {
|
||||||
if strings.TrimSpace(item.ReasonCode) == "" || strings.TrimSpace(item.Message) == "" {
|
return nil, fmt.Errorf("notarius warning document counts are inconsistent")
|
||||||
return nil, fmt.Errorf("notarius warning entries require reason_code and message")
|
|
||||||
}
|
}
|
||||||
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
|
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 {
|
func decodeBoundedJSON(path string, limit int64, destination any) error {
|
||||||
data, err := fileops.ReadRegularFile(path, limit)
|
data, err := fileops.ReadRegularFile(path, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ func TestSubprocessRunnerBuildsExactInvocationAndDiscoversBundle(t *testing.T) {
|
|||||||
if result.Receipt.SchemaVersion != ReceiptSchemaVersion || result.Receipt.RunID != "notarius-run-1" {
|
if result.Receipt.SchemaVersion != ReceiptSchemaVersion || result.Receipt.RunID != "notarius-run-1" {
|
||||||
t.Fatalf("receipt = %#v", result.Receipt)
|
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" {
|
if len(result.Index.Lanes) != 1 || result.Index.Lanes[0].LaneID != "npc-registry" {
|
||||||
t.Fatalf("lanes = %#v", result.Index.Lanes)
|
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" {
|
if len(result.Rejections) != 1 || result.Rejections[0].LaneID != "spells" || result.Rejections[0].ReasonCode != "invalid_spell" {
|
||||||
t.Fatalf("rejections = %#v", result.Rejections)
|
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)
|
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) {
|
func TestSubprocessRunnerUsesMinimalEnvironmentAndSeparatesStreams(t *testing.T) {
|
||||||
@@ -171,8 +273,10 @@ func TestLoadReceiptValidation(t *testing.T) {
|
|||||||
valid := map[string]any{
|
valid := map[string]any{
|
||||||
"schema_version": ReceiptSchemaVersion, "run_id": "run-1", "pipeline_id": "pipeline-1",
|
"schema_version": ReceiptSchemaVersion, "run_id": "run-1", "pipeline_id": "pipeline-1",
|
||||||
"output_directory": filepath.Join(root, "outputs", "run-1"), "index_file": "index.json",
|
"output_directory": filepath.Join(root, "outputs", "run-1"), "index_file": "index.json",
|
||||||
"normalized_output_count": 1, "rejected_output_count": 0, "warning_count": 0,
|
"normalized_output_count": 1, "rejected_output_count": 0,
|
||||||
"validation_status": "approved", "future_field": true,
|
"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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -183,11 +287,12 @@ func TestLoadReceiptValidation(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "unknown fields tolerated", wantOK: true},
|
{name: "unknown fields tolerated", wantOK: true},
|
||||||
{name: "malformed", raw: []byte("{")},
|
{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: "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: "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: "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" },
|
name: "nested index", mutate: func(v map[string]any) { v["index_file"] = "nested/index.json" },
|
||||||
wantError: `index_file "nested/index.json"`,
|
wantError: `index_file "nested/index.json"`,
|
||||||
@@ -369,22 +474,26 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
rejectedPath := filepath.Join(root, "rejected.json")
|
rejectedPath := filepath.Join(root, "rejected.json")
|
||||||
warningsPath := filepath.Join(root, "warnings.json")
|
warningsPath := filepath.Join(root, "warnings.json")
|
||||||
|
diagnosticsPath := filepath.Join(root, "diagnostics.json")
|
||||||
writeJSONFile(t, rejectedPath, map[string]any{"rejected": []any{map[string]any{
|
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,
|
"stage": "validate", "lane_id": "spells", "reason_code": "invalid", "message": "do not retain this", "future": true,
|
||||||
}}, "future": true})
|
}}, "future": true})
|
||||||
writeJSONFile(t, warningsPath, map[string]any{"warnings": []any{map[string]any{
|
writeJSONFile(t, warningsPath, findingEnvelope(warningsSchemaVersion, []any{findingGroup("warning", "degradation", "bounded", "normalize", 2)}, 2, false, 0))
|
||||||
"scope": "lane:spells", "reason_code": "bounded", "message": "do not retain this", "future": true,
|
writeJSONFile(t, diagnosticsPath, findingEnvelope(diagnosticsSchemaVersion, []any{findingGroup("advisory", "data_quality", "low_confidence", "normalize", 3)}, 4, true, 1))
|
||||||
}}, "future": true})
|
|
||||||
rejections, err := loadRejections(rejectedPath)
|
rejections, err := loadRejections(rejectedPath)
|
||||||
if err != nil || len(rejections) != 1 || rejections[0].ReasonCode != "invalid" {
|
if err != nil || len(rejections) != 1 || rejections[0].ReasonCode != "invalid" {
|
||||||
t.Fatalf("loadRejections() = %#v, %v", rejections, err)
|
t.Fatalf("loadRejections() = %#v, %v", rejections, err)
|
||||||
}
|
}
|
||||||
warnings, err := loadWarnings(warningsPath)
|
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)
|
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) {
|
t.Run("malformed "+name, func(t *testing.T) {
|
||||||
if err := os.WriteFile(path, []byte("{"), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte("{"), 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile() error = %v", err)
|
t.Fatalf("WriteFile() error = %v", err)
|
||||||
@@ -392,8 +501,10 @@ func TestLoadDiagnosticSummariesValidateBoundsAndTolerateUnknownFields(t *testin
|
|||||||
var err error
|
var err error
|
||||||
if name == "rejections" {
|
if name == "rejections" {
|
||||||
_, err = loadRejections(path)
|
_, err = loadRejections(path)
|
||||||
} else {
|
} else if name == "warnings" {
|
||||||
_, err = loadWarnings(path)
|
_, err = loadWarnings(path)
|
||||||
|
} else {
|
||||||
|
_, _, _, err = loadDiagnostics(path)
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("summary decoder error = 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") {
|
if _, err := loadRejections(oversized); err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||||
t.Fatalf("loadRejections(oversized) error = %v", err)
|
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) {
|
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"}
|
want := RunResult{BundleRoot: "/bundle"}
|
||||||
fake := &FakeRunner{Result: want}
|
fake := &FakeRunner{Result: want}
|
||||||
got, err := fake.Run(context.Background(), req)
|
got, err := fake.Run(context.Background(), req)
|
||||||
if err != nil || !reflect.DeepEqual(got, want) || !reflect.DeepEqual(fake.Requests, []RunRequest{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)
|
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")
|
wantErr := errors.New("configured failure")
|
||||||
fake.Err = wantErr
|
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)}
|
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 {
|
if includeUnknown {
|
||||||
rejection["future"] = true
|
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, "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{
|
index := validIndexValue([]any{map[string]any{
|
||||||
"lane_id": "npc-registry", "file": "lanes/npc.json", "media_type": "application/json",
|
"lane_id": "npc-registry", "file": "lanes/npc.json", "media_type": "application/json",
|
||||||
"module_key": "dnd/npc-registry", "schema_id": "notarius.dnd.npc_registry",
|
"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{
|
receipt := map[string]any{
|
||||||
"schema_version": ReceiptSchemaVersion, "run_id": "notarius-run-1", "pipeline_id": req.PipelineID,
|
"schema_version": ReceiptSchemaVersion, "run_id": "notarius-run-1", "pipeline_id": req.PipelineID,
|
||||||
"output_directory": bundle, "index_file": "index.json", "normalized_output_count": 1,
|
"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 {
|
if includeUnknown {
|
||||||
receipt["future"] = true
|
receipt["future"] = true
|
||||||
@@ -517,7 +640,7 @@ func createBundleSkeleton(t *testing.T) string {
|
|||||||
if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(bundle, "lanes"), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll(bundle) error = %v", err)
|
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 {
|
if err := os.WriteFile(filepath.Join(bundle, filepath.FromSlash(name)), []byte("{}"), 0o644); err != nil {
|
||||||
t.Fatalf("WriteFile(%q) error = %v", name, err)
|
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 {
|
func validIndexValue(lanes []any) map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"manifest_file": "manifest.json", "output_files": lanes,
|
"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) {
|
func writeJSONFile(t *testing.T, path string, value any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := json.Marshal(value)
|
data, err := json.Marshal(value)
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
"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/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
@@ -25,6 +27,37 @@ type materializingNotariusRunner struct {
|
|||||||
failuresRemaining int
|
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) {
|
func (r *materializingNotariusRunner) Run(_ context.Context, req notarius.RunRequest) (notarius.RunResult, error) {
|
||||||
r.requests = append(r.requests, req)
|
r.requests = append(r.requests, req)
|
||||||
if r.failuresRemaining > 0 {
|
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, "index.json"): `{"manifest_file":"manifest.json"}`,
|
||||||
filepath.Join(bundle, "manifest.json"): `{}`,
|
filepath.Join(bundle, "manifest.json"): `{}`,
|
||||||
filepath.Join(bundle, "rejected.json"): `{"rejected":[]}`,
|
filepath.Join(bundle, "rejected.json"): `{"rejected":[]}`,
|
||||||
filepath.Join(bundle, "warnings.json"): `{"warnings":[]}`,
|
filepath.Join(bundle, "warnings.json"): `{"schema_version":"notarius.warnings.v2","group_count":0,"occurrence_count":0,"groups":[]}`,
|
||||||
filepath.Join(lanesDir, "npcs.json"): `{"npcs":[]}`,
|
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 {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
return notarius.RunResult{}, err
|
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{
|
return notarius.RunResult{
|
||||||
Receipt: notarius.Receipt{
|
Receipt: notarius.Receipt{
|
||||||
SchemaVersion: notarius.ReceiptSchemaVersion, RunID: externalRunID,
|
SchemaVersion: notarius.ReceiptSchemaVersion, RunID: externalRunID,
|
||||||
PipelineID: req.PipelineID, OutputDirectory: bundle, IndexFile: "index.json",
|
PipelineID: req.PipelineID, OutputDirectory: bundle, IndexFile: "index.json",
|
||||||
NormalizedOutputCount: 1, ValidationStatus: "valid",
|
NormalizedOutputCount: len(lanes), ValidationStatus: "approved",
|
||||||
},
|
},
|
||||||
BundleRoot: bundle,
|
BundleRoot: bundle,
|
||||||
Index: notarius.Index{
|
Index: notarius.Index{
|
||||||
Path: filepath.Join(bundle, "index.json"), RejectedPath: filepath.Join(bundle, "rejected.json"),
|
Path: filepath.Join(bundle, "index.json"), RejectedPath: filepath.Join(bundle, "rejected.json"),
|
||||||
WarningsPath: filepath.Join(bundle, "warnings.json"),
|
WarningsPath: filepath.Join(bundle, "warnings.json"),
|
||||||
Lanes: []notarius.LaneDescriptor{{
|
DiagnosticsPath: filepath.Join(bundle, "diagnostics.json"),
|
||||||
LaneID: output.LaneID, File: "lanes/npcs.json", Path: filepath.Join(lanesDir, "npcs.json"),
|
Lanes: lanes,
|
||||||
MediaType: output.MediaType, SchemaID: output.SchemaID,
|
|
||||||
SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
|
|
||||||
}},
|
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -100,6 +148,182 @@ 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"])
|
||||||
|
}
|
||||||
|
for _, name := range []string{"render", "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) {
|
func TestExtractLifecycleChangedOutcomeRerunsSucceededDownstream(t *testing.T) {
|
||||||
cfg, env, runner := extractionLifecycleFixture(t, false)
|
cfg, env, runner := extractionLifecycleFixture(t, false)
|
||||||
analyzeRuns := 0
|
analyzeRuns := 0
|
||||||
@@ -394,6 +618,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) {
|
func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *stage.Env, *materializingNotariusRunner) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
|
|||||||
@@ -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) {
|
func TestExecuteLocksAddListAndRemoveUseRemoteLockStore(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|||||||
@@ -55,21 +55,26 @@ func inspectStableInputs(cfg *config.Config) []stableInputCheck {
|
|||||||
items := []struct {
|
items := []struct {
|
||||||
name string
|
name string
|
||||||
in config.ResolvedInputFile
|
in config.ResolvedInputFile
|
||||||
|
optional bool
|
||||||
}{
|
}{
|
||||||
{name: "speakers", in: cfg.StableInputs.SpeakersFile},
|
{name: "speakers", in: cfg.StableInputs.SpeakersFile},
|
||||||
{name: "autocorrect", in: cfg.StableInputs.AutocorrectFile},
|
{name: "autocorrect", in: cfg.StableInputs.AutocorrectFile},
|
||||||
{name: "glossary", in: cfg.StableInputs.GlossaryFile},
|
{name: "glossary", in: cfg.StableInputs.GlossaryFile},
|
||||||
{name: "players", in: cfg.StableInputs.PlayersFile},
|
{name: "players", in: cfg.StableInputs.PlayersFile},
|
||||||
{name: "party", in: cfg.StableInputs.PartyFile},
|
{name: "party", in: cfg.StableInputs.PartyFile},
|
||||||
|
{name: "spell_catalog", in: cfg.StableInputs.SpellCatalogFile, optional: true},
|
||||||
}
|
}
|
||||||
out := make([]stableInputCheck, 0, len(items))
|
out := make([]stableInputCheck, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
if item.optional && strings.TrimSpace(item.in.Path) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
path, err := resolveHelperConfigRelativePath(item.in)
|
path, err := resolveHelperConfigRelativePath(item.in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out = append(out, stableInputCheck{Name: item.name, Err: err})
|
out = append(out, stableInputCheck{Name: item.name, Err: err})
|
||||||
continue
|
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})
|
out = append(out, stableInputCheck{Name: item.name, Path: path, Err: err})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -271,8 +276,8 @@ func requireInspectionFile(path, label string) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("stat %s %q: %w", label, path, err)
|
return fmt.Errorf("stat %s %q: %w", label, path, err)
|
||||||
}
|
}
|
||||||
if info.IsDir() {
|
if !info.Mode().IsRegular() {
|
||||||
return fmt.Errorf("%s %q is a directory", label, path)
|
return fmt.Errorf("%s %q is not a regular file", label, path)
|
||||||
}
|
}
|
||||||
return nil
|
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")
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
SourceInputPlayers = "narratio.input.players"
|
SourceInputPlayers = "narratio.input.players"
|
||||||
SourceInputParty = "narratio.input.party"
|
SourceInputParty = "narratio.input.party"
|
||||||
SourceInputGlossary = "narratio.input.glossary"
|
SourceInputGlossary = "narratio.input.glossary"
|
||||||
|
SourceInputSpellCatalog = "narratio.input.spell_catalog"
|
||||||
|
|
||||||
configuredSourcePrefix = "narratio.artifact."
|
configuredSourcePrefix = "narratio.artifact."
|
||||||
extractionSourcePrefix = "narratio.extraction."
|
extractionSourcePrefix = "narratio.extraction."
|
||||||
@@ -55,6 +56,14 @@ type ScriptoriumInputSourceDescriptor struct {
|
|||||||
PreviousSession *PreviousSessionSourceDescriptor
|
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.
|
// PreviousSessionSourceDescriptor describes one canonical previous-session input source.
|
||||||
type PreviousSessionSourceDescriptor struct {
|
type PreviousSessionSourceDescriptor struct {
|
||||||
SourceID string
|
SourceID string
|
||||||
@@ -158,9 +167,9 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
|
|||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
|
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
|
||||||
}
|
}
|
||||||
if IsStableInputSource(trimmed) {
|
if prepared, ok := DescribePreparedInputSource(trimmed); ok {
|
||||||
return ScriptoriumInputSourceDescriptor{
|
return ScriptoriumInputSourceDescriptor{
|
||||||
Source: Source{ID: trimmed, Kind: SourceKindStableInput},
|
Source: Source{ID: prepared.SourceID, Kind: SourceKindStableInput},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
|
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
|
||||||
@@ -185,15 +194,34 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri
|
|||||||
return ScriptoriumInputSourceDescriptor{Source: classified}, nil
|
return ScriptoriumInputSourceDescriptor{Source: classified}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsStableInputSource reports whether source is a prepared stable input source
|
var preparedInputSources = map[string]PreparedInputSourceDescriptor{
|
||||||
// available only to Scriptorium input resolution.
|
SourceInputPlayers: {
|
||||||
func IsStableInputSource(source string) bool {
|
SourceID: SourceInputPlayers,
|
||||||
switch strings.TrimSpace(source) {
|
ManifestKind: "players",
|
||||||
case SourceInputPlayers, SourceInputParty, SourceInputGlossary:
|
Filename: "players.yml",
|
||||||
return true
|
},
|
||||||
default:
|
SourceInputParty: {
|
||||||
return false
|
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
|
// 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 players input", source: "narratio.input.players", wantKind: SourceKindStableInput},
|
||||||
{name: "prepared party input", source: "narratio.input.party", wantKind: SourceKindStableInput},
|
{name: "prepared party input", source: "narratio.input.party", wantKind: SourceKindStableInput},
|
||||||
{name: "prepared glossary input", source: "narratio.input.glossary", 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: "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: "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},
|
{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) {
|
func TestValidateInputConfiguredReference(t *testing.T) {
|
||||||
configured := map[string]struct{}{"session_recap": {}}
|
configured := map[string]struct{}{"session_recap": {}}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ func SessionRunNotariusLogPathForCampaign(rootDir, campaign, sessionID, runID st
|
|||||||
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius.stderr.log")
|
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.
|
// SessionRunNotariusOutputRootForCampaign returns the invocation-local Notarius output root.
|
||||||
func SessionRunNotariusOutputRootForCampaign(rootDir, campaign, sessionID, runID string) string {
|
func SessionRunNotariusOutputRootForCampaign(rootDir, campaign, sessionID, runID string) string {
|
||||||
return filepath.Join(SessionRunExtractDirForCampaign(rootDir, campaign, sessionID, runID), "notarius-output")
|
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: "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: "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: "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: "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)},
|
{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
|
||||||
|
}
|
||||||
@@ -138,6 +138,74 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
|||||||
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config")
|
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) {
|
func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) {
|
||||||
pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(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",
|
"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"`
|
GlossaryFile string `yaml:"glossary_file"`
|
||||||
PlayersFile string `yaml:"players_file"`
|
PlayersFile string `yaml:"players_file"`
|
||||||
PartyFile string `yaml:"party_file"`
|
PartyFile string `yaml:"party_file"`
|
||||||
|
SpellCatalogFile string `yaml:"spell_catalog_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionConfig contains per-session inputs and metadata.
|
// SessionConfig contains per-session inputs and metadata.
|
||||||
@@ -266,6 +267,7 @@ type NotariusConfig struct {
|
|||||||
PipelineID string `yaml:"pipeline_id"`
|
PipelineID string `yaml:"pipeline_id"`
|
||||||
Timeout string `yaml:"timeout"`
|
Timeout string `yaml:"timeout"`
|
||||||
WorkingDirectory string `yaml:"working_directory"`
|
WorkingDirectory string `yaml:"working_directory"`
|
||||||
|
References map[string]string `yaml:"references"`
|
||||||
Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
|
Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +295,7 @@ type SessionInputsConfig struct {
|
|||||||
GlossaryFile string `yaml:"glossary_file"`
|
GlossaryFile string `yaml:"glossary_file"`
|
||||||
PlayersFile string `yaml:"players_file"`
|
PlayersFile string `yaml:"players_file"`
|
||||||
PartyFile string `yaml:"party_file"`
|
PartyFile string `yaml:"party_file"`
|
||||||
|
SpellCatalogFile string `yaml:"spell_catalog_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionAudioS3Input configures S3 session-audio input discovery.
|
// SessionAudioS3Input configures S3 session-audio input discovery.
|
||||||
@@ -308,6 +311,7 @@ type ResolvedStableInputs struct {
|
|||||||
GlossaryFile ResolvedInputFile
|
GlossaryFile ResolvedInputFile
|
||||||
PlayersFile ResolvedInputFile
|
PlayersFile ResolvedInputFile
|
||||||
PartyFile ResolvedInputFile
|
PartyFile ResolvedInputFile
|
||||||
|
SpellCatalogFile ResolvedInputFile
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolvedInputFile records one merged config path and its source config file.
|
// ResolvedInputFile records one merged config path and its source config file.
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ const (
|
|||||||
DefaultAuditaReport = true
|
DefaultAuditaReport = true
|
||||||
DefaultNotariusBinary = "notarius"
|
DefaultNotariusBinary = "notarius"
|
||||||
DefaultNotariusTimeout = "3h"
|
DefaultNotariusTimeout = "3h"
|
||||||
|
// MaxNotariusReferenceBindings is the maximum number of CLI reference
|
||||||
|
// bindings accepted for one Notarius invocation.
|
||||||
|
MaxNotariusReferenceBindings = 256
|
||||||
|
|
||||||
DefaultScriptoriumBinary = "scriptorium"
|
DefaultScriptoriumBinary = "scriptorium"
|
||||||
DefaultScriptoriumTimeout = "10m"
|
DefaultScriptoriumTimeout = "10m"
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
|||||||
if sessionCfg == nil {
|
if sessionCfg == nil {
|
||||||
return ResolvedStableInputs{}, fmt.Errorf("session config is required")
|
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)
|
campaignName := CampaignID(campaignCfg)
|
||||||
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
|
sessionCampaign := strings.TrimSpace(sessionCfg.Campaign)
|
||||||
@@ -254,6 +260,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
|||||||
campaignPath,
|
campaignPath,
|
||||||
sessionPath,
|
sessionPath,
|
||||||
),
|
),
|
||||||
|
SpellCatalogFile: selectStableInput(
|
||||||
|
campaignCfg.Inputs.SpellCatalogFile,
|
||||||
|
sessionCfg.Inputs.SpellCatalogFile,
|
||||||
|
campaignPath,
|
||||||
|
sessionPath,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
|
sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path
|
||||||
@@ -261,6 +273,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig
|
|||||||
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
|
sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path
|
||||||
sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path
|
sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path
|
||||||
sessionCfg.Inputs.PartyFile = stable.PartyFile.Path
|
sessionCfg.Inputs.PartyFile = stable.PartyFile.Path
|
||||||
|
sessionCfg.Inputs.SpellCatalogFile = stable.SpellCatalogFile.Path
|
||||||
return stable, nil
|
return stable, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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: "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 session id", yaml: "notarius:\n session_id: forbidden\n"},
|
||||||
{name: "unsupported model", yaml: "notarius:\n model: 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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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) {
|
func TestNotariusEnabledValidation(t *testing.T) {
|
||||||
valid := `notarius:
|
valid := `notarius:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ func Validate(cfg *Config) error {
|
|||||||
if err := validateSession(cfg.Session); err != nil {
|
if err := validateSession(cfg.Session); err != nil {
|
||||||
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
|
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)
|
return fmt.Errorf("pipeline/session config invalid: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +72,9 @@ func validateCampaign(cfg *CampaignConfig) error {
|
|||||||
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
||||||
return fmt.Errorf("campaign.inputs.party_file is required")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +551,39 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
|
|||||||
if strings.TrimSpace(cfg.WorkingDirectory) == "" {
|
if strings.TrimSpace(cfg.WorkingDirectory) == "" {
|
||||||
return fmt.Errorf("pipeline.notarius.working_directory is required when pipeline.notarius.enabled is true")
|
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{}
|
reservedSources := map[string]string{}
|
||||||
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {
|
for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() {
|
||||||
@@ -613,6 +650,7 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error
|
|||||||
cfg.PipelineID = strings.TrimSpace(cfg.PipelineID)
|
cfg.PipelineID = strings.TrimSpace(cfg.PipelineID)
|
||||||
cfg.Timeout = strings.TrimSpace(cfg.Timeout)
|
cfg.Timeout = strings.TrimSpace(cfg.Timeout)
|
||||||
cfg.WorkingDirectory = filepath.Clean(cfg.WorkingDirectory)
|
cfg.WorkingDirectory = filepath.Clean(cfg.WorkingDirectory)
|
||||||
|
cfg.References = normalizedReferences
|
||||||
cfg.Outputs = normalizedOutputs
|
cfg.Outputs = normalizedOutputs
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -762,6 +800,9 @@ func validateSession(cfg *SessionConfig) error {
|
|||||||
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
||||||
return fmt.Errorf("session.inputs.party_file is required")
|
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) != ""
|
hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != ""
|
||||||
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
|
hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0
|
||||||
@@ -797,10 +838,17 @@ func validateSessionIdentifier(fieldName, value string, required bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error {
|
func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig, stableInputs ResolvedStableInputs) error {
|
||||||
if pipeline == nil || session == nil {
|
if pipeline == nil || session == nil {
|
||||||
return 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
|
audioS3Enabled := session.Inputs.AudioS3 != nil
|
||||||
publishUploadEnabled := publishUploadConfiguredForS3(pipeline)
|
publishUploadEnabled := publishUploadConfiguredForS3(pipeline)
|
||||||
|
|||||||
@@ -22,6 +22,32 @@ func RemoveAllUnderRoot(rootPath, target string) error {
|
|||||||
return removeConfinedEntry(root, targetName)
|
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) {
|
func openConfinedCleanupTarget(rootPath, target string) (*os.Root, string, error) {
|
||||||
if strings.TrimSpace(rootPath) == "" {
|
if strings.TrimSpace(rootPath) == "" {
|
||||||
return nil, "", fmt.Errorf("cleanup root is required")
|
return nil, "", fmt.Errorf("cleanup root is required")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package fileops
|
package fileops
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,3 +79,47 @@ func TestRemoveAllUnderRootRejectsSymlinkInTree(t *testing.T) {
|
|||||||
t.Fatalf("outside sentinel was changed: %v", err)
|
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() }()
|
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 {
|
if err := EnsureWorkspaceDirectory(filepath.Dir(dst)); err != nil {
|
||||||
return "", fmt.Errorf("create destination directory: %w", err)
|
return "", fmt.Errorf("create destination directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
digest := sha256.New()
|
digest := sha256.New()
|
||||||
err = replaceFileFromReaderConfined(
|
if err := replaceFileFromReaderConfined(
|
||||||
dst,
|
dst,
|
||||||
io.TeeReader(in, digest),
|
io.TeeReader(src, digest),
|
||||||
ReplaceFileOptions{Mode: perm},
|
ReplaceFileOptions{Mode: perm},
|
||||||
)
|
); err != nil {
|
||||||
if err != nil {
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(digest.Sum(nil)), nil
|
return hex.EncodeToString(digest.Sum(nil)), nil
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package fileops
|
package fileops
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -74,6 +76,28 @@ func TestCopyFileAtomicWithChecksumMatchesDestination(t *testing.T) {
|
|||||||
assertNoMatchingTempFiles(t, filepath.Dir(dst), ".copied.txt.tmp-")
|
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) {
|
func TestCopyFileAtomicCleansTempFileOnInstallFailure(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
src := filepath.Join(root, "source.txt")
|
src := filepath.Join(root, "source.txt")
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -627,17 +627,26 @@ func resolveScriptoriumInput(inputCfg config.ScriptoriumInputConfig, execution a
|
|||||||
return analyzeInputFailure(describeErr)
|
return analyzeInputFailure(describeErr)
|
||||||
}
|
}
|
||||||
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
|
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
|
||||||
resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, execution.Paths)
|
identity, err := artifacts.ResolvePreparedInput(execution.Paths, execution.Manifest, descriptor.Source.ID)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
return analyzeInputFound(identity.Path, nil)
|
||||||
|
}
|
||||||
|
if errors.Is(err, artifacts.ErrPreparedInputAbsent) {
|
||||||
if inputCfg.Required {
|
if inputCfg.Required {
|
||||||
return analyzeInputFailure(err)
|
return analyzeInputFailure(fmt.Errorf(
|
||||||
|
"required prepared input source %q is unavailable; run narratio run-stage prepare %s --force",
|
||||||
|
descriptor.Source.ID,
|
||||||
|
execution.SessionID,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
return analyzeInputMissing()
|
return analyzeInputMissing()
|
||||||
}
|
}
|
||||||
if !ok {
|
return analyzeInputFailure(fmt.Errorf(
|
||||||
return analyzeInputMissing()
|
"prepared input source %q is invalid; run narratio run-stage prepare %s --force: %w",
|
||||||
}
|
descriptor.Source.ID,
|
||||||
return analyzeInputFound(resolvedPath, nil)
|
execution.SessionID,
|
||||||
|
err,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
||||||
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
|
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
|
||||||
@@ -710,36 +719,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(
|
func buildAnalyzeRuntimeArtifactCatalog(
|
||||||
paths artifacts.SessionPaths,
|
paths artifacts.SessionPaths,
|
||||||
m *manifest.Manifest,
|
m *manifest.Manifest,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
"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/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
@@ -1099,9 +1100,11 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
|
|||||||
playersPath := filepath.Join(paths.InputsDir, "players.yml")
|
playersPath := filepath.Join(paths.InputsDir, "players.yml")
|
||||||
partyPath := filepath.Join(paths.InputsDir, "party.yml")
|
partyPath := filepath.Join(paths.InputsDir, "party.yml")
|
||||||
glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml")
|
glossaryPath := filepath.Join(paths.InputsDir, "glossary.yml")
|
||||||
writeAnalyzeFile(t, playersPath, "- Eric\n")
|
spellCatalogPath := filepath.Join(paths.InputsDir, "spell_catalog.json")
|
||||||
writeAnalyzeFile(t, partyPath, "- Arannis\n")
|
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, playersPath, "- Eric\n")
|
||||||
writeAnalyzeFile(t, glossaryPath, "- term: Ten Towns\n")
|
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputParty, partyPath, "- Arannis\n")
|
||||||
|
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputGlossary, glossaryPath, "- term: Ten Towns\n")
|
||||||
|
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputSpellCatalog, spellCatalogPath, "{\"spells\":[]}\n")
|
||||||
|
|
||||||
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
|
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
|
||||||
@@ -1116,6 +1119,10 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
|
|||||||
Source: "narratio.input.glossary",
|
Source: "narratio.input.glossary",
|
||||||
Required: true,
|
Required: true,
|
||||||
}
|
}
|
||||||
|
artifact.Inputs["spells"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.input.spell_catalog",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
||||||
|
|
||||||
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
@@ -1134,6 +1141,9 @@ func TestAnalyzeResolvesPreparedStableInputSources(t *testing.T) {
|
|||||||
if fake.RunRequests[0].InputPaths["glossary"] != glossaryPath {
|
if fake.RunRequests[0].InputPaths["glossary"] != glossaryPath {
|
||||||
t.Fatalf("glossary input = %q, want %q", fake.RunRequests[0].InputPaths["glossary"], glossaryPath)
|
t.Fatalf("glossary input = %q, want %q", fake.RunRequests[0].InputPaths["glossary"], glossaryPath)
|
||||||
}
|
}
|
||||||
|
if fake.RunRequests[0].InputPaths["spells"] != spellCatalogPath {
|
||||||
|
t.Fatalf("spells input = %q, want %q", fake.RunRequests[0].InputPaths["spells"], spellCatalogPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnalyzeMissingRequiredPreparedStableInputFailsWithPrepareGuidance(t *testing.T) {
|
func TestAnalyzeMissingRequiredPreparedStableInputFailsWithPrepareGuidance(t *testing.T) {
|
||||||
@@ -1182,6 +1192,28 @@ func TestAnalyzeMissingOptionalPreparedStableInputIsOmitted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeInvalidOptionalPreparedStableInputFailsWithPrepareGuidance(t *testing.T) {
|
||||||
|
env, m, _ := setupAnalyzeEnv(t)
|
||||||
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
|
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
|
||||||
|
playersPath := filepath.Join(paths.InputsDir, "players.yml")
|
||||||
|
recordPreparedAnalyzeInput(t, m, artifactpolicy.SourceInputPlayers, playersPath, "- Eric\n")
|
||||||
|
m.Inputs[len(m.Inputs)-1].Checksum = strings.Repeat("0", 64)
|
||||||
|
|
||||||
|
artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"]
|
||||||
|
artifact.Inputs["players"] = config.ScriptoriumInputConfig{
|
||||||
|
Source: "narratio.input.players",
|
||||||
|
Required: false,
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact
|
||||||
|
|
||||||
|
_, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "prepared input source \"narratio.input.players\" is invalid") ||
|
||||||
|
!strings.Contains(err.Error(), "run narratio run-stage prepare 2026-05-03 --force") {
|
||||||
|
t.Fatalf("Run() error = %v, want invalid prepared input guidance", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
|
func TestAnalyzeSupportsCanonicalNormalizedTranscriptSourceFromManifestOutput(t *testing.T) {
|
||||||
env, m, fake := setupAnalyzeEnv(t)
|
env, m, fake := setupAnalyzeEnv(t)
|
||||||
paths := sessionPathsForEnv(env, m.SessionID)
|
paths := sessionPathsForEnv(env, m.SessionID)
|
||||||
@@ -1650,6 +1682,24 @@ func writeAnalyzeFile(t *testing.T, path, contents string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func recordPreparedAnalyzeInput(t *testing.T, m *manifest.Manifest, sourceID, path, contents string) {
|
||||||
|
t.Helper()
|
||||||
|
writeAnalyzeFile(t, path, contents)
|
||||||
|
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID)
|
||||||
|
}
|
||||||
|
checksum, err := artifacts.SHA256File(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SHA256File(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
m.Inputs = append(m.Inputs, manifest.InputRecord{
|
||||||
|
Kind: descriptor.ManifestKind,
|
||||||
|
Path: path,
|
||||||
|
Checksum: checksum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func writeAnalyzeFileNoTest(path, contents string) {
|
func writeAnalyzeFileNoTest(path, contents string) {
|
||||||
if strings.TrimSpace(path) == "" {
|
if strings.TrimSpace(path) == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: resolve final-trimmed transcript identity: %w", err)
|
return nil, fmt.Errorf("extract: resolve final-trimmed transcript identity: %w", err)
|
||||||
}
|
}
|
||||||
|
references, err := resolveExtractReferences(paths, m, notariusConfig, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract: resolve Notarius references: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
timeout, err := time.ParseDuration(strings.TrimSpace(notariusConfig.Timeout))
|
timeout, err := time.ParseDuration(strings.TrimSpace(notariusConfig.Timeout))
|
||||||
if err != nil || timeout <= 0 {
|
if err != nil || timeout <= 0 {
|
||||||
@@ -119,20 +123,32 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: resolve durable bundle path: %w", err)
|
return nil, fmt.Errorf("extract: resolve durable bundle path: %w", err)
|
||||||
}
|
}
|
||||||
for _, directory := range []string{filepath.Dir(receiptPath), outputRoot, filepath.Dir(durableBundle)} {
|
referenceSnapshotRoot, err := absolutePath(artifacts.SessionRunNotariusReferencesDirForCampaign(workspaceRoot, campaign, sessionID, runID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract: resolve reference snapshot path: %w", err)
|
||||||
|
}
|
||||||
|
directories := []string{filepath.Dir(receiptPath), outputRoot, filepath.Dir(durableBundle)}
|
||||||
|
if len(references.Bindings) > 0 {
|
||||||
|
directories = append(directories, referenceSnapshotRoot)
|
||||||
|
}
|
||||||
|
for _, directory := range directories {
|
||||||
if err := fileops.EnsureWorkspaceDirectory(directory); err != nil {
|
if err := fileops.EnsureWorkspaceDirectory(directory); err != nil {
|
||||||
return nil, fmt.Errorf("extract: create directory %q: %w", directory, err)
|
return nil, fmt.Errorf("extract: create directory %q: %w", directory, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
referenceBindings, err := materializeExtractReferenceSnapshots(paths, references, referenceSnapshotRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract: materialize Notarius reference snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, notariusConfig, timeout, workingDirectory, input)
|
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, notariusConfig, timeout, workingDirectory, input, references.Identities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: build configuration fingerprint: %w", err)
|
return nil, fmt.Errorf("extract: build configuration fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
request := notarius.RunRequest{
|
request := notarius.RunRequest{
|
||||||
Binary: resolvedBinary, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
|
Binary: resolvedBinary, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
|
||||||
InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory,
|
InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory,
|
||||||
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout,
|
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout, References: referenceBindings,
|
||||||
}
|
}
|
||||||
adapterResult, err := env.Notarius.Run(ctx, request)
|
adapterResult, err := env.Notarius.Run(ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -144,6 +160,9 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
if adapterResult.Receipt.RunID == "" || adapterResult.Receipt.PipelineID != notariusConfig.PipelineID {
|
if adapterResult.Receipt.RunID == "" || adapterResult.Receipt.PipelineID != notariusConfig.PipelineID {
|
||||||
return nil, fmt.Errorf("extract: notarius receipt identity is missing or incompatible")
|
return nil, fmt.Errorf("extract: notarius receipt identity is missing or incompatible")
|
||||||
}
|
}
|
||||||
|
if err := verifyExtractReferenceSnapshots(referenceSnapshotRoot, references); err != nil {
|
||||||
|
return nil, fmt.Errorf("extract: verify Notarius reference snapshots: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
selected, err := selectRequiredNotariusLanes(env.ArtifactStore, notariusConfig.Outputs, adapterResult)
|
selected, err := selectRequiredNotariusLanes(env.ArtifactStore, notariusConfig.Outputs, adapterResult)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -161,6 +180,10 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: resolve staging warnings relative path: %w", err)
|
return nil, fmt.Errorf("extract: resolve staging warnings relative path: %w", err)
|
||||||
}
|
}
|
||||||
|
diagnosticsRelative, err := pathsafe.SlashRelativeFromRoot(adapterResult.BundleRoot, adapterResult.Index.DiagnosticsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract: resolve staging diagnostics relative path: %w", err)
|
||||||
|
}
|
||||||
stagingIndexChecksum, err := checksumRegularFile(adapterResult.Index.Path, false)
|
stagingIndexChecksum, err := checksumRegularFile(adapterResult.Index.Path, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: validate staging index: %w", err)
|
return nil, fmt.Errorf("extract: validate staging index: %w", err)
|
||||||
@@ -188,6 +211,10 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("extract: resolve promoted warnings: %w", err)
|
return nil, fmt.Errorf("extract: resolve promoted warnings: %w", err)
|
||||||
}
|
}
|
||||||
|
promotedDiagnosticsPath, err := pathsafe.JoinSlashRelativeUnderRoot(durableBundle, diagnosticsRelative)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extract: resolve promoted diagnostics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
outputs := make([]artifacts.Ref, 0, len(selected)+1)
|
outputs := make([]artifacts.Ref, 0, len(selected)+1)
|
||||||
for _, lane := range selected {
|
for _, lane := range selected {
|
||||||
@@ -237,18 +264,27 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
"diagnostic_path": logPath,
|
"diagnostic_path": logPath,
|
||||||
"rejections_path": promotedRejectionsPath,
|
"rejections_path": promotedRejectionsPath,
|
||||||
"warnings_path": promotedWarningsPath,
|
"warnings_path": promotedWarningsPath,
|
||||||
|
"diagnostics_path": promotedDiagnosticsPath,
|
||||||
"narratio_run_id": runID,
|
"narratio_run_id": runID,
|
||||||
"configuration_fingerprint": fingerprint,
|
"configuration_fingerprint": fingerprint,
|
||||||
"direct_input": input.Metadata(),
|
"direct_input": input.Metadata(),
|
||||||
|
"reference_count": len(references.Identities),
|
||||||
|
"references": extractReferenceMetadata(references.Identities),
|
||||||
"receipt": map[string]any{
|
"receipt": map[string]any{
|
||||||
"run_id": adapterResult.Receipt.RunID, "pipeline_id": adapterResult.Receipt.PipelineID,
|
"run_id": adapterResult.Receipt.RunID, "pipeline_id": adapterResult.Receipt.PipelineID,
|
||||||
"normalized_output_count": adapterResult.Receipt.NormalizedOutputCount,
|
"normalized_output_count": adapterResult.Receipt.NormalizedOutputCount,
|
||||||
"rejected_output_count": adapterResult.Receipt.RejectedOutputCount,
|
"rejected_output_count": adapterResult.Receipt.RejectedOutputCount,
|
||||||
"warning_count": adapterResult.Receipt.WarningCount,
|
"warning_group_count": adapterResult.Receipt.WarningGroupCount,
|
||||||
|
"warning_occurrence_count": adapterResult.Receipt.WarningOccurrenceCount,
|
||||||
|
"diagnostic_group_count": adapterResult.Receipt.DiagnosticGroupCount,
|
||||||
|
"diagnostic_occurrence_count": adapterResult.Receipt.DiagnosticOccurrenceCount,
|
||||||
|
"diagnostics_truncated": adapterResult.Receipt.DiagnosticsTruncated,
|
||||||
"validation_status": adapterResult.Receipt.ValidationStatus,
|
"validation_status": adapterResult.Receipt.ValidationStatus,
|
||||||
},
|
},
|
||||||
"rejections": boundedRejectionMetadata(adapterResult.Rejections),
|
"rejections": boundedRejectionMetadata(adapterResult.Rejections),
|
||||||
"warnings": boundedWarningMetadata(adapterResult.Warnings),
|
"warnings": boundedWarningMetadata(adapterResult.Warnings),
|
||||||
|
"diagnostics": boundedDiagnosticMetadata(adapterResult.Diagnostics),
|
||||||
|
"validation_summaries": boundedValidationMetadata(adapterResult.Receipt.ValidationSummaries),
|
||||||
}
|
}
|
||||||
return &StageResult{
|
return &StageResult{
|
||||||
Outputs: outputs,
|
Outputs: outputs,
|
||||||
@@ -282,6 +318,11 @@ func selectRequiredNotariusLanes(
|
|||||||
return nil, fmt.Errorf("extract: required lane %q was rejected (reason_code=%q)", expected.LaneID, rejection.ReasonCode)
|
return nil, fmt.Errorf("extract: required lane %q was rejected (reason_code=%q)", expected.LaneID, rejection.ReasonCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, validation := range result.Receipt.ValidationSummaries {
|
||||||
|
if validation.LaneID == expected.LaneID && validation.Status != "complete" {
|
||||||
|
return nil, fmt.Errorf("extract: required lane %q validation is %q", expected.LaneID, validation.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
matches := make([]notarius.LaneDescriptor, 0, 1)
|
matches := make([]notarius.LaneDescriptor, 0, 1)
|
||||||
for _, descriptor := range result.Index.Lanes {
|
for _, descriptor := range result.Index.Lanes {
|
||||||
if descriptor.LaneID == expected.LaneID {
|
if descriptor.LaneID == expected.LaneID {
|
||||||
@@ -359,6 +400,7 @@ type fingerprintDocument struct {
|
|||||||
Timeout string `json:"timeout"`
|
Timeout string `json:"timeout"`
|
||||||
WorkingDirectory string `json:"working_directory"`
|
WorkingDirectory string `json:"working_directory"`
|
||||||
Input artifacts.ExtractionInputIdentity `json:"input"`
|
Input artifacts.ExtractionInputIdentity `json:"input"`
|
||||||
|
References []extractReferenceIdentity `json:"references"`
|
||||||
Outputs []fingerprintOutput `json:"outputs"`
|
Outputs []fingerprintOutput `json:"outputs"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +410,7 @@ func extractionFingerprint(
|
|||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
workingDirectory string,
|
workingDirectory string,
|
||||||
input artifacts.ExtractionInputIdentity,
|
input artifacts.ExtractionInputIdentity,
|
||||||
|
references []extractReferenceIdentity,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
keys := make([]string, 0, len(cfg.Outputs))
|
keys := make([]string, 0, len(cfg.Outputs))
|
||||||
for key := range cfg.Outputs {
|
for key := range cfg.Outputs {
|
||||||
@@ -382,9 +425,20 @@ func extractionFingerprint(
|
|||||||
SchemaID: output.SchemaID, SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
|
SchemaID: output.SchemaID, SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
sortedReferences := append([]extractReferenceIdentity(nil), references...)
|
||||||
|
sort.Slice(sortedReferences, func(i, j int) bool {
|
||||||
|
if sortedReferences[i].Selector != sortedReferences[j].Selector {
|
||||||
|
return sortedReferences[i].Selector < sortedReferences[j].Selector
|
||||||
|
}
|
||||||
|
if sortedReferences[i].SourceID != sortedReferences[j].SourceID {
|
||||||
|
return sortedReferences[i].SourceID < sortedReferences[j].SourceID
|
||||||
|
}
|
||||||
|
return sortedReferences[i].Path < sortedReferences[j].Path
|
||||||
|
})
|
||||||
payload, err := json.Marshal(fingerprintDocument{
|
payload, err := json.Marshal(fingerprintDocument{
|
||||||
Binary: binary, ConfigPath: configPath, PipelineID: cfg.PipelineID,
|
Binary: binary, ConfigPath: configPath, PipelineID: cfg.PipelineID,
|
||||||
Timeout: timeout.String(), WorkingDirectory: workingDirectory, Input: input, Outputs: outputs,
|
Timeout: timeout.String(), WorkingDirectory: workingDirectory, Input: input,
|
||||||
|
References: sortedReferences, Outputs: outputs,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -441,7 +495,52 @@ func boundedWarningMetadata(values []notarius.WarningSummary) []map[string]any {
|
|||||||
}
|
}
|
||||||
result := make([]map[string]any, 0, limit)
|
result := make([]map[string]any, 0, limit)
|
||||||
for _, value := range values[:limit] {
|
for _, value := range values[:limit] {
|
||||||
result = append(result, map[string]any{"scope": value.Scope, "reason_code": value.ReasonCode})
|
result = append(result, map[string]any{
|
||||||
|
"disposition": value.Disposition, "category": value.Category,
|
||||||
|
"reason_code": value.ReasonCode, "origin": diagnosticOriginMetadata(value.Origin),
|
||||||
|
"occurrence_count": value.OccurrenceCount,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boundedDiagnosticMetadata(values []notarius.DiagnosticSummary) []map[string]any {
|
||||||
|
limit := len(values)
|
||||||
|
if limit > maxDiagnosticSummaries {
|
||||||
|
limit = maxDiagnosticSummaries
|
||||||
|
}
|
||||||
|
result := make([]map[string]any, 0, limit)
|
||||||
|
for _, value := range values[:limit] {
|
||||||
|
result = append(result, map[string]any{
|
||||||
|
"disposition": value.Disposition, "category": value.Category,
|
||||||
|
"reason_code": value.ReasonCode, "origin": diagnosticOriginMetadata(value.Origin),
|
||||||
|
"occurrence_count": value.OccurrenceCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func boundedValidationMetadata(values []notarius.ValidationSummary) []map[string]any {
|
||||||
|
limit := len(values)
|
||||||
|
if limit > maxDiagnosticSummaries {
|
||||||
|
limit = maxDiagnosticSummaries
|
||||||
|
}
|
||||||
|
result := make([]map[string]any, 0, limit)
|
||||||
|
for _, value := range values[:limit] {
|
||||||
|
result = append(result, map[string]any{
|
||||||
|
"stage": value.Stage, "step_id": value.StepID, "lane_id": value.LaneID,
|
||||||
|
"module_key": value.ModuleKey, "chunk_id": value.ChunkID, "status": value.Status,
|
||||||
|
"rejecting_validators": value.RejectingValidators, "reason_codes": value.ReasonCodes,
|
||||||
|
"incomplete_validators": value.IncompleteValidators,
|
||||||
|
"producer_attempt_count": value.ProducerAttemptCount, "terminal_action": value.TerminalAction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticOriginMetadata(value notarius.DiagnosticOrigin) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"stage": value.Stage, "step_id": value.StepID, "lane_id": value.LaneID,
|
||||||
|
"module_key": value.ModuleKey, "validator_key": value.ValidatorKey,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
193
internal/stage/extract_references.go
Normal file
193
internal/stage/extract_references.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/notariusref"
|
||||||
|
)
|
||||||
|
|
||||||
|
type extractReferenceIdentity struct {
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
SourceID string `json:"source_id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Checksum string `json:"checksum"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func materializeExtractReferenceSnapshots(
|
||||||
|
paths artifacts.SessionPaths,
|
||||||
|
references resolvedExtractReferences,
|
||||||
|
snapshotRoot string,
|
||||||
|
) ([]notarius.ReferenceBinding, error) {
|
||||||
|
if len(references.Bindings) != len(references.Identities) {
|
||||||
|
return nil, fmt.Errorf("reference bindings and identities have different lengths")
|
||||||
|
}
|
||||||
|
bindings := make([]notarius.ReferenceBinding, len(references.Bindings))
|
||||||
|
copied := make(map[string]string, len(references.Bindings))
|
||||||
|
destinationOwners := make(map[string]string, len(references.Bindings))
|
||||||
|
for index, binding := range references.Bindings {
|
||||||
|
identity := references.Identities[index]
|
||||||
|
destination, ok := copied[identity.SourceID]
|
||||||
|
if !ok {
|
||||||
|
filename := filepath.Base(filepath.FromSlash(identity.Path))
|
||||||
|
if filename == "" || filename == "." || filename == string(filepath.Separator) {
|
||||||
|
return nil, fmt.Errorf("reference %q source %q has invalid prepared filename", identity.Selector, identity.SourceID)
|
||||||
|
}
|
||||||
|
destination = filepath.Join(snapshotRoot, filename)
|
||||||
|
if owner, duplicate := destinationOwners[destination]; duplicate && owner != identity.SourceID {
|
||||||
|
return nil, fmt.Errorf("reference sources %q and %q resolve to the same snapshot path", owner, identity.SourceID)
|
||||||
|
}
|
||||||
|
destinationOwners[destination] = identity.SourceID
|
||||||
|
|
||||||
|
file, err := fileops.OpenConfinedRegularFile(paths.Root, identity.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open reference %q source %q for snapshot: %w", identity.Selector, identity.SourceID, err)
|
||||||
|
}
|
||||||
|
checksum, copyErr := fileops.WriteReaderAtomicWithChecksum(destination, file, fileops.WorkspaceFileMode)
|
||||||
|
closeErr := file.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
return nil, fmt.Errorf("snapshot reference %q source %q: %w", identity.Selector, identity.SourceID, copyErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return nil, fmt.Errorf("close reference %q source %q: %w", identity.Selector, identity.SourceID, closeErr)
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(destination)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("inspect reference %q snapshot: %w", identity.Selector, err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() || !strings.EqualFold(checksum, identity.Checksum) || info.Size() != identity.SizeBytes {
|
||||||
|
return nil, fmt.Errorf("reference %q source %q changed while its verified snapshot was created", identity.Selector, identity.SourceID)
|
||||||
|
}
|
||||||
|
copied[identity.SourceID] = destination
|
||||||
|
}
|
||||||
|
bindings[index] = notarius.ReferenceBinding{Selector: binding.Selector, Path: destination}
|
||||||
|
}
|
||||||
|
return bindings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyExtractReferenceSnapshots(snapshotRoot string, references resolvedExtractReferences) error {
|
||||||
|
verified := make(map[string]struct{}, len(references.Identities))
|
||||||
|
for _, identity := range references.Identities {
|
||||||
|
if _, ok := verified[identity.SourceID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filename := filepath.Base(filepath.FromSlash(identity.Path))
|
||||||
|
file, err := fileops.OpenConfinedRegularFile(snapshotRoot, filename)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reopen reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, err)
|
||||||
|
}
|
||||||
|
digest := sha256.New()
|
||||||
|
size, readErr := io.Copy(digest, file)
|
||||||
|
closeErr := file.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
return fmt.Errorf("revalidate reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, readErr)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return fmt.Errorf("close reference %q source %q snapshot: %w", identity.Selector, identity.SourceID, closeErr)
|
||||||
|
}
|
||||||
|
checksum := hex.EncodeToString(digest.Sum(nil))
|
||||||
|
if !strings.EqualFold(checksum, identity.Checksum) || size != identity.SizeBytes {
|
||||||
|
return fmt.Errorf("reference %q source %q snapshot changed while Notarius was running", identity.Selector, identity.SourceID)
|
||||||
|
}
|
||||||
|
verified[identity.SourceID] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolvedExtractReferences struct {
|
||||||
|
Bindings []notarius.ReferenceBinding
|
||||||
|
Identities []extractReferenceIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveExtractReferences(
|
||||||
|
paths artifacts.SessionPaths,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
cfg *config.NotariusConfig,
|
||||||
|
sessionID string,
|
||||||
|
) (resolvedExtractReferences, error) {
|
||||||
|
if cfg == nil || len(cfg.References) == 0 {
|
||||||
|
return resolvedExtractReferences{
|
||||||
|
Bindings: []notarius.ReferenceBinding{},
|
||||||
|
Identities: []extractReferenceIdentity{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type configuredReference struct {
|
||||||
|
selector string
|
||||||
|
sourceID string
|
||||||
|
}
|
||||||
|
configured := make([]configuredReference, 0, len(cfg.References))
|
||||||
|
seen := make(map[string]struct{}, len(cfg.References))
|
||||||
|
for rawSelector, rawSourceID := range cfg.References {
|
||||||
|
selector, err := notariusref.NormalizeSelector(rawSelector)
|
||||||
|
if err != nil {
|
||||||
|
return resolvedExtractReferences{}, fmt.Errorf("reference selector %q is invalid: %w", rawSelector, err)
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[selector]; duplicate {
|
||||||
|
return resolvedExtractReferences{}, fmt.Errorf("reference selector %q is configured more than once after normalization", selector)
|
||||||
|
}
|
||||||
|
seen[selector] = struct{}{}
|
||||||
|
configured = append(configured, configuredReference{selector: selector, sourceID: strings.TrimSpace(rawSourceID)})
|
||||||
|
}
|
||||||
|
sort.Slice(configured, func(i, j int) bool { return configured[i].selector < configured[j].selector })
|
||||||
|
resolved := resolvedExtractReferences{
|
||||||
|
Bindings: make([]notarius.ReferenceBinding, 0, len(configured)),
|
||||||
|
Identities: make([]extractReferenceIdentity, 0, len(configured)),
|
||||||
|
}
|
||||||
|
for _, reference := range configured {
|
||||||
|
selector := reference.selector
|
||||||
|
sourceID := reference.sourceID
|
||||||
|
identity, err := artifacts.ResolvePreparedInput(paths, m, sourceID)
|
||||||
|
if err != nil {
|
||||||
|
return resolvedExtractReferences{}, fmt.Errorf(
|
||||||
|
"reference %q source %q is unavailable or invalid; run narratio run-stage prepare %s --force: %w",
|
||||||
|
selector,
|
||||||
|
sourceID,
|
||||||
|
sessionID,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
resolved.Bindings = append(resolved.Bindings, notarius.ReferenceBinding{
|
||||||
|
Selector: selector,
|
||||||
|
Path: identity.Path,
|
||||||
|
})
|
||||||
|
resolved.Identities = append(resolved.Identities, extractReferenceIdentity{
|
||||||
|
Selector: selector,
|
||||||
|
SourceID: identity.SourceID,
|
||||||
|
Path: identity.RelativePath,
|
||||||
|
Checksum: identity.Checksum,
|
||||||
|
SizeBytes: identity.Size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractReferenceMetadata(identities []extractReferenceIdentity) []map[string]any {
|
||||||
|
limit := len(identities)
|
||||||
|
if limit > config.MaxNotariusReferenceBindings {
|
||||||
|
limit = config.MaxNotariusReferenceBindings
|
||||||
|
}
|
||||||
|
metadata := make([]map[string]any, 0, limit)
|
||||||
|
for _, identity := range identities[:limit] {
|
||||||
|
metadata = append(metadata, map[string]any{
|
||||||
|
"selector": identity.Selector,
|
||||||
|
"source_id": identity.SourceID,
|
||||||
|
"path": identity.Path,
|
||||||
|
"checksum": identity.Checksum,
|
||||||
|
"size_bytes": identity.SizeBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
@@ -49,7 +49,11 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ResumeValidation{}, fmt.Errorf("extract resume: resolve final-trimmed transcript identity: %w", err)
|
return ResumeValidation{}, fmt.Errorf("extract resume: resolve final-trimmed transcript identity: %w", err)
|
||||||
}
|
}
|
||||||
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, cfg, timeout, workingDirectory, input)
|
references, err := resolveExtractReferences(paths, m, cfg, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return ResumeValidation{}, fmt.Errorf("extract resume: resolve Notarius references: %w", err)
|
||||||
|
}
|
||||||
|
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, cfg, timeout, workingDirectory, input, references.Identities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ResumeValidation{}, fmt.Errorf("extract resume: build configuration fingerprint: %w", err)
|
return ResumeValidation{}, fmt.Errorf("extract resume: build configuration fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
"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/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
@@ -75,11 +76,176 @@ func TestExtractStageResolvesManifestInputAndBuildsExactRequest(t *testing.T) {
|
|||||||
if !filepath.IsAbs(req.Binary) {
|
if !filepath.IsAbs(req.Binary) {
|
||||||
t.Fatalf("binary = %q, want absolute resolved path", req.Binary)
|
t.Fatalf("binary = %q, want absolute resolved path", req.Binary)
|
||||||
}
|
}
|
||||||
|
if len(req.References) != 0 {
|
||||||
|
t.Fatalf("references = %#v, want none", req.References)
|
||||||
|
}
|
||||||
|
if result.Metadata["reference_count"] != 0 || len(result.Metadata["references"].([]map[string]any)) != 0 {
|
||||||
|
t.Fatalf("reference metadata = count %#v references %#v", result.Metadata["reference_count"], result.Metadata["references"])
|
||||||
|
}
|
||||||
if len(result.Outputs) != 2 {
|
if len(result.Outputs) != 2 {
|
||||||
t.Fatalf("outputs = %#v, want lane and index", result.Outputs)
|
t.Fatalf("outputs = %#v, want lane and index", result.Outputs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractStageResolvesAllPreparedReferencesInSelectorOrder(t *testing.T) {
|
||||||
|
env, m, fake := setupExtractEnv(t)
|
||||||
|
configured := []struct {
|
||||||
|
selector string
|
||||||
|
sourceID string
|
||||||
|
contents string
|
||||||
|
}{
|
||||||
|
{selector: " players ", sourceID: artifactpolicy.SourceInputPlayers, contents: "players-secret\n"},
|
||||||
|
{selector: "spells.spell_catalog", sourceID: artifactpolicy.SourceInputSpellCatalog, contents: "spell-secret\n"},
|
||||||
|
{selector: "glossary", sourceID: artifactpolicy.SourceInputGlossary, contents: "glossary-secret\n"},
|
||||||
|
{selector: "party", sourceID: artifactpolicy.SourceInputParty, contents: "party-secret\n"},
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Notarius.References = make(map[string]string, len(configured))
|
||||||
|
preparedPaths := make(map[string]string, len(configured))
|
||||||
|
wantSources := make(map[string]string, len(configured))
|
||||||
|
for _, reference := range configured {
|
||||||
|
env.Config.Pipeline.Notarius.References[reference.selector] = reference.sourceID
|
||||||
|
selector := strings.TrimSpace(reference.selector)
|
||||||
|
preparedPaths[selector] = recordPreparedExtractInput(t, env, m, reference.sourceID, reference.contents)
|
||||||
|
wantSources[selector] = reference.sourceID
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := (extractStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
wantSelectors := []string{"glossary", "party", "players", "spells.spell_catalog"}
|
||||||
|
if len(fake.Requests) != 1 || len(fake.Requests[0].References) != len(wantSelectors) {
|
||||||
|
t.Fatalf("requests = %#v", fake.Requests)
|
||||||
|
}
|
||||||
|
for index, selector := range wantSelectors {
|
||||||
|
binding := fake.Requests[0].References[index]
|
||||||
|
wantPath := filepath.Join(
|
||||||
|
artifacts.SessionRunNotariusReferencesDirForCampaign(
|
||||||
|
env.Config.Pipeline.Workspace.Root, m.Campaign, m.SessionID, m.RunID,
|
||||||
|
),
|
||||||
|
filepath.Base(preparedPaths[selector]),
|
||||||
|
)
|
||||||
|
if binding.Selector != selector || binding.Path != wantPath || binding.Path == preparedPaths[selector] || !filepath.IsAbs(binding.Path) {
|
||||||
|
t.Fatalf("reference[%d] = %#v, want selector %q verified snapshot %q", index, binding, selector, wantPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Metadata["reference_count"] != len(wantSelectors) {
|
||||||
|
t.Fatalf("reference_count = %#v", result.Metadata["reference_count"])
|
||||||
|
}
|
||||||
|
metadata, ok := result.Metadata["references"].([]map[string]any)
|
||||||
|
if !ok || len(metadata) != len(wantSelectors) {
|
||||||
|
t.Fatalf("references metadata = %#v", result.Metadata["references"])
|
||||||
|
}
|
||||||
|
for index, selector := range wantSelectors {
|
||||||
|
entry := metadata[index]
|
||||||
|
if len(entry) != 5 || entry["selector"] != selector || entry["source_id"] != wantSources[selector] {
|
||||||
|
t.Fatalf("reference metadata[%d] = %#v", index, entry)
|
||||||
|
}
|
||||||
|
if path, _ := entry["path"].(string); !strings.HasPrefix(path, "inputs/") || filepath.IsAbs(path) {
|
||||||
|
t.Fatalf("reference metadata path = %#v", entry["path"])
|
||||||
|
}
|
||||||
|
if entry["checksum"] == "" || entry["size_bytes"] == int64(0) {
|
||||||
|
t.Fatalf("reference identity metadata = %#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(result.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(metadata) error = %v", err)
|
||||||
|
}
|
||||||
|
for _, reference := range configured {
|
||||||
|
if strings.Contains(string(encoded), strings.TrimSpace(reference.contents)) {
|
||||||
|
t.Fatalf("metadata contains reference payload %q: %s", reference.contents, encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type referenceMutationRunner struct {
|
||||||
|
delegate notarius.Runner
|
||||||
|
preparedPath string
|
||||||
|
preparedBytes []byte
|
||||||
|
mutateSnapshot bool
|
||||||
|
snapshotBytes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *referenceMutationRunner) Run(ctx context.Context, req notarius.RunRequest) (notarius.RunResult, error) {
|
||||||
|
if err := os.WriteFile(r.preparedPath, r.preparedBytes, 0o664); err != nil {
|
||||||
|
return notarius.RunResult{}, err
|
||||||
|
}
|
||||||
|
if len(req.References) != 1 {
|
||||||
|
return notarius.RunResult{}, fmt.Errorf("got %d references, want one", len(req.References))
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(req.References[0].Path)
|
||||||
|
if err != nil {
|
||||||
|
return notarius.RunResult{}, err
|
||||||
|
}
|
||||||
|
r.snapshotBytes = data
|
||||||
|
if r.mutateSnapshot {
|
||||||
|
if err := os.WriteFile(req.References[0].Path, []byte("tampered snapshot\n"), 0o664); err != nil {
|
||||||
|
return notarius.RunResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.delegate.Run(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractStageInvokesNotariusWithVerifiedReferenceSnapshot(t *testing.T) {
|
||||||
|
env, m, fake := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty}
|
||||||
|
preparedPath := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "verified party\n")
|
||||||
|
runner := &referenceMutationRunner{
|
||||||
|
delegate: fake, preparedPath: preparedPath, preparedBytes: []byte("changed after snapshot\n"),
|
||||||
|
}
|
||||||
|
env.Notarius = runner
|
||||||
|
|
||||||
|
if _, err := (extractStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if string(runner.snapshotBytes) != "verified party\n" {
|
||||||
|
t.Fatalf("Notarius reference bytes = %q, want verified snapshot", runner.snapshotBytes)
|
||||||
|
}
|
||||||
|
if len(fake.Requests) != 1 || fake.Requests[0].References[0].Path == preparedPath {
|
||||||
|
t.Fatalf("adapter requests = %#v, want a run-local reference snapshot", fake.Requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractStageRejectsReferenceSnapshotChangedDuringNotariusRun(t *testing.T) {
|
||||||
|
env, m, fake := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputParty}
|
||||||
|
preparedPath := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "verified party\n")
|
||||||
|
env.Notarius = &referenceMutationRunner{
|
||||||
|
delegate: fake, preparedPath: preparedPath, preparedBytes: []byte("changed after snapshot\n"), mutateSnapshot: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := (extractStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "snapshot changed while Notarius was running") || result != nil {
|
||||||
|
t.Fatalf("Run() result = %#v error = %v, want changed-snapshot failure", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractStageRejectsUnavailableReferenceBeforeInvocationOrRunDirectoryCreation(t *testing.T) {
|
||||||
|
env, m, fake := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"spell_catalog": artifactpolicy.SourceInputSpellCatalog,
|
||||||
|
}
|
||||||
|
runRoot := artifacts.SessionRunRootForCampaign(
|
||||||
|
env.Config.Pipeline.Workspace.Root, m.Campaign, m.SessionID, m.RunID,
|
||||||
|
)
|
||||||
|
if err := os.RemoveAll(runRoot); err != nil {
|
||||||
|
t.Fatalf("RemoveAll(run root) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := (extractStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "run narratio run-stage prepare ") || !strings.Contains(err.Error(), "--force") {
|
||||||
|
t.Fatalf("Run() result = %#v error = %v", result, err)
|
||||||
|
}
|
||||||
|
if len(fake.Requests) != 0 {
|
||||||
|
t.Fatalf("adapter requests = %#v", fake.Requests)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(runRoot); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("run root stat error = %v, want not exist", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
|
func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
|
||||||
env, m, fake := setupExtractEnv(t)
|
env, m, fake := setupExtractEnv(t)
|
||||||
fixture := extractFixtureFromEnv(t, env, m)
|
fixture := extractFixtureFromEnv(t, env, m)
|
||||||
@@ -99,7 +265,8 @@ func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
|
|||||||
if result.Metadata["receipt_path"] != artifacts.SessionRunNotariusReceiptPathForCampaign(fixture.workspace, fixture.campaign, fixture.sessionID, fixture.runID) ||
|
if result.Metadata["receipt_path"] != artifacts.SessionRunNotariusReceiptPathForCampaign(fixture.workspace, fixture.campaign, fixture.sessionID, fixture.runID) ||
|
||||||
result.Metadata["diagnostic_path"] != artifacts.SessionRunNotariusLogPathForCampaign(fixture.workspace, fixture.campaign, fixture.sessionID, fixture.runID) ||
|
result.Metadata["diagnostic_path"] != artifacts.SessionRunNotariusLogPathForCampaign(fixture.workspace, fixture.campaign, fixture.sessionID, fixture.runID) ||
|
||||||
result.Metadata["rejections_path"] != filepath.Join(durableBundle, "rejected.json") ||
|
result.Metadata["rejections_path"] != filepath.Join(durableBundle, "rejected.json") ||
|
||||||
result.Metadata["warnings_path"] != filepath.Join(durableBundle, "warnings.json") {
|
result.Metadata["warnings_path"] != filepath.Join(durableBundle, "warnings.json") ||
|
||||||
|
result.Metadata["diagnostics_path"] != filepath.Join(durableBundle, "diagnostics.json") {
|
||||||
t.Fatalf("diagnostic metadata = %#v", result.Metadata)
|
t.Fatalf("diagnostic metadata = %#v", result.Metadata)
|
||||||
}
|
}
|
||||||
if len(result.Outputs) != 2 {
|
if len(result.Outputs) != 2 {
|
||||||
@@ -133,7 +300,7 @@ func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
|
|||||||
t.Fatalf("lane path was not re-resolved after promotion: %q", lane.AbsolutePath)
|
t.Fatalf("lane path was not re-resolved after promotion: %q", lane.AbsolutePath)
|
||||||
}
|
}
|
||||||
for _, relative := range []string{
|
for _, relative := range []string{
|
||||||
"index.json", "manifest.json", "rejected.json", "warnings.json", "lanes/npc.json",
|
"index.json", "manifest.json", "rejected.json", "warnings.json", "diagnostics.json", "lanes/npc.json",
|
||||||
"lanes/unconfigured.json", "chunk-map.json", "evidence-context.json", "unknown/private-debug.json",
|
"lanes/unconfigured.json", "chunk-map.json", "evidence-context.json", "unknown/private-debug.json",
|
||||||
} {
|
} {
|
||||||
if _, err := os.Stat(filepath.Join(durableBundle, filepath.FromSlash(relative))); err != nil {
|
if _, err := os.Stat(filepath.Join(durableBundle, filepath.FromSlash(relative))); err != nil {
|
||||||
@@ -156,6 +323,81 @@ func TestExtractStageProducesImmutableManifestReadyOutputs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractStageConsumesAllDefaultDndPipelineArtifacts(t *testing.T) {
|
||||||
|
env, m, fake := setupExtractEnv(t)
|
||||||
|
|
||||||
|
contracts := 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"},
|
||||||
|
}
|
||||||
|
env.Config.Pipeline.Notarius.Outputs = contracts
|
||||||
|
fake.Result.Index.Lanes = nil
|
||||||
|
fake.Result.Rejections = nil
|
||||||
|
fake.Result.Warnings = nil
|
||||||
|
fake.Result.Diagnostics = nil
|
||||||
|
fake.Result.Receipt.NormalizedOutputCount = len(contracts)
|
||||||
|
fake.Result.Receipt.RejectedOutputCount = 0
|
||||||
|
fake.Result.Receipt.WarningGroupCount = 0
|
||||||
|
fake.Result.Receipt.WarningOccurrenceCount = 0
|
||||||
|
fake.Result.Receipt.DiagnosticGroupCount = 0
|
||||||
|
fake.Result.Receipt.DiagnosticOccurrenceCount = 0
|
||||||
|
fake.Result.Receipt.ValidationStatus = "approved"
|
||||||
|
indexDescriptors := make([]map[string]any, 0, len(contracts))
|
||||||
|
for _, contract := range contracts {
|
||||||
|
relative := "lanes/" + contract.LaneID + ".json"
|
||||||
|
path := filepath.Join(fake.Result.BundleRoot, filepath.FromSlash(relative))
|
||||||
|
if err := os.WriteFile(path, []byte(`{"records":[]}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%q) error = %v", path, err)
|
||||||
|
}
|
||||||
|
fake.Result.Index.Lanes = append(fake.Result.Index.Lanes, notarius.LaneDescriptor{
|
||||||
|
LaneID: contract.LaneID, File: relative, Path: path, MediaType: contract.MediaType,
|
||||||
|
ModuleKey: contract.ModuleKey, SchemaID: contract.SchemaID, SchemaVersion: contract.SchemaVersion,
|
||||||
|
})
|
||||||
|
indexDescriptors = append(indexDescriptors, map[string]any{
|
||||||
|
"lane_id": contract.LaneID, "file": relative, "media_type": contract.MediaType,
|
||||||
|
"module_key": contract.ModuleKey, "schema_id": contract.SchemaID,
|
||||||
|
"schema_version": contract.SchemaVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := writeJSONFile(fake.Result.Index.Path, map[string]any{
|
||||||
|
"manifest_file": "manifest.json", "output_files": indexDescriptors,
|
||||||
|
"rejected_file": "rejected.json", "warnings_file": "warnings.json",
|
||||||
|
"diagnostics_file": "diagnostics.json",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("write complete D&D index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := (extractStage{}).Run(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != len(contracts)+1 {
|
||||||
|
t.Fatalf("output count = %d, want %d", len(result.Outputs), len(contracts)+1)
|
||||||
|
}
|
||||||
|
got := make(map[string]*artifactmodel.ContractMetadata, len(contracts))
|
||||||
|
for _, output := range result.Outputs {
|
||||||
|
if output.Kind == extractLaneOutputKind {
|
||||||
|
got[output.SourceID] = output.Contract
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, contract := range contracts {
|
||||||
|
sourceID := artifacts.ExtractionArtifactSourceID(key)
|
||||||
|
actual := got[sourceID]
|
||||||
|
if actual == nil || actual.MediaType != contract.MediaType || actual.SchemaID != contract.SchemaID ||
|
||||||
|
actual.SchemaVersion != contract.SchemaVersion || actual.ModuleKey != contract.ModuleKey {
|
||||||
|
t.Fatalf("source %q contract = %#v, want %#v", sourceID, actual, contract)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractStageRejectsMissingOrInvalidFinalTrimmedInputBeforeInvocation(t *testing.T) {
|
func TestExtractStageRejectsMissingOrInvalidFinalTrimmedInputBeforeInvocation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -197,6 +439,9 @@ func TestExtractStageEnforcesRequiredLanePolicy(t *testing.T) {
|
|||||||
{name: "rejected", mutate: func(result *notarius.RunResult) {
|
{name: "rejected", mutate: func(result *notarius.RunResult) {
|
||||||
result.Rejections = append(result.Rejections, notarius.RejectionSummary{LaneID: "npc-registry", ReasonCode: "invalid_npc"})
|
result.Rejections = append(result.Rejections, notarius.RejectionSummary{LaneID: "npc-registry", ReasonCode: "invalid_npc"})
|
||||||
}, want: "was rejected"},
|
}, want: "was rejected"},
|
||||||
|
{name: "incomplete validation", mutate: func(result *notarius.RunResult) {
|
||||||
|
result.Receipt.ValidationSummaries = []notarius.ValidationSummary{{LaneID: "npc-registry", Status: "incomplete"}}
|
||||||
|
}, want: `validation is "incomplete"`},
|
||||||
{name: "duplicate", mutate: func(result *notarius.RunResult) {
|
{name: "duplicate", mutate: func(result *notarius.RunResult) {
|
||||||
result.Index.Lanes = append(result.Index.Lanes, result.Index.Lanes[0])
|
result.Index.Lanes = append(result.Index.Lanes, result.Index.Lanes[0])
|
||||||
}, want: "2 descriptors"},
|
}, want: "2 descriptors"},
|
||||||
@@ -281,11 +526,15 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
|
|||||||
second := &config.NotariusConfig{PipelineID: "pipeline", Outputs: map[string]config.NotariusOutputConfig{
|
second := &config.NotariusConfig{PipelineID: "pipeline", Outputs: map[string]config.NotariusOutputConfig{
|
||||||
"alpha": first.Outputs["alpha"], "zeta": first.Outputs["zeta"],
|
"alpha": first.Outputs["alpha"], "zeta": first.Outputs["zeta"],
|
||||||
}}
|
}}
|
||||||
one, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", first, time.Minute, "/work", input)
|
references := []extractReferenceIdentity{
|
||||||
|
{Selector: "zeta", SourceID: artifactpolicy.SourceInputPlayers, Path: "inputs/players.yml", Checksum: "players-checksum", SizeBytes: 12},
|
||||||
|
{Selector: "alpha", SourceID: artifactpolicy.SourceInputParty, Path: "inputs/party.yml", Checksum: "party-checksum", SizeBytes: 34},
|
||||||
|
}
|
||||||
|
one, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", first, time.Minute, "/work", input, references)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("extractionFingerprint(first) error = %v", err)
|
t.Fatalf("extractionFingerprint(first) error = %v", err)
|
||||||
}
|
}
|
||||||
two, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input)
|
two, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input, []extractReferenceIdentity{references[1], references[0]})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("extractionFingerprint(second) error = %v", err)
|
t.Fatalf("extractionFingerprint(second) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -305,7 +554,7 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
changedInput := input
|
changedInput := input
|
||||||
test.mutate(&changedInput)
|
test.mutate(&changedInput)
|
||||||
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", changedInput)
|
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", changedInput, references)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("extractionFingerprint(changed input) error = %v", err)
|
t.Fatalf("extractionFingerprint(changed input) error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -314,10 +563,36 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*extractReferenceIdentity)
|
||||||
|
}{
|
||||||
|
{name: "selector", mutate: func(identity *extractReferenceIdentity) { identity.Selector = "changed" }},
|
||||||
|
{name: "source", mutate: func(identity *extractReferenceIdentity) { identity.SourceID = artifactpolicy.SourceInputGlossary }},
|
||||||
|
{name: "path", mutate: func(identity *extractReferenceIdentity) { identity.Path = "inputs/changed.yml" }},
|
||||||
|
{name: "checksum", mutate: func(identity *extractReferenceIdentity) { identity.Checksum = "changed-checksum" }},
|
||||||
|
{name: "size", mutate: func(identity *extractReferenceIdentity) { identity.SizeBytes++ }},
|
||||||
|
} {
|
||||||
|
t.Run("reference "+test.name, func(t *testing.T) {
|
||||||
|
changedReferences := append([]extractReferenceIdentity(nil), references...)
|
||||||
|
test.mutate(&changedReferences[0])
|
||||||
|
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input, changedReferences)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractionFingerprint(changed reference) error = %v", err)
|
||||||
|
}
|
||||||
|
if one == changed {
|
||||||
|
t.Fatalf("fingerprint did not change with reference %s", test.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractStageResumeValidationAcceptsCurrentImmutableResult(t *testing.T) {
|
func TestExtractStageResumeValidationAcceptsCurrentImmutableResult(t *testing.T) {
|
||||||
env, m, _ := setupExtractEnv(t)
|
env, m, _ := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"party": artifactpolicy.SourceInputParty,
|
||||||
|
}
|
||||||
|
recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "party-reference\n")
|
||||||
seedSucceededExtractResult(t, env, m)
|
seedSucceededExtractResult(t, env, m)
|
||||||
m.RunID = "20260810T020304Z-fedcba98"
|
m.RunID = "20260810T020304Z-fedcba98"
|
||||||
|
|
||||||
@@ -330,6 +605,93 @@ func TestExtractStageResumeValidationAcceptsCurrentImmutableResult(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractStageResumeValidationRejectsChangedPreparedReferenceIdentity(t *testing.T) {
|
||||||
|
env, m, _ := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"party": artifactpolicy.SourceInputParty,
|
||||||
|
}
|
||||||
|
path := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "original-party\n")
|
||||||
|
seedSucceededExtractResult(t, env, m)
|
||||||
|
if err := os.WriteFile(path, []byte("changed-party\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(reference) error = %v", err)
|
||||||
|
}
|
||||||
|
checksum, err := artifacts.SHA256File(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SHA256File(reference) error = %v", err)
|
||||||
|
}
|
||||||
|
descriptor, _ := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty)
|
||||||
|
for index := range m.Inputs {
|
||||||
|
if m.Inputs[index].Kind == descriptor.ManifestKind {
|
||||||
|
m.Inputs[index].Checksum = checksum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateResume() error = %v", err)
|
||||||
|
}
|
||||||
|
if validation.Resumable || !strings.Contains(validation.Reason, "invocation contract changed") {
|
||||||
|
t.Fatalf("validation = %#v, want changed invocation contract", validation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractStageResumeValidationErrorsForUnavailablePreparedReference(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*testing.T, string, *manifest.Manifest)
|
||||||
|
}{
|
||||||
|
{name: "missing", mutate: func(t *testing.T, path string, _ *manifest.Manifest) {
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatalf("Remove(reference) error = %v", err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "unsafe manifest path", mutate: func(t *testing.T, _ string, m *manifest.Manifest) {
|
||||||
|
descriptor, _ := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputParty)
|
||||||
|
for index := range m.Inputs {
|
||||||
|
if m.Inputs[index].Kind == descriptor.ManifestKind {
|
||||||
|
m.Inputs[index].Path = filepath.Join(t.TempDir(), "outside.yml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "stale checksum", mutate: func(t *testing.T, path string, _ *manifest.Manifest) {
|
||||||
|
if err := os.WriteFile(path, []byte("tampered-party\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(reference) error = %v", err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
env, m, _ := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"party": artifactpolicy.SourceInputParty,
|
||||||
|
}
|
||||||
|
path := recordPreparedExtractInput(t, env, m, artifactpolicy.SourceInputParty, "original-party\n")
|
||||||
|
seedSucceededExtractResult(t, env, m)
|
||||||
|
test.mutate(t, path, m)
|
||||||
|
|
||||||
|
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "run narratio run-stage prepare ") || !strings.Contains(err.Error(), "--force") {
|
||||||
|
t.Fatalf("ValidateResume() validation = %#v error = %v", validation, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractStageResumeValidationDoesNotResolveReferencesWhenDisabled(t *testing.T) {
|
||||||
|
env, m, _ := setupExtractEnv(t)
|
||||||
|
env.Config.Pipeline.Notarius.Enabled = false
|
||||||
|
env.Config.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"missing": artifactpolicy.SourceInputSpellCatalog,
|
||||||
|
}
|
||||||
|
|
||||||
|
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateResume() error = %v", err)
|
||||||
|
}
|
||||||
|
if validation.Resumable || !strings.Contains(validation.Reason, "disabled") {
|
||||||
|
t.Fatalf("validation = %#v", validation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractStageAdapterResultIsImmediatelyReusableAndCatalogVisible(t *testing.T) {
|
func TestExtractStageAdapterResultIsImmediatelyReusableAndCatalogVisible(t *testing.T) {
|
||||||
env, m, fake := setupExtractEnv(t)
|
env, m, fake := setupExtractEnv(t)
|
||||||
producerRunID := m.RunID
|
producerRunID := m.RunID
|
||||||
@@ -338,7 +700,10 @@ func TestExtractStageAdapterResultIsImmediatelyReusableAndCatalogVisible(t *test
|
|||||||
"schema_version": notarius.ReceiptSchemaVersion,
|
"schema_version": notarius.ReceiptSchemaVersion,
|
||||||
"run_id": "notarius-run-1", "pipeline_id": "dnd-session",
|
"run_id": "notarius-run-1", "pipeline_id": "dnd-session",
|
||||||
"output_directory": fake.Result.BundleRoot, "index_file": "index.json",
|
"output_directory": fake.Result.BundleRoot, "index_file": "index.json",
|
||||||
"normalized_output_count": 2, "rejected_output_count": 0, "warning_count": 0,
|
"normalized_output_count": 2, "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,
|
"validation_status": "approved", "future_field": true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -540,6 +905,29 @@ func extractFixtureFromEnv(t *testing.T, env *Env, m *manifest.Manifest) extract
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func recordPreparedExtractInput(t *testing.T, env *Env, m *manifest.Manifest, sourceID, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("DescribePreparedInputSource(%q) ok = false", sourceID)
|
||||||
|
}
|
||||||
|
path := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, descriptor.Filename)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(prepared input) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(prepared input) error = %v", err)
|
||||||
|
}
|
||||||
|
checksum, err := artifacts.SHA256File(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SHA256File(prepared input) error = %v", err)
|
||||||
|
}
|
||||||
|
m.Inputs = append(m.Inputs, manifest.InputRecord{
|
||||||
|
Kind: descriptor.ManifestKind, Path: path, Checksum: checksum,
|
||||||
|
})
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunner) {
|
func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunner) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
@@ -594,10 +982,11 @@ func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunn
|
|||||||
t.Fatalf("MkdirAll(bundle unknown) error = %v", err)
|
t.Fatalf("MkdirAll(bundle unknown) error = %v", err)
|
||||||
}
|
}
|
||||||
files := map[string]string{
|
files := map[string]string{
|
||||||
"index.json": `{"manifest_file":"manifest.json","output_files":[{"lane_id":"npc-registry","file":"lanes/npc.json","media_type":"application/json","module_key":"dnd/npc-registry","schema_id":"notarius.dnd.npc_registry","schema_version":"v1","future_field":true},{"lane_id":"unconfigured","file":"lanes/unconfigured.json"}],"rejected_file":"rejected.json","warnings_file":"warnings.json","future_field":true}`,
|
"index.json": `{"manifest_file":"manifest.json","output_files":[{"lane_id":"npc-registry","file":"lanes/npc.json","media_type":"application/json","module_key":"dnd/npc-registry","schema_id":"notarius.dnd.npc_registry","schema_version":"v1","future_field":true},{"lane_id":"unconfigured","file":"lanes/unconfigured.json"}],"rejected_file":"rejected.json","warnings_file":"warnings.json","diagnostics_file":"diagnostics.json","future_field":true}`,
|
||||||
"manifest.json": `{}`,
|
"manifest.json": `{}`,
|
||||||
"rejected.json": `{"rejected":[]}`,
|
"rejected.json": `{"rejected":[]}`,
|
||||||
"warnings.json": `{"warnings":[]}`,
|
"warnings.json": `{"schema_version":"notarius.warnings.v2","group_count":0,"occurrence_count":0,"groups":[]}`,
|
||||||
|
"diagnostics.json": `{"schema_version":"notarius.diagnostics.v1","group_count":0,"occurrence_count":0,"truncated":false,"unrepresented_occurrence_count":0,"groups":[]}`,
|
||||||
"lanes/npc.json": `{"npcs":[]}`,
|
"lanes/npc.json": `{"npcs":[]}`,
|
||||||
"lanes/unconfigured.json": `{"spells":[]}`,
|
"lanes/unconfigured.json": `{"spells":[]}`,
|
||||||
"chunk-map.json": `{"chunks":[]}`,
|
"chunk-map.json": `{"chunks":[]}`,
|
||||||
@@ -613,12 +1002,14 @@ func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunn
|
|||||||
Receipt: notarius.Receipt{
|
Receipt: notarius.Receipt{
|
||||||
SchemaVersion: notarius.ReceiptSchemaVersion, RunID: "notarius-run-1", PipelineID: "dnd-session",
|
SchemaVersion: notarius.ReceiptSchemaVersion, RunID: "notarius-run-1", PipelineID: "dnd-session",
|
||||||
OutputDirectory: bundle, IndexFile: "index.json", NormalizedOutputCount: 2,
|
OutputDirectory: bundle, IndexFile: "index.json", NormalizedOutputCount: 2,
|
||||||
RejectedOutputCount: 1, WarningCount: 1, ValidationStatus: "rejected",
|
RejectedOutputCount: 1, WarningGroupCount: 1, WarningOccurrenceCount: 1,
|
||||||
|
DiagnosticGroupCount: 1, DiagnosticOccurrenceCount: 1, ValidationStatus: "rejected",
|
||||||
},
|
},
|
||||||
BundleRoot: bundle,
|
BundleRoot: bundle,
|
||||||
Index: notarius.Index{
|
Index: notarius.Index{
|
||||||
Path: filepath.Join(bundle, "index.json"), RejectedPath: filepath.Join(bundle, "rejected.json"),
|
Path: filepath.Join(bundle, "index.json"), RejectedPath: filepath.Join(bundle, "rejected.json"),
|
||||||
WarningsPath: filepath.Join(bundle, "warnings.json"),
|
WarningsPath: filepath.Join(bundle, "warnings.json"),
|
||||||
|
DiagnosticsPath: filepath.Join(bundle, "diagnostics.json"),
|
||||||
Lanes: []notarius.LaneDescriptor{
|
Lanes: []notarius.LaneDescriptor{
|
||||||
{
|
{
|
||||||
LaneID: "npc-registry", File: "lanes/npc.json", Path: filepath.Join(bundle, "lanes", "npc.json"),
|
LaneID: "npc-registry", File: "lanes/npc.json", Path: filepath.Join(bundle, "lanes", "npc.json"),
|
||||||
@@ -629,7 +1020,14 @@ func setupExtractEnv(t *testing.T) (*Env, *manifest.Manifest, *notarius.FakeRunn
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Rejections: []notarius.RejectionSummary{{LaneID: "optional", ReasonCode: "optional_rejected"}},
|
Rejections: []notarius.RejectionSummary{{LaneID: "optional", ReasonCode: "optional_rejected"}},
|
||||||
Warnings: []notarius.WarningSummary{{Scope: "lane:npc-registry", ReasonCode: "normalized_name"}},
|
Warnings: []notarius.WarningSummary{{
|
||||||
|
Disposition: "warning", Category: "degradation", ReasonCode: "normalized_name",
|
||||||
|
Origin: notarius.DiagnosticOrigin{Stage: "normalize", LaneID: "npc-registry"}, OccurrenceCount: 1,
|
||||||
|
}},
|
||||||
|
Diagnostics: []notarius.DiagnosticSummary{{
|
||||||
|
Disposition: "advisory", Category: "data_quality", ReasonCode: "low_confidence",
|
||||||
|
Origin: notarius.DiagnosticOrigin{Stage: "normalize", ValidatorKey: "validator"}, OccurrenceCount: 1,
|
||||||
|
}},
|
||||||
}}
|
}}
|
||||||
env := &Env{
|
env := &Env{
|
||||||
Config: &config.Config{
|
Config: &config.Config{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/audio"
|
"gitea.maximumdirect.net/eric/narratio/internal/audio"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
@@ -64,6 +65,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc)
|
glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc)
|
||||||
playersInput := stableInputSource(env.Config.StableInputs.PlayersFile, env.Config.Session.Inputs.PlayersFile, sessionSrc)
|
playersInput := stableInputSource(env.Config.StableInputs.PlayersFile, env.Config.Session.Inputs.PlayersFile, sessionSrc)
|
||||||
partyInput := stableInputSource(env.Config.StableInputs.PartyFile, env.Config.Session.Inputs.PartyFile, sessionSrc)
|
partyInput := stableInputSource(env.Config.StableInputs.PartyFile, env.Config.Session.Inputs.PartyFile, sessionSrc)
|
||||||
|
spellCatalogInput := stableInputSource(env.Config.StableInputs.SpellCatalogFile, env.Config.Session.Inputs.SpellCatalogFile, sessionSrc)
|
||||||
|
|
||||||
speakersSrc, err := resolveConfigRelativePath(speakersInput)
|
speakersSrc, err := resolveConfigRelativePath(speakersInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -85,6 +87,17 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("prepare: party path: %w", err)
|
return nil, fmt.Errorf("prepare: party path: %w", err)
|
||||||
}
|
}
|
||||||
|
spellCatalogConfigured := strings.TrimSpace(spellCatalogInput.Path) != ""
|
||||||
|
spellCatalogSrc := ""
|
||||||
|
if spellCatalogConfigured {
|
||||||
|
spellCatalogSrc, err = resolveConfigRelativePath(spellCatalogInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare: spell catalog path: %w", err)
|
||||||
|
}
|
||||||
|
if err := requireRegularReadableFile(spellCatalogSrc, "spell catalog"); err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, required := range []struct {
|
for _, required := range []struct {
|
||||||
path string
|
path string
|
||||||
@@ -106,7 +119,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err)
|
return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
inputs := make([]manifest.InputRecord, 0, 8+len(resolvedLocalAudio))
|
inputs := make([]manifest.InputRecord, 0, 9+len(resolvedLocalAudio))
|
||||||
registerInput := func(kind, path, checksum string) {
|
registerInput := func(kind, path, checksum string) {
|
||||||
inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum})
|
inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum})
|
||||||
}
|
}
|
||||||
@@ -155,18 +168,51 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
}
|
}
|
||||||
registerInput("pipeline_resolved", pipelineDst, pipelineChecksum)
|
registerInput("pipeline_resolved", pipelineDst, pipelineChecksum)
|
||||||
|
|
||||||
for _, cfgFile := range []struct {
|
type preparedConfigFile struct {
|
||||||
kind string
|
kind string
|
||||||
src string
|
src string
|
||||||
dst string
|
dst string
|
||||||
source string
|
source string
|
||||||
}{
|
}
|
||||||
|
preparedConfigFiles := []preparedConfigFile{
|
||||||
{kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source},
|
{kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source},
|
||||||
{kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source},
|
{kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source},
|
||||||
{kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source},
|
}
|
||||||
{kind: "players", src: playersSrc, dst: filepath.Join(paths.InputsDir, "players.yml"), source: playersInput.Source},
|
for _, prepared := range []struct {
|
||||||
{kind: "party", src: partySrc, dst: filepath.Join(paths.InputsDir, "party.yml"), source: partyInput.Source},
|
sourceID string
|
||||||
|
input config.ResolvedInputFile
|
||||||
|
src string
|
||||||
|
}{
|
||||||
|
{sourceID: artifactpolicy.SourceInputGlossary, input: glossaryInput, src: glossarySrc},
|
||||||
|
{sourceID: artifactpolicy.SourceInputPlayers, input: playersInput, src: playersSrc},
|
||||||
|
{sourceID: artifactpolicy.SourceInputParty, input: partyInput, src: partySrc},
|
||||||
} {
|
} {
|
||||||
|
descriptor, ok := artifactpolicy.DescribePreparedInputSource(prepared.sourceID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("prepare: prepared-input descriptor %q is unavailable", prepared.sourceID)
|
||||||
|
}
|
||||||
|
preparedConfigFiles = append(preparedConfigFiles, preparedConfigFile{
|
||||||
|
kind: descriptor.ManifestKind, src: prepared.src,
|
||||||
|
dst: filepath.Join(paths.InputsDir, descriptor.Filename), source: prepared.input.Source,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
spellDescriptor, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputSpellCatalog)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("prepare: spell catalog prepared-input descriptor is unavailable")
|
||||||
|
}
|
||||||
|
spellCatalogDst := filepath.Join(paths.InputsDir, spellDescriptor.Filename)
|
||||||
|
if spellCatalogConfigured {
|
||||||
|
preparedConfigFiles = append(preparedConfigFiles, preparedConfigFile{
|
||||||
|
kind: spellDescriptor.ManifestKind,
|
||||||
|
src: spellCatalogSrc,
|
||||||
|
dst: spellCatalogDst,
|
||||||
|
source: spellCatalogInput.Source,
|
||||||
|
})
|
||||||
|
} else if err := fileops.RemoveFileUnderRoot(paths.Root, spellCatalogDst); err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare: remove obsolete spell catalog: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cfgFile := range preparedConfigFiles {
|
||||||
checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst)
|
checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("prepare: materialize %s: %w", cfgFile.kind, err)
|
return nil, fmt.Errorf("prepare: materialize %s: %w", cfgFile.kind, err)
|
||||||
@@ -585,6 +631,40 @@ func requireFile(path string, label string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func requireRegularReadableFile(path string, label string) error {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return fmt.Errorf("%s path is required", label)
|
||||||
|
}
|
||||||
|
declared, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect %s %q: %w", label, path, err)
|
||||||
|
}
|
||||||
|
if declared.Mode()&os.ModeSymlink != 0 || !declared.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("%s %q is not a regular file without symlinks", label, path)
|
||||||
|
}
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s %q: %w", label, path, err)
|
||||||
|
}
|
||||||
|
info, statErr := file.Stat()
|
||||||
|
closeErr := file.Close()
|
||||||
|
if statErr != nil {
|
||||||
|
return fmt.Errorf("stat %s %q: %w", label, path, statErr)
|
||||||
|
}
|
||||||
|
current, currentErr := os.Lstat(path)
|
||||||
|
if currentErr != nil {
|
||||||
|
return fmt.Errorf("reinspect %s %q: %w", label, path, currentErr)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() || current.Mode()&os.ModeSymlink != 0 || !current.Mode().IsRegular() ||
|
||||||
|
!os.SameFile(info, declared) || !os.SameFile(info, current) {
|
||||||
|
return fmt.Errorf("%s %q changed while being opened", label, path)
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return fmt.Errorf("close %s %q: %w", label, path, closeErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func isFlac(path string) bool {
|
func isFlac(path string) bool {
|
||||||
return strings.EqualFold(filepath.Ext(path), ".flac")
|
return strings.EqualFold(filepath.Ext(path), ".flac")
|
||||||
}
|
}
|
||||||
|
|||||||
45
internal/stage/prepare_fifo_test.go
Normal file
45
internal/stage/prepare_fifo_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package stage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareStageRejectsSpellCatalogFIFOWithoutBlocking(t *testing.T) {
|
||||||
|
env, m := setupPrepareEnv(t)
|
||||||
|
root := filepath.Dir(env.Config.SessionPath)
|
||||||
|
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
|
||||||
|
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
|
||||||
|
|
||||||
|
sourcePath := filepath.Join(root, "spells.fifo")
|
||||||
|
if err := unix.Mkfifo(sourcePath, 0o644); err != nil {
|
||||||
|
t.Fatalf("Mkfifo() error = %v", err)
|
||||||
|
}
|
||||||
|
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
|
||||||
|
Path: "./spells.fifo",
|
||||||
|
ConfigPath: env.Config.CampaignPath,
|
||||||
|
Source: "campaign_config",
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := (prepareStage{}).Run(context.Background(), env, m)
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "not a regular file") {
|
||||||
|
t.Fatalf("prepare.Run() error = %v, want regular-file rejection", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("prepare.Run() blocked while inspecting a FIFO spell catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package stage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -154,6 +156,167 @@ func TestPrepareStageIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrepareStageMaterializesSpellCatalogWithProvenance(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configPath func(*config.Config) string
|
||||||
|
source string
|
||||||
|
}{
|
||||||
|
{name: "campaign", configPath: func(cfg *config.Config) string { return cfg.CampaignPath }, source: "campaign_config"},
|
||||||
|
{name: "session", configPath: func(cfg *config.Config) string { return cfg.SessionPath }, source: "session_config"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
env, m := setupPrepareEnv(t)
|
||||||
|
root := filepath.Dir(env.Config.SessionPath)
|
||||||
|
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
|
||||||
|
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
|
||||||
|
|
||||||
|
payload := []byte(`{"spells":[{"name":"Fireball"}]}` + "\n")
|
||||||
|
sourcePath := filepath.Join(filepath.Dir(tt.configPath(env.Config)), tt.name+"-spells.json")
|
||||||
|
writeFile(t, sourcePath, string(payload))
|
||||||
|
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
|
||||||
|
Path: filepath.Base(sourcePath),
|
||||||
|
ConfigPath: tt.configPath(env.Config),
|
||||||
|
Source: tt.source,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
|
||||||
|
got, err := os.ReadFile(destination)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(destination) error = %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != string(payload) {
|
||||||
|
t.Fatalf("destination bytes = %q, want %q", got, payload)
|
||||||
|
}
|
||||||
|
record := findManifestInput(t, m.Inputs, "spell_catalog")
|
||||||
|
digest := sha256.Sum256(payload)
|
||||||
|
wantChecksum := hex.EncodeToString(digest[:])
|
||||||
|
if record.Path != destination || record.Source != tt.source || record.Checksum != wantChecksum {
|
||||||
|
t.Fatalf("spell catalog record = %#v, want path=%q source=%q checksum=%q", record, destination, tt.source, wantChecksum)
|
||||||
|
}
|
||||||
|
assertManifestInputsSorted(t, m.Inputs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareStageSpellCatalogOptionalOmission(t *testing.T) {
|
||||||
|
env, m := setupPrepareEnv(t)
|
||||||
|
root := filepath.Dir(env.Config.SessionPath)
|
||||||
|
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
|
||||||
|
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
|
||||||
|
|
||||||
|
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
for _, input := range m.Inputs {
|
||||||
|
if input.Kind == "spell_catalog" {
|
||||||
|
t.Fatalf("unexpected spell catalog record: %#v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
|
||||||
|
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("Lstat(destination) error = %v, want not exist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareStageSpellCatalogMissingOrNonRegularSource(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(string) error
|
||||||
|
}{
|
||||||
|
{name: "missing", setup: func(string) error { return nil }},
|
||||||
|
{name: "directory", setup: func(path string) error { return os.Mkdir(path, 0o755) }},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
env, m := setupPrepareEnv(t)
|
||||||
|
root := filepath.Dir(env.Config.SessionPath)
|
||||||
|
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
|
||||||
|
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
|
||||||
|
sourcePath := filepath.Join(root, "spells.json")
|
||||||
|
if err := tt.setup(sourcePath); err != nil {
|
||||||
|
t.Fatalf("setup source: %v", err)
|
||||||
|
}
|
||||||
|
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
|
||||||
|
Path: "./spells.json",
|
||||||
|
ConfigPath: env.Config.CampaignPath,
|
||||||
|
Source: "campaign_config",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := (prepareStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "spell catalog") {
|
||||||
|
t.Fatalf("prepare.Run() error = %v, want spell catalog source error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareStageReplacesAndRemovesSpellCatalog(t *testing.T) {
|
||||||
|
env, m := setupPrepareEnv(t)
|
||||||
|
root := filepath.Dir(env.Config.SessionPath)
|
||||||
|
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
|
||||||
|
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
|
||||||
|
sourcePath := filepath.Join(root, "spells.json")
|
||||||
|
writeFile(t, sourcePath, "first\n")
|
||||||
|
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
|
||||||
|
Path: "./spells.json",
|
||||||
|
ConfigPath: env.Config.CampaignPath,
|
||||||
|
Source: "campaign_config",
|
||||||
|
}
|
||||||
|
|
||||||
|
stage := prepareStage{}
|
||||||
|
if _, err := stage.Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("first prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
firstChecksum := findManifestInput(t, m.Inputs, "spell_catalog").Checksum
|
||||||
|
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
|
||||||
|
|
||||||
|
writeFile(t, sourcePath, "second\n")
|
||||||
|
if _, err := stage.Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("replacement prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
secondChecksum := findManifestInput(t, m.Inputs, "spell_catalog").Checksum
|
||||||
|
if secondChecksum == firstChecksum {
|
||||||
|
t.Fatalf("replacement checksum = %q, want different from %q", secondChecksum, firstChecksum)
|
||||||
|
}
|
||||||
|
mustReadFileEquals(t, destination, "second\n")
|
||||||
|
|
||||||
|
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{}
|
||||||
|
env.Config.Session.Inputs.SpellCatalogFile = ""
|
||||||
|
if _, err := stage.Run(context.Background(), env, m); err != nil {
|
||||||
|
t.Fatalf("removal prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("Lstat(destination) error = %v, want removed", err)
|
||||||
|
}
|
||||||
|
for _, input := range m.Inputs {
|
||||||
|
if input.Kind == "spell_catalog" {
|
||||||
|
t.Fatalf("stale spell catalog record remains: %#v", input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareStageRefusesAmbiguousObsoleteSpellCatalog(t *testing.T) {
|
||||||
|
env, m := setupPrepareEnv(t)
|
||||||
|
root := filepath.Dir(env.Config.SessionPath)
|
||||||
|
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
|
||||||
|
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
|
||||||
|
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
|
||||||
|
writeFile(t, filepath.Join(destination, "keep.txt"), "keep")
|
||||||
|
|
||||||
|
_, err := (prepareStage{}).Run(context.Background(), env, m)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "remove obsolete spell catalog") || !strings.Contains(err.Error(), "non-regular") {
|
||||||
|
t.Fatalf("prepare.Run() error = %v, want ambiguous destination rejection", err)
|
||||||
|
}
|
||||||
|
mustReadFileEquals(t, filepath.Join(destination, "keep.txt"), "keep")
|
||||||
|
}
|
||||||
|
|
||||||
func TestPrepareStageRecordsLocalSessionProvenance(t *testing.T) {
|
func TestPrepareStageRecordsLocalSessionProvenance(t *testing.T) {
|
||||||
env, m := setupPrepareEnv(t)
|
env, m := setupPrepareEnv(t)
|
||||||
root := filepath.Dir(env.Config.SessionPath)
|
root := filepath.Dir(env.Config.SessionPath)
|
||||||
@@ -719,6 +882,17 @@ func findManifestInput(t *testing.T, inputs []manifest.InputRecord, kind string)
|
|||||||
return manifest.InputRecord{}
|
return manifest.InputRecord{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertManifestInputsSorted(t *testing.T, inputs []manifest.InputRecord) {
|
||||||
|
t.Helper()
|
||||||
|
for index := 1; index < len(inputs); index++ {
|
||||||
|
previous := inputs[index-1]
|
||||||
|
current := inputs[index]
|
||||||
|
if previous.Kind > current.Kind || (previous.Kind == current.Kind && previous.Path > current.Path) {
|
||||||
|
t.Fatalf("manifest inputs are not sorted at %d: %#v before %#v", index, previous, current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustReadFileEquals(t *testing.T, path, want string) {
|
func mustReadFileEquals(t *testing.T, path, want string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
|
|||||||
Reference in New Issue
Block a user