Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b4b328c4e | |||
| ee2b8e63e6 | |||
| 51edd384c0 | |||
| b804d0f2c8 | |||
| fd5ccc668b | |||
| 0dc8ff9b52 | |||
| 8657a28bdb | |||
| a9c5e4ad4e | |||
| 2176b4371d | |||
| effc10d75b | |||
| 5887839aa1 | |||
| 3128bef20a | |||
| 4e4e2b7d96 | |||
| 99f4f9a0db | |||
| 6abdd67bb5 | |||
| c32e0c401f | |||
| ab5751459a | |||
| 62de6abdbf | |||
| 903dc70682 | |||
| 23c714da66 | |||
| 6639775d7d | |||
| 966b95b176 | |||
| 3bcf2c08dd | |||
| 700ab655ca | |||
| 85c5647385 | |||
| 2ef7c76d99 | |||
| 9bc1b0feda | |||
| 5cec84a4a7 | |||
| abfbe42d61 | |||
| e7e3bef1e4 | |||
| a68e8e31a4 | |||
| 3a9e60cda9 | |||
| 905ff03ccc | |||
| 495f7bcde4 | |||
| 51e0e8c5d0 | |||
| a2409a1fd1 | |||
| b3363f87d6 | |||
| 5831c0c9e6 | |||
| 42ed81cbe1 |
@@ -58,6 +58,16 @@ steps:
|
|||||||
build_binary windows amd64 ".exe"
|
build_binary windows amd64 ".exe"
|
||||||
build_binary windows arm64 ".exe"
|
build_binary windows arm64 ".exe"
|
||||||
|
|
||||||
|
smoke_binary="$dist/narratio-version-smoke"
|
||||||
|
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=$version" \
|
||||||
|
-o "$smoke_binary" "$pkg"
|
||||||
|
reported_version="$("$smoke_binary" version)"
|
||||||
|
rm -f "$smoke_binary"
|
||||||
|
if [ "$reported_version" != "narratio $version" ]; then
|
||||||
|
echo "release binary reported unexpected version: $reported_version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
image: woodpeckerci/plugin-release
|
image: woodpeckerci/plugin-release
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
79
docs/cli.md
79
docs/cli.md
@@ -12,7 +12,9 @@ This runs the canonical full pipeline for session `2026-04-04`.
|
|||||||
|
|
||||||
Top-level commands:
|
Top-level commands:
|
||||||
|
|
||||||
- `run <session_id>`: run full stage order.
|
- `version`: print the Narratio build version.
|
||||||
|
- `run <session_id>`: run all or one contiguous range of the canonical stage order.
|
||||||
|
- `regenerate-artifacts <session_id>`: force-run extraction through analysis.
|
||||||
- `run-stage <stage> <session_id>`: run one stage.
|
- `run-stage <stage> <session_id>`: run one stage.
|
||||||
- `analyze <session_id>`: force-run analyze.
|
- `analyze <session_id>`: force-run analyze.
|
||||||
- `publish <session_id>`: force-run publish.
|
- `publish <session_id>`: force-run publish.
|
||||||
@@ -70,22 +72,63 @@ Commands with additional positionals keep their command-specific order:
|
|||||||
|
|
||||||
## Command Reference
|
## Command Reference
|
||||||
|
|
||||||
|
### `version`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio version
|
||||||
|
```
|
||||||
|
|
||||||
|
Official release binaries report their exact Git tag. Binaries built directly
|
||||||
|
from source without release linker metadata report `dev`.
|
||||||
|
|
||||||
### `run`
|
### `run`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio run <session_id> [--force] [--artifacts <name[,name...]>] [...common config flags]
|
narratio run <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- evaluates full stage order;
|
- evaluates one inclusive contiguous range of the canonical stage order;
|
||||||
- runs `extract` between `trim` and `render`; an omitted or disabled Notarius
|
- defaults an omitted `--from` to `prepare` and an omitted `--through` to
|
||||||
|
`notify`, so omitting both retains full-pipeline behavior;
|
||||||
|
- rejects unknown endpoints and a `--from` endpoint after `--through`;
|
||||||
|
- runs `render` before `extract`; an omitted or disabled Notarius
|
||||||
configuration records an explicit `notarius_disabled` self-skip;
|
configuration records an explicit `notarius_disabled` self-skip;
|
||||||
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
- skips already-succeeded stages unless `--force` is set or a stage-specific
|
||||||
resume check finds its durable result obsolete;
|
resume check finds its durable result obsolete;
|
||||||
|
- applies `--force` only to stages in the selected range;
|
||||||
|
- rejects repeated `--from`, `--through`, or `--force` options, including
|
||||||
|
`--name=value` spellings;
|
||||||
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- writes session and run manifests.
|
- writes session and run manifests.
|
||||||
|
|
||||||
|
When `--artifacts` is present, the selected range must contain `analyze` or
|
||||||
|
`publish`. Either consumer is sufficient, including a one-stage range.
|
||||||
|
|
||||||
|
### `regenerate-artifacts`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio regenerate-artifacts <session_id> [--artifacts <name[,name...]>] [...common config flags]
|
||||||
|
```
|
||||||
|
|
||||||
|
Exactly equivalent to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio run <session_id> --force --from extract --through analyze [caller options]
|
||||||
|
```
|
||||||
|
|
||||||
|
The command always reruns extraction. Analysis rebuilds the selected configured
|
||||||
|
artifacts and any prerequisites required by those targets; without
|
||||||
|
`--artifacts`, it uses the normal default analysis selection. Publish and notify
|
||||||
|
never run. Common session/configuration options and repeatable artifact values
|
||||||
|
pass through unchanged.
|
||||||
|
|
||||||
|
Because the expansion owns `--force`, `--from`, and `--through`, callers cannot
|
||||||
|
supply those options. The shared `run` parser reports them as duplicate
|
||||||
|
singleton flags. The alias has no private execution options or behavior, and
|
||||||
|
runtime diagnostics may identify the operation as `run`.
|
||||||
|
|
||||||
### `run-stage`
|
### `run-stage`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -100,8 +143,8 @@ Valid stage names:
|
|||||||
- `polish`
|
- `polish`
|
||||||
- `normalize`
|
- `normalize`
|
||||||
- `trim`
|
- `trim`
|
||||||
- `extract`
|
|
||||||
- `render`
|
- `render`
|
||||||
|
- `extract`
|
||||||
- `analyze`
|
- `analyze`
|
||||||
- `publish`
|
- `publish`
|
||||||
- `notify`
|
- `notify`
|
||||||
@@ -153,10 +196,17 @@ post-publish cleanup behavior.
|
|||||||
### `session plan`
|
### `session plan`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
narratio session plan <session_id> [--force] [...common config flags]
|
narratio session plan <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [...common config flags]
|
||||||
```
|
```
|
||||||
|
|
||||||
Validates config, prepares local workdir layout, and prints run/skip decisions for each stage.
|
Uses the same inclusive bounds, endpoint validation, force scope, and artifact
|
||||||
|
selection contract as `run`. It validates config and prints run/skip decisions
|
||||||
|
for selected stages only without creating the local workdir or changing the
|
||||||
|
manifest. Resume-capable selected stages are checked against durable evidence.
|
||||||
|
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
|
||||||
|
execution order, and reusable current artifacts with concise reasons. These
|
||||||
|
artifact decisions come from the same reconciliation and work planner used by
|
||||||
|
execution; the preview does not predict output identities.
|
||||||
|
|
||||||
### `session validate`
|
### `session validate`
|
||||||
|
|
||||||
@@ -252,14 +302,17 @@ and precedence.
|
|||||||
|
|
||||||
## `--artifacts` Selection Rules
|
## `--artifacts` Selection Rules
|
||||||
|
|
||||||
- accepted on `run`, `run-stage`, `analyze`, and `publish`;
|
- accepted on `run`, `session plan`, `run-stage`, `analyze`, and `publish`;
|
||||||
|
- repeatable and comma-separated values are combined, surrounding whitespace
|
||||||
|
is removed, and duplicate names are collapsed;
|
||||||
- names must exist in `pipeline.scriptorium.artifacts`;
|
- names must exist in `pipeline.scriptorium.artifacts`;
|
||||||
- empty entries are invalid;
|
- empty entries are invalid;
|
||||||
- repeated names are deduplicated.
|
- on `run-stage`, only `analyze` and `publish` accept the option.
|
||||||
|
|
||||||
Effects:
|
Effects:
|
||||||
|
|
||||||
- filters analyze execution to selected configured artifacts;
|
- selects explicit analyze targets; required configured prerequisites may be
|
||||||
|
reused or rebuilt before them;
|
||||||
- filters publish rules that source `narratio.artifact.<name>`;
|
- filters publish rules that source `narratio.artifact.<name>`;
|
||||||
- does not filter built-in transcript/bounds or explicitly configured
|
- does not filter built-in transcript/bounds or explicitly configured
|
||||||
`narratio.extraction.<name>` publish sources; and
|
`narratio.extraction.<name>` publish sources; and
|
||||||
@@ -291,6 +344,12 @@ Force publish only:
|
|||||||
narratio publish 2026-04-04
|
narratio publish 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Regenerate post-transcript artifacts without publishing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
|
||||||
|
```
|
||||||
|
|
||||||
## Output And Exit Behavior
|
## Output And Exit Behavior
|
||||||
|
|
||||||
- Successful commands write their result or summary to standard output and
|
- Successful commands write their result or summary to standard output and
|
||||||
|
|||||||
@@ -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>`:
|
||||||
@@ -268,7 +310,7 @@ For each `pipeline.scriptorium.artifacts.<name>`:
|
|||||||
| Field | Type | Required | Rule |
|
| Field | Type | Required | Rule |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `enabled` | bool | No | `false` if omitted |
|
| `enabled` | bool | No | `false` if omitted |
|
||||||
| `depends_on[]` | list[string] | No | must reference configured artifact keys; no self-reference; enabled graph must be acyclic |
|
| `depends_on[]` | list[string] | No | must reference configured artifact keys; no self-reference; configured graph must be acyclic |
|
||||||
| `render_debug` | bool | No | per-artifact override |
|
| `render_debug` | bool | No | per-artifact override |
|
||||||
| `prompt_id` | string | Conditional | required when artifact is enabled |
|
| `prompt_id` | string | Conditional | required when artifact is enabled |
|
||||||
| `profile_id` | string | No | empty |
|
| `profile_id` | string | No | empty |
|
||||||
@@ -281,12 +323,13 @@ Narratio adds `session_id=narratio-session-<session_id>` to every Scriptorium re
|
|||||||
|
|
||||||
Without `--artifacts`, analyze executes enabled configured artifacts. With an
|
Without `--artifacts`, analyze executes enabled configured artifacts. With an
|
||||||
explicit `--artifacts` list, the exact named configured artifacts are the
|
explicit `--artifacts` list, the exact named configured artifacts are the
|
||||||
one-invocation execution set even if their `enabled` values are false; the list
|
one-invocation targets even if their `enabled` values are false. Analyze closes
|
||||||
does not automatically include dependencies. Named artifacts must therefore be
|
those targets over `depends_on`: a current prerequisite is reused, while a
|
||||||
configured with valid executable fields, and their configured dependencies must
|
stale, missing, failed, or legacy prerequisite is rebuilt before its dependent.
|
||||||
already be available to analyze. This override affects analyze planning only;
|
Unrelated artifacts are not executed. Named targets and any prerequisite that
|
||||||
publish uses the list only to filter configured
|
may require rebuilding must therefore have valid executable fields. This
|
||||||
`narratio.artifact.<name>` output rules.
|
override affects analyze planning only; publish uses the list only to filter
|
||||||
|
configured `narratio.artifact.<name>` output rules.
|
||||||
|
|
||||||
For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_name>`:
|
For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_name>`:
|
||||||
|
|
||||||
@@ -318,6 +361,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 +377,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
|
||||||
|
|||||||
@@ -35,18 +35,33 @@ Adapters do not own:
|
|||||||
|
|
||||||
## Default Wiring
|
## Default Wiring
|
||||||
|
|
||||||
`internal/app/runner.go` initializes default adapters when not injected:
|
`internal/app/runner.go` initializes default adapters when not injected and
|
||||||
|
only when the selected execution plan needs them:
|
||||||
|
|
||||||
- WhisperX HTTP client from pipeline config.
|
- WhisperX HTTP client for `transcribe`.
|
||||||
- Seriatim subprocess runner.
|
- Seriatim subprocess runner for `merge`, `normalize`, `trim`, or `render`.
|
||||||
- Audita subprocess runner.
|
- Audita subprocess runner for `polish`.
|
||||||
- Scriptorium subprocess runner.
|
- Scriptorium subprocess runner for `trim` or `analyze`.
|
||||||
- Notarius subprocess runner when extraction is enabled.
|
- Notarius subprocess runner for `extract` when extraction is enabled.
|
||||||
- Noop notifier (`notify.NoopSender`).
|
- Noop notifier (`notify.NoopSender`) for `notify`.
|
||||||
- Object store only when required by selected stages/config.
|
- Object store only when required by selected stages/config.
|
||||||
|
|
||||||
|
Remote publish locks are loaded only for a selected, enabled publish that
|
||||||
|
uploads a run. Shared session lifecycle setup still applies to every selected
|
||||||
|
range, but an unselected integration is neither initialized nor validated by
|
||||||
|
runner composition. Each selected stage retains its own fail-fast configuration
|
||||||
|
and input validation.
|
||||||
|
|
||||||
|
`session plan` is outside production adapter composition. It performs
|
||||||
|
resume validation and models selected transitions against cloned manifest
|
||||||
|
state without constructing or invoking stage-execution adapters. The shared
|
||||||
|
command configuration loader may still use object storage to retrieve a missing
|
||||||
|
remote session file before planning begins.
|
||||||
|
|
||||||
Notarius is composed only when extraction is enabled; the extract stage owns
|
Notarius is composed only when extraction is enabled; the extract stage owns
|
||||||
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,28 +36,45 @@ unrecognized token into a valid source. Extraction sources are registered only
|
|||||||
from `pipeline.notarius.outputs`; the Notarius index has no selectable source
|
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:
|
||||||
|
|
||||||
- `planned`: source registered for run context;
|
- `planned`: source registered for run context;
|
||||||
- `executable`: included in the effective analyze artifact set;
|
- `executable`: included in the effective analyze artifact set;
|
||||||
- `available`: local file exists and validates;
|
- `available`: the source's canonical evidence owner validates its current
|
||||||
|
manifest record and durable bytes;
|
||||||
- `provenance`: availability source.
|
- `provenance`: availability source.
|
||||||
|
|
||||||
Configured definitions are always registered. Without an explicit selection,
|
Configured definitions are always registered. Without an explicit selection,
|
||||||
the effective analyze set contains enabled definitions. With `--artifacts`, the
|
the effective analyze set contains enabled definitions. With `--artifacts`, the
|
||||||
exact named configured definitions become the effective set for that invocation,
|
exact named configured definitions become the effective set for that invocation,
|
||||||
regardless of their `enabled` value; dependencies are not added implicitly.
|
regardless of their `enabled` value. The effective-set resolver itself does not
|
||||||
Availability is separate from executability: a non-executable configured output
|
expand dependencies; the analyze work planner closes those targets over their
|
||||||
may be reused from a canonical non-empty file, while an executable definition
|
configured prerequisite graph. Availability is separate from executability.
|
||||||
is generated by analyze. Extraction entries are registered from configuration
|
Configured outputs, including non-executable prerequisites, become available
|
||||||
and become available only after compatible extraction evidence is hydrated.
|
only when the versioned analyze state identifies a current result whose source,
|
||||||
|
contract, canonical configured path, size, and checksum match a confined
|
||||||
|
no-follow regular file. An incidental canonical file and a legacy aggregate
|
||||||
|
analyze output are unavailable.
|
||||||
|
Extraction entries are registered from configuration and become available only
|
||||||
|
after compatible extraction evidence is hydrated.
|
||||||
|
|
||||||
|
During an analyze invocation, a newly validated and atomically materialized
|
||||||
|
configured output is marked available with its producer run ID, contract,
|
||||||
|
checksum, and size. Later scheduled dependents therefore observe the same
|
||||||
|
semantic identity whether their prerequisite was reused from current manifest
|
||||||
|
evidence or produced earlier in the invocation.
|
||||||
|
|
||||||
Current provenance values:
|
Current provenance values:
|
||||||
|
|
||||||
- `generated.current_analyze_run`
|
- `generated.current_analyze_run`
|
||||||
- `filesystem.disabled_artifact_output`
|
- `manifest.current_analyze_artifact`
|
||||||
- `manifest.inputs.previous_cache`
|
- `manifest.inputs.previous_cache`
|
||||||
- `current_session.previous_cache`
|
- `current_session.previous_cache`
|
||||||
|
|
||||||
@@ -70,7 +87,26 @@ Built-ins:
|
|||||||
|
|
||||||
Configured sources (`narratio.artifact.*`):
|
Configured sources (`narratio.artifact.*`):
|
||||||
|
|
||||||
- resolve only through runtime catalog availability.
|
- resolve only through runtime catalog availability;
|
||||||
|
- use the shared typed analyze-evidence inspection in
|
||||||
|
`analyze_evidence.go` for prior current-session results;
|
||||||
|
- require the supported analyze-state and fingerprint versions, a `current`
|
||||||
|
record for the exact configured key and source ID, a complete contract, the
|
||||||
|
configured canonical relative path, positive stored size, and stored
|
||||||
|
checksum matching bytes read from a confined no-follow regular file; and
|
||||||
|
- treat non-current statuses, legacy or malformed records, removed keys,
|
||||||
|
unsafe or missing files, and size/checksum mismatches as unavailable without
|
||||||
|
rewriting manifest state. Catalog construction iterates current
|
||||||
|
configuration, so removed or renamed records are not advertised.
|
||||||
|
|
||||||
|
Prepared stable sources (`narratio.input.*`):
|
||||||
|
|
||||||
|
- 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.*`):
|
||||||
|
|
||||||
@@ -199,7 +235,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`
|
||||||
|
|||||||
@@ -35,6 +35,79 @@ The model admits these stage states:
|
|||||||
- `stale`
|
- `stale`
|
||||||
- `interrupted`
|
- `interrupted`
|
||||||
|
|
||||||
|
### Analyze-owned artifact state
|
||||||
|
|
||||||
|
The `analyze` stage record may carry `analyze_state_version: 1` and an
|
||||||
|
`analyze_artifacts` map keyed by normalized configured artifact key. The
|
||||||
|
version is the authority marker: version 1 with no entries is a valid evaluated
|
||||||
|
empty set, while an absent version is legacy aggregate-only state and provides
|
||||||
|
no current configured-artifact evidence.
|
||||||
|
|
||||||
|
Each analyze artifact record has one disposition:
|
||||||
|
|
||||||
|
- `current`: the configured artifact is available and carries a versioned
|
||||||
|
fingerprint plus a complete output record and separate output size;
|
||||||
|
- `stale`: the recorded semantic identity is no longer current;
|
||||||
|
- `missing`: no validated current result exists;
|
||||||
|
- `failed`: the attempted work failed and carries a bounded diagnostic; or
|
||||||
|
- `unselected`: the artifact was intentionally outside the evaluated set.
|
||||||
|
|
||||||
|
Records bind their normalized key and dependencies, fingerprint contract when
|
||||||
|
evaluated, canonical session-relative output identity when current, producing
|
||||||
|
Narratio run, update time, and bounded non-secret Scriptorium provenance and
|
||||||
|
diagnostic paths. A current output includes its configured source ID, contract,
|
||||||
|
checksum, and positive byte size. Non-current records cannot carry an output,
|
||||||
|
so an older file is not advertised through stale, missing, failed, or
|
||||||
|
unselected state.
|
||||||
|
|
||||||
|
The session-stage collection is the reconciled authority across invocations.
|
||||||
|
The corresponding collection on an invocation's `analyze` stage record is an
|
||||||
|
audit of only the artifacts evaluated or attempted by that run. These records
|
||||||
|
remain analyze-owned data inside the fixed stage; they are not dynamic stages
|
||||||
|
or generic subtasks.
|
||||||
|
|
||||||
|
The stage result contract has one analyze-specific projection boundary. On
|
||||||
|
success, the runner validates and deep-copies the complete reconciled session
|
||||||
|
collection and the invocation subset. Aggregate session outputs are rebuilt in
|
||||||
|
configured-key order from current session records only; invocation outputs are
|
||||||
|
limited to current records produced by that invocation's run ID. Ordinary
|
||||||
|
stage outputs cannot accompany this projection, so there is one source of
|
||||||
|
artifact authority.
|
||||||
|
|
||||||
|
Successful incremental execution replaces only evaluated artifact records and
|
||||||
|
preserves valid unrelated current records. Rebuilt outputs are compared by
|
||||||
|
bytes and contract: an unchanged identity permits an unselected dependent with
|
||||||
|
the same recomputed fingerprint to remain current, while a changed identity
|
||||||
|
removes output authority from every unselected transitive dependent by marking
|
||||||
|
it stale. A partial analyze invocation can therefore succeed while unrelated
|
||||||
|
configured records remain stale. Existing canonical files never create current
|
||||||
|
records without validated execution and projection.
|
||||||
|
|
||||||
|
Aggregate analyze status is deliberately coarser than this collection. Resume
|
||||||
|
validation may skip a succeeded aggregate record when the selected artifact
|
||||||
|
closure is current even if unrelated records are stale. Conversely, a stale
|
||||||
|
aggregate record may cross the ordinary runner boundary and perform zero
|
||||||
|
Scriptorium calls when reconciliation proves every selected artifact current;
|
||||||
|
the successful projection then restores the aggregate status.
|
||||||
|
|
||||||
|
Analyze may return a projection together with an error. That restricted result
|
||||||
|
cannot carry ordinary outputs, skip state, aggregate logs, generated configs,
|
||||||
|
or metadata. The runner persists only the validated per-artifact collections,
|
||||||
|
then marks the aggregate analyze and run state failed and invalidates delivery
|
||||||
|
dependents conservatively. Unrelated current records survive because the
|
||||||
|
session projection is complete. A malformed projection is not applied, and a
|
||||||
|
failed session projection save restores the prior per-artifact authority before
|
||||||
|
terminal failure persistence.
|
||||||
|
|
||||||
|
The incremental executor constructs this restricted projection at each
|
||||||
|
scheduled artifact boundary. The active record is failed without output,
|
||||||
|
current transitive dependents are stale, unrelated current records survive, and
|
||||||
|
only earlier validated and materialized completions remain current in the
|
||||||
|
invocation subset. Session failure state is persisted before invocation failure
|
||||||
|
state. If either terminal save fails, its persistence error is joined with the
|
||||||
|
original adapter, validation, or filesystem cause; a failed projection save
|
||||||
|
does not turn incidental canonical bytes into manifest authority.
|
||||||
|
|
||||||
## Run Manifest
|
## Run Manifest
|
||||||
|
|
||||||
`manifest.RunManifest` is created for each invocation and records:
|
`manifest.RunManifest` is created for each invocation and records:
|
||||||
@@ -86,7 +159,10 @@ failed in both manifests, persisting each transition. On success it records
|
|||||||
outputs, logs, generated configuration references, and metadata. Artifact
|
outputs, logs, generated configuration references, and metadata. Artifact
|
||||||
records may include optional contract and external provenance objects; old
|
records may include optional contract and external provenance objects; old
|
||||||
manifests remain compatible when those fields are absent. A successful forced
|
manifests remain compatible when those fields are absent. A successful forced
|
||||||
rerun marks only succeeded downstream session-stage records stale.
|
rerun marks only succeeded transitive dependent session-stage records stale.
|
||||||
|
The application owns a fixed dependency relation distinct from execution order;
|
||||||
|
dependents are returned in canonical order. Render and extract therefore never
|
||||||
|
stale one another, while either can stale analyze, publish, and notify.
|
||||||
|
|
||||||
Starting an execution clears the current session-stage record's prior outputs,
|
Starting an execution clears the current session-stage record's prior outputs,
|
||||||
logs, generated configuration references, and metadata. Failed and skipped
|
logs, generated configuration references, and metadata. Failed and skipped
|
||||||
@@ -96,6 +172,12 @@ those details because resume validation and diagnosis may still require them
|
|||||||
before execution begins. Invocation run manifests remain immutable audit
|
before execution begins. Invocation run manifests remain immutable audit
|
||||||
records of their own outcomes.
|
records of their own outcomes.
|
||||||
|
|
||||||
|
Aggregate lifecycle clearing deliberately preserves the analyze-owned
|
||||||
|
per-artifact collection. This lets later reconciliation replace only evaluated
|
||||||
|
entries without erasing unrelated current results. Other stages retain their
|
||||||
|
existing aggregate-only lifecycle behavior and are forbidden from carrying the
|
||||||
|
analyze-specific fields.
|
||||||
|
|
||||||
A stage may explicitly return a skipped disposition and stable reason. The
|
A stage may explicitly return a skipped disposition and stable reason. The
|
||||||
runner persists that outcome in both manifests, clears older outputs for the
|
runner persists that outcome in both manifests, clears older outputs for the
|
||||||
session-stage record along with older logs, generated configuration references,
|
session-stage record along with older logs, generated configuration references,
|
||||||
@@ -107,19 +189,29 @@ cannot contain outputs.
|
|||||||
When an already-succeeded stage is skipped, the invocation run manifest records
|
When an already-succeeded stage is skipped, the invocation run manifest records
|
||||||
the `skip` action and reason. The session manifest deliberately retains its
|
the `skip` action and reason. The session manifest deliberately retains its
|
||||||
existing succeeded record because it remains the cross-invocation progress
|
existing succeeded record because it remains the cross-invocation progress
|
||||||
authority. Stages with a resume validator, currently extraction, may reject an
|
authority. Extraction and analyze have resume validators and may reject an
|
||||||
otherwise eligible skip when the recorded durable result is obsolete; the
|
otherwise eligible skip when their selected durable evidence is obsolete; the
|
||||||
runner marks it stale and executes it.
|
runner marks the aggregate record stale and executes it. Analyze's validator
|
||||||
|
can still accept a partial selection when only unrelated artifact records are
|
||||||
|
stale.
|
||||||
|
|
||||||
Session manifest is the authoritative stage-progress ledger across invocations.
|
Session manifest is the authoritative stage-progress ledger across invocations.
|
||||||
Run manifest is invocation-scoped audit state.
|
Run manifest is invocation-scoped audit state.
|
||||||
|
|
||||||
|
Before an explicitly bounded execution starts after `prepare`, the application
|
||||||
|
reads the session manifest and accepts only `succeeded` or `skipped` for every
|
||||||
|
excluded canonical prefix stage. The first other status or absent record fails
|
||||||
|
the request before layout mutation, adapter initialization, session-manifest
|
||||||
|
writes, or run-manifest creation. Excluded prefix records are not passed to
|
||||||
|
resume validators. Records after the selected end are not prerequisites and
|
||||||
|
may be made stale by selected work without being scheduled.
|
||||||
|
|
||||||
After a publish commits remotely, any configured local cleanup is first recorded
|
After a publish commits remotely, any configured local cleanup is first recorded
|
||||||
as a session-manifest obligation before deletion begins. Each target becomes
|
as a session-manifest obligation before deletion begins. Each target becomes
|
||||||
complete only after its confined deletion (or safe absence check) and a
|
complete only after its confined deletion (or safe absence check) and a
|
||||||
successful manifest save. An incomplete obligation is retried on later
|
successful manifest save. An incomplete obligation is retried when publish
|
||||||
invocations independently of their selected stages and retains the committed
|
executes again and retains the committed run and remote identity that authorized
|
||||||
run and remote identity that authorized it.
|
it; an invocation that does not execute publish does not perform cleanup.
|
||||||
|
|
||||||
Each invocation derives campaign, session, run, local-path, and remote-prefix
|
Each invocation derives campaign, session, run, local-path, and remote-prefix
|
||||||
metadata from the validated resolved configuration as one projection. A persisted
|
metadata from the validated resolved configuration as one projection. A persisted
|
||||||
@@ -139,7 +231,7 @@ where a durable running record can require operator interpretation.
|
|||||||
- running, failed, and self-skipped stages do not retain result payloads from
|
- running, failed, and self-skipped stages do not retain result payloads from
|
||||||
an earlier success.
|
an earlier success.
|
||||||
- stale stages retain prior details until replacement execution starts.
|
- stale stages retain prior details until replacement execution starts.
|
||||||
- force reruns stale downstream succeeded stages.
|
- force reruns stale succeeded stages in the fixed dependency relation.
|
||||||
- run manifest does not replace session manifest as progress authority.
|
- run manifest does not replace session manifest as progress authority.
|
||||||
- remote commitment is established by a verified current pointer and remote
|
- remote commitment is established by a verified current pointer and remote
|
||||||
commit relationship, never by a mutable session-manifest boolean.
|
commit relationship, never by a mutable session-manifest boolean.
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ Narratio-level contracts; external transport and SDK details remain in
|
|||||||
adapters. The normative rules for these relationships remain in
|
adapters. The normative rules for these relationships remain in
|
||||||
[Architecture](../policy/architecture.md).
|
[Architecture](../policy/architecture.md).
|
||||||
|
|
||||||
|
Pipeline execution and `session plan` share the same inclusive contiguous-range
|
||||||
|
model. Planning clones session state and applies selected-stage transitions and
|
||||||
|
resume validation in memory; it does not create invocation state or initialize
|
||||||
|
stage-execution adapters. Command configuration loading can still retrieve a
|
||||||
|
missing session file through configured remote storage. Analyze planning
|
||||||
|
additionally exposes the artifact closure's targets, prerequisite rebuilds,
|
||||||
|
execution order, and current reuse.
|
||||||
|
|
||||||
## Pipeline Stage Set
|
## Pipeline Stage Set
|
||||||
|
|
||||||
The implemented canonical order is:
|
The implemented canonical order is:
|
||||||
@@ -53,8 +61,8 @@ The implemented canonical order is:
|
|||||||
4. [`polish`](stage-polish.md)
|
4. [`polish`](stage-polish.md)
|
||||||
5. [`normalize`](stage-normalize.md)
|
5. [`normalize`](stage-normalize.md)
|
||||||
6. [`trim`](stage-trim.md)
|
6. [`trim`](stage-trim.md)
|
||||||
7. [`extract`](stage-extract.md)
|
7. [`render`](stage-render.md)
|
||||||
8. [`render`](stage-render.md)
|
8. [`extract`](stage-extract.md)
|
||||||
9. [`analyze`](stage-analyze.md)
|
9. [`analyze`](stage-analyze.md)
|
||||||
10. [`publish`](stage-publish.md)
|
10. [`publish`](stage-publish.md)
|
||||||
11. `notify` (no-op)
|
11. `notify` (no-op)
|
||||||
@@ -65,6 +73,13 @@ mechanics. The
|
|||||||
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
|
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
|
||||||
and execution semantics.
|
and execution semantics.
|
||||||
|
|
||||||
|
Execution order and invalidation are separate application contracts. The stage
|
||||||
|
registry owns the flat execution sequence. The application orchestration owner
|
||||||
|
uses a fixed, validated dependency relation to find transitive dependents in
|
||||||
|
canonical order. In particular, `render` and `extract` are sibling consumers of
|
||||||
|
trimmed transcript state: neither invalidates the other, while either can stale
|
||||||
|
`analyze`, `publish`, and `notify`.
|
||||||
|
|
||||||
## Focused Documentation
|
## Focused Documentation
|
||||||
|
|
||||||
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
|
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
|
||||||
@@ -84,8 +99,8 @@ and execution semantics.
|
|||||||
- [`polish`](stage-polish.md)
|
- [`polish`](stage-polish.md)
|
||||||
- [`normalize`](stage-normalize.md)
|
- [`normalize`](stage-normalize.md)
|
||||||
- [`trim`](stage-trim.md)
|
- [`trim`](stage-trim.md)
|
||||||
- [`extract`](stage-extract.md)
|
|
||||||
- [`render`](stage-render.md)
|
- [`render`](stage-render.md)
|
||||||
|
- [`extract`](stage-extract.md)
|
||||||
- [`analyze`](stage-analyze.md)
|
- [`analyze`](stage-analyze.md)
|
||||||
- [`publish`](stage-publish.md)
|
- [`publish`](stage-publish.md)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Execute selected configured Scriptorium artifacts in dependency order and materialize outputs.
|
Reconcile configured Scriptorium artifacts, execute only required work in
|
||||||
|
dependency order, and safely materialize validated outputs.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
@@ -14,35 +15,111 @@ 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>`
|
||||||
|
|
||||||
## Outputs
|
## Outputs
|
||||||
|
|
||||||
- one materialized output per executed configured artifact (`output_path`)
|
- one current per-artifact manifest record per validated materialized output
|
||||||
- stage metadata describing selected/generated/reused artifacts
|
- stage metadata describing selected/generated/reused artifacts
|
||||||
|
|
||||||
## Key Behavior
|
## Key Behavior
|
||||||
|
|
||||||
- when Scriptorium is absent or no configured artifact is executable, completes
|
- when `pipeline.scriptorium` is absent or no configured artifact is
|
||||||
successfully with no outputs and records explanatory metadata. This is not an
|
executable, completes successfully with no outputs and records explanatory
|
||||||
explicit self-skip: both manifests record success, satisfy publish's
|
metadata. This is not an explicit self-skip: both manifests record success,
|
||||||
prerequisite, and an ordinary later run reuses the result until forced.
|
satisfy publish's prerequisite, and an ordinary later run reuses the result
|
||||||
|
while the effective set remains empty. Enabling or selecting an artifact
|
||||||
|
later makes missing versioned evidence non-resumable and schedules it without
|
||||||
|
requiring force.
|
||||||
- builds a runtime artifact catalog containing built-ins, configured artifacts,
|
- builds a runtime artifact catalog containing built-ins, configured artifacts,
|
||||||
and configured extraction lanes. Extraction availability is hydrated only
|
and configured extraction lanes. Extraction availability is hydrated only
|
||||||
from compatible successful extraction evidence.
|
from compatible successful extraction evidence.
|
||||||
- uses enabled configured artifacts by default. An explicit `--artifacts`
|
- uses enabled configured artifacts by default. An explicit `--artifacts`
|
||||||
selection is a one-invocation override: it makes exactly the named configured
|
selection is a one-invocation override that makes exactly the named
|
||||||
artifacts executable even when disabled, and does not automatically include
|
configured artifacts explicit targets even when disabled. The work planner
|
||||||
dependencies. A selected artifact's dependencies must instead already be
|
adds required configured prerequisites, reuses current ones, and schedules
|
||||||
available to the catalog.
|
stale, missing, or otherwise non-current prerequisites before dependents.
|
||||||
- marks non-executable configured artifacts as reusable when output files already exist.
|
- makes a non-executable configured artifact reusable only when its current
|
||||||
|
manifest record and durable output pass the configured-artifact evidence
|
||||||
|
contract; an incidental or stale canonical file is unavailable.
|
||||||
- validates selected artifact dependency order (cycle-safe topo ordering).
|
- validates selected artifact dependency order (cycle-safe topo ordering).
|
||||||
- resolves required/optional inputs per artifact source definition.
|
- resolves required/optional inputs per artifact source definition into an
|
||||||
- omits an unavailable optional input; an unavailable required input fails.
|
ordered semantic identity. Each identity records the configured input name,
|
||||||
- resolves prepared stable input sources from `inputs/*.yml` materialized by `prepare`.
|
canonical source ID, required policy, explicit presence, source contract,
|
||||||
|
checksum, size, and a source-based logical identity. Workspace paths and
|
||||||
|
producer run IDs are excluded.
|
||||||
|
- orders input identities by configured input name independently of Go map
|
||||||
|
iteration. Runtime adapter paths remain a separate execution-only map.
|
||||||
|
- omits an unavailable optional input from the adapter request while retaining
|
||||||
|
explicit absence in its semantic identity; an unavailable required input
|
||||||
|
fails.
|
||||||
|
- resolves prepared stable input sources through the shared manifest-authoritative
|
||||||
|
identity resolver; it does not accept incidental files or fall back to
|
||||||
|
campaign/session source paths.
|
||||||
|
- reuses checksums and sizes from validated prepared, extraction, and current
|
||||||
|
configured-artifact evidence. Other resolved inputs are hashed as confined
|
||||||
|
regular files with streaming reads and the central resolved-artifact size
|
||||||
|
limit.
|
||||||
|
- owns a versioned SHA-256 fingerprint contract with one fixed-field canonical
|
||||||
|
JSON payload and no map serialization. Configured artifacts are fingerprinted
|
||||||
|
in deterministic dependency order.
|
||||||
|
- fingerprints the normalized artifact key, prompt and profile identifiers,
|
||||||
|
effective render-debug behavior, session-relative output identity, sorted
|
||||||
|
dependency keys, ordered input declarations and semantic identities,
|
||||||
|
validated current dependency-output identities, and sorted effective
|
||||||
|
Scriptorium variables (including Narratio's sticky session variable).
|
||||||
|
- provides read-only reconciliation that classifies each configured record as
|
||||||
|
current, stale, missing, failed, legacy, or otherwise non-resumable, and
|
||||||
|
separately identifies manifest records removed from current configuration.
|
||||||
|
A record is current only when its fingerprint version and value match and its
|
||||||
|
configured output still passes manifest-authoritative evidence validation.
|
||||||
|
- owns a read-only typed work planner. Its explicit targets are enabled
|
||||||
|
artifacts by default or the exact normalized `--artifacts` selection when
|
||||||
|
supplied. It closes targets over configured prerequisites, orders the closure
|
||||||
|
topologically, reuses current members, and schedules every non-current member
|
||||||
|
before its dependents.
|
||||||
|
- force applies only to explicit targets. A current prerequisite is reused
|
||||||
|
unless it is itself an explicit forced target; disabled prerequisites may be
|
||||||
|
rebuilt when required, while unrelated disabled artifacts are excluded.
|
||||||
|
- the work plan carries explicit targets, prerequisite-only work, deterministic
|
||||||
|
execution and reuse lists, invalidated and removed records, and a cloned
|
||||||
|
projected record collection. Valid unrelated configured records survive the
|
||||||
|
projection, removed records are omitted, and legacy files never become
|
||||||
|
current without regeneration.
|
||||||
|
- implements aggregate resume validation by running the same read-only catalog,
|
||||||
|
fingerprint reconciliation, and work planner used by execution. A succeeded
|
||||||
|
aggregate record is reusable exactly when the selected closure schedules no
|
||||||
|
artifact work; stale unrelated records do not block a partial selection.
|
||||||
|
- exposes the typed artifact decision to `session plan`. Planning applies it to
|
||||||
|
a cloned manifest after modeling earlier selected stage transitions, so
|
||||||
|
aggregate run/skip and artifact execute/reuse decisions match the ordinary
|
||||||
|
runner without creating durable state or invoking Scriptorium.
|
||||||
|
- executes only the work plan's scheduled entries. Manifest-validated current
|
||||||
|
prerequisites remain available through the runtime catalog without invoking
|
||||||
|
Scriptorium; newly produced prerequisites enter that catalog with the same
|
||||||
|
contract, checksum, and size identity used for persisted current evidence.
|
||||||
|
- keeps adapter output in the invocation's run-local analyze directory until
|
||||||
|
it is a safe, non-empty, bounded regular file with a calculated checksum and
|
||||||
|
complete output contract. Canonical replacement uses the shared atomic file
|
||||||
|
operation boundary and verifies that the installed checksum matches the
|
||||||
|
validated run-local bytes.
|
||||||
|
- records each successful artifact's freshly computed fingerprint, canonical
|
||||||
|
relative output path, contract, checksum, size, producer run ID, bounded
|
||||||
|
Scriptorium provenance, logs, and generated configuration references in the
|
||||||
|
analyze-owned projection.
|
||||||
|
- preserves valid unrelated current records during partial execution. If a
|
||||||
|
rebuilt output's bytes and contract are unchanged, unselected dependents may
|
||||||
|
remain current. If that semantic identity changes, unselected transitive
|
||||||
|
dependents become stale without being executed; dependents included in the
|
||||||
|
invocation are evaluated in dependency order instead.
|
||||||
|
- reports all evaluated targets and prerequisites in invocation state. The
|
||||||
|
runner reconstructs aggregate session outputs from every current session
|
||||||
|
record and invocation outputs from only records produced by the current run.
|
||||||
|
Unrelated stale records do not make an otherwise successful partial
|
||||||
|
invocation fail.
|
||||||
- resolves previous-session sources from local `previous/` cache only.
|
- 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.
|
||||||
@@ -57,11 +134,33 @@ Supported source families:
|
|||||||
guidance.
|
guidance.
|
||||||
- dependency cycles or unavailable required dependencies fail.
|
- dependency cycles or unavailable required dependencies fail.
|
||||||
- adapter validation failures fail stage.
|
- adapter validation failures fail stage.
|
||||||
|
- a scheduled artifact failure returns the restricted analyze-state projection
|
||||||
|
with the active artifact marked `failed`, a bounded error, and no output
|
||||||
|
authority. Current transitive dependents become stale without execution.
|
||||||
|
- earlier artifacts from the invocation remain current only after their
|
||||||
|
run-local output passed validation and canonical materialization. They remain
|
||||||
|
in invocation history; unattempted later artifacts do not appear there.
|
||||||
|
- unrelated current records survive a partial failure. Old canonical bytes for
|
||||||
|
the failed artifact and newly materialized bytes whose projection cannot be
|
||||||
|
persisted are incidental, not current evidence.
|
||||||
|
- the runner persists a valid partial projection before it marks aggregate
|
||||||
|
analyze failed and invalidates publish and notify through the application
|
||||||
|
dependency relation. Projection-persistence errors retain the last durable
|
||||||
|
per-artifact authority and are joined with the original failure context.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- `analyze` performs no remote storage calls for previous-session source resolution.
|
- `analyze` performs no remote storage calls for previous-session source resolution.
|
||||||
|
- input-identity resolution is read-only: it does not invoke adapters,
|
||||||
|
materialize outputs, update status, or create run records.
|
||||||
|
- fingerprints exclude timeouts, retries, timestamps, producer and Narratio run
|
||||||
|
IDs, executable and config paths, workspace roots, diagnostic locations, and
|
||||||
|
executable or private transitive configuration contents. A change that is
|
||||||
|
visible only inside Scriptorium—such as a file privately loaded by its config
|
||||||
|
path—requires an explicit forced regeneration.
|
||||||
- output provenance and metadata are deterministic per execution.
|
- output provenance and metadata are deterministic per execution.
|
||||||
|
- a canonical file without current per-artifact manifest evidence is never
|
||||||
|
promoted to current state.
|
||||||
|
|
||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
@@ -70,4 +169,13 @@ Supported source families:
|
|||||||
- [CLI](../cli.md) owns user-visible artifact selection.
|
- [CLI](../cli.md) owns user-visible artifact selection.
|
||||||
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
|
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
|
||||||
- Implementation and tests: `internal/stage/analyze.go`,
|
- Implementation and tests: `internal/stage/analyze.go`,
|
||||||
`internal/stage/analyze_test.go`
|
`internal/stage/analyze_input_identity.go`, `internal/stage/analyze_test.go`,
|
||||||
|
`internal/stage/analyze_input_identity_test.go`,
|
||||||
|
`internal/stage/analyze_fingerprint.go`,
|
||||||
|
`internal/stage/analyze_fingerprint_test.go`,
|
||||||
|
`internal/stage/analyze_reconciliation.go`, and
|
||||||
|
`internal/stage/analyze_reconciliation_test.go`,
|
||||||
|
`internal/stage/analyze_plan.go`, `internal/stage/analyze_plan_test.go`, and
|
||||||
|
`internal/stage/analyze_incremental_execution_test.go`, and
|
||||||
|
`internal/stage/analyze_failure_test.go`,
|
||||||
|
`internal/stage/analyze_resume.go`, and `internal/stage/analyze_resume_test.go`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Responsibility
|
## Responsibility
|
||||||
|
|
||||||
`extract` runs after `trim` and before `render`. It converts the canonical
|
`extract` runs after `render` and before `analyze`. It converts the canonical
|
||||||
`narratio.transcript.final_trimmed` JSON into configured Notarius lane artifacts.
|
`narratio.transcript.final_trimmed` JSON into configured Notarius lane artifacts.
|
||||||
An omitted or disabled Notarius section makes the stage explicitly self-skip
|
An omitted or disabled Notarius section makes the stage explicitly self-skip
|
||||||
with reason `notarius_disabled`, no outputs, and no Notarius runner.
|
with reason `notarius_disabled`, no outputs, and no Notarius runner.
|
||||||
@@ -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,23 +40,35 @@ 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.
|
||||||
|
|
||||||
Any executed extraction outcome that replaces a different effective outcome
|
Any executed extraction outcome that replaces a different effective outcome
|
||||||
marks succeeded downstream stages stale. Repeating the same disabled self-skip
|
marks succeeded analysis and delivery dependents stale. Render is an independent
|
||||||
with no outputs is stable and does not repeatedly invalidate downstream stages.
|
sibling and remains current. Repeating the same disabled self-skip with no
|
||||||
|
outputs is stable and does not repeatedly invalidate dependent stages.
|
||||||
|
|
||||||
## Resume Validation
|
## Resume Validation
|
||||||
|
|
||||||
`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 +83,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;
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ Exact remote placement and the operator workflow belong in
|
|||||||
checks a declared checksum when present, then streams the opened descriptor.
|
checks a declared checksum when present, then streams the opened descriptor.
|
||||||
- derives the durable previous-cache archive from its validated manifest using
|
- derives the durable previous-cache archive from its validated manifest using
|
||||||
the same confinement and regular-file checks.
|
the same confinement and regular-file checks.
|
||||||
- resolves publish output sources through runtime artifact catalog and manifest-aware resolution.
|
- resolves publish output sources through runtime artifact catalog and
|
||||||
|
manifest-aware resolution. Configured Scriptorium outputs are publishable
|
||||||
|
only from validated `current` per-artifact analyze evidence; an incidental
|
||||||
|
canonical file, legacy aggregate output, stale/failed/unselected record, or
|
||||||
|
mismatched path, size, or checksum remains unavailable. This does not change
|
||||||
|
the explicit compatibility policies owned by built-in, extraction, or
|
||||||
|
previous-session sources.
|
||||||
- publishes extraction lanes only through explicit configured output rules;
|
- publishes extraction lanes only through explicit configured output rules;
|
||||||
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
neither run-local nor durable Notarius bundles are scanned or uploaded wholesale.
|
||||||
- selected artifact filter applies to configured artifact sources only.
|
- selected artifact filter applies to configured artifact sources only.
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
|
Render Markdown transcript artifacts from normalized JSON transcripts via Seriatim.
|
||||||
|
It runs after `trim` and before `extract` in the canonical sequence. Render and
|
||||||
|
extract are independent sibling consumers: replacing render output does not
|
||||||
|
invalidate extraction, but it does invalidate succeeded analysis and delivery
|
||||||
|
records that may consume rendered transcripts.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -75,8 +80,8 @@ Canonical stage order:
|
|||||||
4. `polish`
|
4. `polish`
|
||||||
5. `normalize`
|
5. `normalize`
|
||||||
6. `trim`
|
6. `trim`
|
||||||
7. `extract`
|
7. `render`
|
||||||
8. `render`
|
8. `extract`
|
||||||
9. `analyze`
|
9. `analyze`
|
||||||
10. `publish`
|
10. `publish`
|
||||||
11. `notify`
|
11. `notify`
|
||||||
@@ -85,12 +90,12 @@ Execution rules:
|
|||||||
|
|
||||||
- succeeded stages are skipped unless `--force` is set;
|
- succeeded stages are skipped unless `--force` is set;
|
||||||
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- forcing an upstream stage marks succeeded downstream stages as `stale` before
|
- forcing a stage marks succeeded transitive dependents as `stale` before the
|
||||||
the replacement runs; and
|
replacement runs; render and extract are independent siblings; and
|
||||||
- an executed failure, changed self-skip, or success that replaces a different
|
- an executed failure, changed self-skip, or success that replaces a different
|
||||||
effective upstream outcome also marks succeeded downstream stages stale. A
|
effective outcome uses the same fixed dependency relation. A
|
||||||
repeated self-skip with the same reason and no outputs is stable and does not
|
repeated self-skip with the same reason and no outputs is stable and does not
|
||||||
perpetually rerun downstream work.
|
perpetually rerun dependent work.
|
||||||
|
|
||||||
An explicit self-skip is a durable `skipped` stage outcome that later runs
|
An explicit self-skip is a durable `skipped` stage outcome that later runs
|
||||||
reconsider. It differs from successful no-output execution: disabled `render`
|
reconsider. It differs from successful no-output execution: disabled `render`
|
||||||
@@ -106,14 +111,81 @@ Single-stage execution:
|
|||||||
narratio run-stage normalize 2026-04-04 --force
|
narratio run-stage normalize 2026-04-04 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Contiguous bounded execution uses inclusive canonical endpoints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session plan 2026-04-04 --from extract --through analyze --force
|
||||||
|
narratio run 2026-04-04 --from extract --through analyze --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Omitting `--from` selects from `prepare`; omitting `--through` selects through
|
||||||
|
`notify`. Force applies only within the selected range. Repeating `--from`,
|
||||||
|
`--through`, or `--force` is rejected instead of resolving by argument order.
|
||||||
|
The plan command uses the same selection contract and prints only the selected
|
||||||
|
range. Planning is read-only: it clones the loaded manifest, models selected
|
||||||
|
stage transitions and invalidation in memory, and invokes resume validation
|
||||||
|
without writing the manifest, creating run directories, materializing files,
|
||||||
|
or invoking pipeline adapters. Analyze detail separates explicit targets,
|
||||||
|
prerequisite rebuilds, scheduled execution, and current reuse. This lets a
|
||||||
|
coarsely stale aggregate analyze stage show zero artifact executions when its
|
||||||
|
selected artifact evidence is still semantically current.
|
||||||
|
|
||||||
|
Before a bounded run or plan whose range starts after `prepare`, every excluded
|
||||||
|
prefix stage must already have a session-manifest status of `succeeded` or
|
||||||
|
`skipped`. Narratio reports the first absent, pending, running, failed, stale,
|
||||||
|
or interrupted prerequisite without creating a run record or changing session
|
||||||
|
state. Widen `--from` to include that stage, or recover it explicitly before
|
||||||
|
retrying. Excluded prefix stages are not resume-validated or repaired as part
|
||||||
|
of the bounded invocation; selected stages still reject missing, unsafe, or
|
||||||
|
manifest-inconsistent inputs at their owning boundary.
|
||||||
|
|
||||||
|
Stages after `--through` are not prerequisites and are never scheduled by the
|
||||||
|
bounded invocation. A selected forced stage can mark one of those succeeded
|
||||||
|
dependents stale through the fixed invalidation relation, but the dependent
|
||||||
|
does not execute until a later invocation selects it. Production composition
|
||||||
|
likewise initializes only collaborators needed by the selected range and
|
||||||
|
shared session lifecycle. In particular, render does not require Notarius or
|
||||||
|
Scriptorium, extract does not require Scriptorium, and analyze does not require
|
||||||
|
the transcription, Seriatim, Audita, or Notarius adapters.
|
||||||
|
|
||||||
|
For the common post-transcript development loop, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio regenerate-artifacts 2026-04-04
|
||||||
|
narratio regenerate-artifacts 2026-04-04 --artifacts session_recap,player_handout
|
||||||
|
```
|
||||||
|
|
||||||
|
This command is a transparent expansion to a forced bounded `run` from
|
||||||
|
`extract` through `analyze`. Extraction always rebuilds its complete configured
|
||||||
|
bundle. Analysis rebuilds the selected targets and their required analysis
|
||||||
|
prerequisites, or uses the normal default selection when no artifact names are
|
||||||
|
given. The command does not run publish or notify; delivery remains a separate
|
||||||
|
operator action.
|
||||||
|
|
||||||
|
Inspect current artifact evidence, then publish explicitly when the regenerated
|
||||||
|
set is ready:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session artifacts 2026-04-04
|
||||||
|
narratio publish 2026-04-04
|
||||||
|
```
|
||||||
|
|
||||||
|
If planning or execution reports stale, missing, failed, legacy, or tampered
|
||||||
|
analysis evidence, regenerate the affected target instead of copying an older
|
||||||
|
canonical file into place or editing the manifest. See
|
||||||
|
[Troubleshooting: Analysis artifact evidence is not current](./troubleshooting.md#analysis-artifact-evidence-is-not-current).
|
||||||
|
|
||||||
## Artifact Selection
|
## Artifact Selection
|
||||||
|
|
||||||
`--artifacts` can be used on `run`, `run-stage`, `analyze`, and `publish`.
|
`--artifacts` can be used on `run`, `session plan`, `run-stage`, `analyze`, and
|
||||||
|
`publish`. For a bounded run or plan, the selected range must contain `analyze`
|
||||||
|
or `publish`.
|
||||||
|
|
||||||
Selection behavior:
|
Selection behavior:
|
||||||
|
|
||||||
- validates names against `pipeline.scriptorium.artifacts`;
|
- validates names against `pipeline.scriptorium.artifacts`;
|
||||||
- filters analyze execution to selected configured artifacts;
|
- selects explicit analyze targets and permits their required configured
|
||||||
|
prerequisites to be reused or rebuilt first;
|
||||||
- filters publish rules for `narratio.artifact.<name>` sources only;
|
- filters publish rules for `narratio.artifact.<name>` sources only;
|
||||||
- does not suppress built-in transcript, bounds, or explicitly configured
|
- does not suppress built-in transcript, bounds, or explicitly configured
|
||||||
`narratio.extraction.<name>` publish sources; and
|
`narratio.extraction.<name>` publish sources; and
|
||||||
@@ -135,6 +207,31 @@ The directory is immutable once promoted. Configured lanes become
|
|||||||
the bundle and `index.json` are retained for audit and resume validation but
|
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 +278,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.
|
||||||
@@ -405,7 +503,11 @@ Rules:
|
|||||||
- `pipeline.workspace.cleanup_after_publish=true`
|
- `pipeline.workspace.cleanup_after_publish=true`
|
||||||
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
|
- Narratio first records the exact run-scoped cleanup obligation. If cleanup
|
||||||
reports incomplete, the remote committed snapshot remains current; rerun
|
reports incomplete, the remote committed snapshot remains current; rerun
|
||||||
Narratio to retry only the outstanding confined local cleanup.
|
publish to retry only the outstanding confined local cleanup.
|
||||||
|
|
||||||
|
Post-publish cleanup is evaluated only when `publish` actually executes in the
|
||||||
|
current invocation. A bounded range that excludes publish does not replay a
|
||||||
|
cleanup obligation as an unrelated side effect.
|
||||||
|
|
||||||
## Operational Caveats
|
## Operational Caveats
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ in the [integration documentation](../integrations/).
|
|||||||
The pipeline has one canonical ordered stage set. Configuration may enable,
|
The pipeline has one canonical ordered stage set. Configuration may enable,
|
||||||
disable, or parameterize supported behavior, but it must not turn that sequence
|
disable, or parameterize supported behavior, but it must not turn that sequence
|
||||||
into an arbitrary DAG or hide orchestration in generic workflow abstractions.
|
into an arbitrary DAG or hide orchestration in generic workflow abstractions.
|
||||||
|
An invocation selects either the full sequence or one inclusive contiguous
|
||||||
|
range of it. Execution remains flat and canonical even though invalidation is
|
||||||
|
dependency-aware: the application owns a separate fixed relation used only to
|
||||||
|
stale transitive dependents, including dependents outside a selected range.
|
||||||
The implemented stage inventory belongs in the
|
The implemented stage inventory belongs in the
|
||||||
[Internal Overview](../internal/overview.md).
|
[Internal Overview](../internal/overview.md).
|
||||||
|
|
||||||
@@ -87,8 +91,10 @@ merely on incidental files existing on disk.
|
|||||||
|
|
||||||
A failed or interrupted stage must not be presented as successful. Failure
|
A failed or interrupted stage must not be presented as successful. Failure
|
||||||
should preserve enough local state and diagnostics for inspection, recovery,
|
should preserve enough local state and diagnostics for inspection, recovery,
|
||||||
and resume. Forcing an upstream stage invalidates succeeded downstream work
|
and resume. Forcing a stage invalidates succeeded transitive dependents
|
||||||
according to the canonical stage order.
|
according to a fixed application-owned relation that is separate from canonical
|
||||||
|
execution order. The relation is validated against the stage inventory and is
|
||||||
|
not configurable.
|
||||||
|
|
||||||
A stage may explicitly self-skip with a stable reason and no outputs. That
|
A stage may explicitly self-skip with a stable reason and no outputs. That
|
||||||
outcome is persisted, clears older outputs owned by the stage, and is
|
outcome is persisted, clears older outputs owned by the stage, and is
|
||||||
|
|||||||
7
docs/releases/README.md
Normal file
7
docs/releases/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Release Notes
|
||||||
|
|
||||||
|
This directory contains the maintained release-note text for Narratio releases.
|
||||||
|
The corresponding Gitea release is the canonical source for downloadable
|
||||||
|
binaries and checksums.
|
||||||
|
|
||||||
|
- [v1.5.0](v1.5.0.md)
|
||||||
47
docs/releases/v1.5.0.md
Normal file
47
docs/releases/v1.5.0.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Narratio v1.5.0
|
||||||
|
|
||||||
|
Narratio v1.5.0 makes repeated post-transcript artifact development faster and
|
||||||
|
more explicit while retaining the fixed, stage-driven pipeline model.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- The canonical pipeline now completes deterministic rendering before
|
||||||
|
extraction, cleanly separating transcript-generating stages from
|
||||||
|
artifact-generating stages.
|
||||||
|
- `narratio run` and `narratio session plan` accept inclusive `--from` and
|
||||||
|
`--through` bounds. Excluded transcript stages are not executed or
|
||||||
|
invalidated by a bounded artifact-regeneration run.
|
||||||
|
- `narratio regenerate-artifacts SESSION` is an exact convenience alias for a
|
||||||
|
forced run from `extract` through `analyze`, including focused
|
||||||
|
`--artifacts` selections.
|
||||||
|
- Configured Scriptorium artifacts now have independent,
|
||||||
|
manifest-authoritative freshness. Narratio reuses validated current work,
|
||||||
|
rebuilds stale prerequisites in dependency order, and persists successful,
|
||||||
|
failed, and newly stale artifact state when an analysis invocation only
|
||||||
|
partially succeeds.
|
||||||
|
- Publish consumes only configured artifacts backed by current manifest
|
||||||
|
evidence; incidental or tampered files are not promoted as current output.
|
||||||
|
|
||||||
|
## Reliability And Administration
|
||||||
|
|
||||||
|
- Bounded prerequisites are checked again under the session lock before any
|
||||||
|
run mutation, closing a concurrent-run race.
|
||||||
|
- Analysis fingerprints are stable across executable and configuration path
|
||||||
|
changes and continue to cover only Narratio-observable semantic inputs.
|
||||||
|
- Runner composition now carries one validated execution plan from command
|
||||||
|
parsing through prerequisite validation, adapter composition, manifest
|
||||||
|
recording, and stage execution.
|
||||||
|
- `narratio version` reports the exact tag embedded in official release
|
||||||
|
binaries; ordinary source builds report `dev`.
|
||||||
|
|
||||||
|
## Upgrade Notes
|
||||||
|
|
||||||
|
- Existing unbounded commands and direct `run-stage`, `analyze`, and `publish`
|
||||||
|
workflows retain their meanings.
|
||||||
|
- Manifests written before artifact-level analysis state remain readable.
|
||||||
|
Legacy aggregate analysis success is not sufficient freshness evidence, so
|
||||||
|
the first analysis evaluation after upgrading may regenerate configured
|
||||||
|
artifacts once.
|
||||||
|
- Narratio cannot observe executable contents or configuration, prompt,
|
||||||
|
profile, module, and other files loaded privately by Scriptorium. Explicitly
|
||||||
|
force affected artifacts after changing those private inputs.
|
||||||
@@ -117,6 +117,40 @@ Safe fix:
|
|||||||
|
|
||||||
Relevant reference: [CLI artifact selection](./cli.md).
|
Relevant reference: [CLI artifact selection](./cli.md).
|
||||||
|
|
||||||
|
## Bounded run prerequisite is unusable
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- `run` or `session plan` reports that a prerequisite stage is absent or has a
|
||||||
|
pending, running, failed, stale, or interrupted status before the selected
|
||||||
|
start.
|
||||||
|
|
||||||
|
Likely cause:
|
||||||
|
|
||||||
|
- `--from` excludes upstream work that has not reached the terminal
|
||||||
|
`succeeded` or `skipped` state in the session manifest.
|
||||||
|
|
||||||
|
Diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session status 2026-04-04
|
||||||
|
narratio session plan 2026-04-04 --from render --through analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- widen the bounded range to include the first reported stage, or recover that
|
||||||
|
stage explicitly with `run-stage` before retrying. The failed check does not
|
||||||
|
create a run record or modify the manifest. Narratio does not resume-validate
|
||||||
|
excluded prefix stages, and stages after `--through` are not prerequisites.
|
||||||
|
|
||||||
|
If prerequisite statuses are terminal but a selected stage reports a missing,
|
||||||
|
unsafe, or checksum-inconsistent artifact, repair the artifact at the stage
|
||||||
|
that owns it; do not edit the manifest to bypass the selected stage's concrete
|
||||||
|
input validation.
|
||||||
|
|
||||||
|
Relevant reference: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior).
|
||||||
|
|
||||||
## Notarius executable missing
|
## Notarius executable missing
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
@@ -158,6 +192,66 @@ is expected audit state, not a signal to relink the old bundle manually.
|
|||||||
|
|
||||||
Relevant reference: [Operations: Extraction Workflow](./operations.md#extraction-workflow).
|
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 +292,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 +324,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,12 +348,110 @@ 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).
|
||||||
|
|
||||||
|
## Analysis artifact evidence is not current
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- ordinary continuation or `session plan` schedules one or more configured
|
||||||
|
artifacts even though a canonical output file exists; or
|
||||||
|
- publish reports a configured artifact source unavailable.
|
||||||
|
|
||||||
|
Likely causes:
|
||||||
|
|
||||||
|
- the per-artifact record is stale, missing, failed, unselected, malformed, or
|
||||||
|
from the legacy aggregate-only manifest contract;
|
||||||
|
- a configured prompt/profile, dependency, input identity, output path, or
|
||||||
|
effective variable changed; or
|
||||||
|
- the recorded output is missing, unsafe, empty, or has a size/checksum that no
|
||||||
|
longer matches its manifest evidence.
|
||||||
|
|
||||||
|
Diagnostics:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio session status 2026-04-04
|
||||||
|
narratio session artifacts 2026-04-04
|
||||||
|
narratio session plan 2026-04-04 --from analyze --through analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- investigate unexpected path or checksum changes as possible tampering;
|
||||||
|
- otherwise let the selected analyze work rerun, or explicitly regenerate only
|
||||||
|
the affected targets; and
|
||||||
|
- never edit the fingerprint/checksum in the manifest or copy an old file into
|
||||||
|
the canonical path as a substitute for current evidence.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio analyze 2026-04-04 --artifacts session_recap
|
||||||
|
```
|
||||||
|
|
||||||
|
Relevant references: [Operations: Artifact Selection](./operations.md#artifact-selection)
|
||||||
|
and [Artifact Internals](./internal/artifacts.md#resolution-rules).
|
||||||
|
|
||||||
|
## Legacy aggregate analysis requires regeneration
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- a manifest from an older Narratio version reports aggregate analyze success
|
||||||
|
and the old files are present, but configured artifact sources remain
|
||||||
|
unavailable.
|
||||||
|
|
||||||
|
Likely cause:
|
||||||
|
|
||||||
|
- the manifest has no supported per-artifact analyze state. Aggregate output
|
||||||
|
lists do not establish current configured-artifact authority.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- regenerate the required artifacts. A partial selection makes only its
|
||||||
|
targets and prerequisites eligible for current state; unselected legacy
|
||||||
|
files intentionally remain unavailable. Run full analysis later when every
|
||||||
|
enabled configured artifact must become current.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio analyze 2026-04-04 --artifacts session_recap
|
||||||
|
narratio analyze 2026-04-04
|
||||||
|
```
|
||||||
|
|
||||||
|
After current records exist, inspect them and publish explicitly. Do not delete
|
||||||
|
the legacy files merely to influence selection; availability is manifest-owned.
|
||||||
|
|
||||||
|
Relevant references: [Operations: Stage Execution and Continuation Behavior](./operations.md#stage-execution-and-continuation-behavior)
|
||||||
|
and [Manifest Internals](./internal/manifest.md#analyze-owned-artifact-state).
|
||||||
|
|
||||||
|
## Scriptorium private input changed without a rerun
|
||||||
|
|
||||||
|
Symptom:
|
||||||
|
|
||||||
|
- a prompt, profile, imported configuration file, executable, or other input
|
||||||
|
loaded privately by Scriptorium changed, but Narratio still considers an
|
||||||
|
artifact current.
|
||||||
|
|
||||||
|
Likely cause:
|
||||||
|
|
||||||
|
- analysis fingerprints cover Narratio-observable semantic identities, not
|
||||||
|
executable contents or arbitrary files and transitive configuration that
|
||||||
|
Scriptorium loads behind its configured paths and identifiers.
|
||||||
|
|
||||||
|
Safe fix:
|
||||||
|
|
||||||
|
- explicitly force the affected target after changing an unobserved private
|
||||||
|
input. Force applies to explicit targets; current prerequisites remain
|
||||||
|
reusable unless selected themselves.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
narratio analyze 2026-04-04 --artifacts session_recap
|
||||||
|
```
|
||||||
|
|
||||||
|
Relevant reference: [Analyze Internals](./internal/stage-analyze.md#invariants).
|
||||||
|
|
||||||
## Previous-session artifact input missing
|
## Previous-session artifact input missing
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
|
|||||||
@@ -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,20 +31,41 @@ 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.
|
||||||
type Receipt struct {
|
type Receipt struct {
|
||||||
SchemaVersion string
|
SchemaVersion string
|
||||||
RunID string
|
RunID string
|
||||||
PipelineID string
|
PipelineID string
|
||||||
OutputDirectory string
|
OutputDirectory string
|
||||||
IndexFile string
|
IndexFile string
|
||||||
NormalizedOutputCount int
|
NormalizedOutputCount int
|
||||||
RejectedOutputCount int
|
RejectedOutputCount int
|
||||||
WarningCount int
|
WarningGroupCount int
|
||||||
ValidationStatus string
|
WarningOccurrenceCount int
|
||||||
DebugDirectory string
|
DiagnosticGroupCount int
|
||||||
|
DiagnosticOccurrenceCount int
|
||||||
|
DiagnosticsTruncated bool
|
||||||
|
ValidationStatus string
|
||||||
|
ValidationSummaries []ValidationSummary
|
||||||
|
DebugDirectory string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidationSummary retains the bounded outcome of one Notarius producer result.
|
||||||
|
type ValidationSummary struct {
|
||||||
|
Stage string
|
||||||
|
StepID string
|
||||||
|
LaneID string
|
||||||
|
ModuleKey string
|
||||||
|
ChunkID string
|
||||||
|
Status string
|
||||||
|
RejectingValidators []string
|
||||||
|
ReasonCodes []string
|
||||||
|
IncompleteValidators []string
|
||||||
|
ProducerAttemptCount int
|
||||||
|
TerminalAction string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LaneDescriptor identifies one normalized lane payload discovered through the index.
|
// LaneDescriptor identifies one normalized lane payload discovered through the index.
|
||||||
@@ -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
|
||||||
ReasonCode string
|
Category string
|
||||||
|
ReasonCode string
|
||||||
|
Origin DiagnosticOrigin
|
||||||
|
OccurrenceCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiagnosticOrigin identifies the framework-owned pipeline location of a finding.
|
||||||
|
type DiagnosticOrigin struct {
|
||||||
|
Stage string
|
||||||
|
StepID string
|
||||||
|
LaneID string
|
||||||
|
ModuleKey string
|
||||||
|
ValidatorKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiagnosticSummary retains bounded advisory or observation group metadata.
|
||||||
|
type DiagnosticSummary struct {
|
||||||
|
Disposition string
|
||||||
|
Category string
|
||||||
|
ReasonCode string
|
||||||
|
Origin DiagnosticOrigin
|
||||||
|
OccurrenceCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunResult describes a successfully decoded and validated Notarius bundle.
|
// 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,17 +11,24 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxReceiptBytes = 1 << 20
|
maxReceiptBytes = 1 << 20
|
||||||
maxIndexBytes = 4 << 20
|
maxIndexBytes = 4 << 20
|
||||||
maxSummaryBytes = 4 << 20
|
maxSummaryBytes = 4 << 20
|
||||||
canonicalIndexFile = "index.json"
|
canonicalIndexFile = "index.json"
|
||||||
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,47 +152,124 @@ 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 {
|
||||||
SchemaVersion string `json:"schema_version"`
|
SchemaVersion string `json:"schema_version"`
|
||||||
RunID string `json:"run_id"`
|
RunID string `json:"run_id"`
|
||||||
PipelineID string `json:"pipeline_id"`
|
PipelineID string `json:"pipeline_id"`
|
||||||
OutputDirectory string `json:"output_directory"`
|
OutputDirectory string `json:"output_directory"`
|
||||||
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"`
|
||||||
ValidationStatus string `json:"validation_status"`
|
WarningOccurrenceCount *int `json:"warning_occurrence_count"`
|
||||||
DebugDirectory string `json:"debug_directory"`
|
DiagnosticGroupCount *int `json:"diagnostic_group_count"`
|
||||||
|
DiagnosticOccurrenceCount *int `json:"diagnostic_occurrence_count"`
|
||||||
|
DiagnosticsTruncated *bool `json:"diagnostics_truncated"`
|
||||||
|
ValidationStatus string `json:"validation_status"`
|
||||||
|
ValidationSummaries []validationSummaryDocument `json:"validation_summaries"`
|
||||||
|
DebugDirectory string `json:"debug_directory"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type validationSummaryDocument struct {
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
StepID string `json:"step_id"`
|
||||||
|
LaneID string `json:"lane_id"`
|
||||||
|
ModuleKey string `json:"module_key"`
|
||||||
|
ChunkID string `json:"chunk_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
RejectingValidators []string `json:"rejecting_validators"`
|
||||||
|
ReasonCodes []string `json:"reason_codes"`
|
||||||
|
IncompleteValidators []string `json:"incomplete_validators"`
|
||||||
|
ProducerAttemptCount *int `json:"producer_attempt_count"`
|
||||||
|
TerminalAction string `json:"terminal_action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validValidationStatus(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case "approved", "rejected", "incomplete":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateValidationSummaries(documents []validationSummaryDocument) ([]ValidationSummary, error) {
|
||||||
|
summaries := make([]ValidationSummary, 0, len(documents))
|
||||||
|
for _, document := range documents {
|
||||||
|
if document.Status != "complete" && document.Status != "rejected" && document.Status != "incomplete" {
|
||||||
|
return nil, fmt.Errorf("notarius validation summary status %q is invalid", document.Status)
|
||||||
|
}
|
||||||
|
if document.ProducerAttemptCount == nil || *document.ProducerAttemptCount <= 0 || !validTerminalAction(document.TerminalAction) {
|
||||||
|
return nil, fmt.Errorf("notarius validation summary is missing required fields")
|
||||||
|
}
|
||||||
|
summaries = append(summaries, ValidationSummary{
|
||||||
|
Stage: document.Stage, StepID: document.StepID, LaneID: document.LaneID,
|
||||||
|
ModuleKey: document.ModuleKey, ChunkID: document.ChunkID, Status: document.Status,
|
||||||
|
RejectingValidators: append([]string(nil), document.RejectingValidators...),
|
||||||
|
ReasonCodes: append([]string(nil), document.ReasonCodes...),
|
||||||
|
IncompleteValidators: append([]string(nil), document.IncompleteValidators...),
|
||||||
|
ProducerAttemptCount: *document.ProducerAttemptCount, TerminalAction: document.TerminalAction,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return summaries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validTerminalAction(value string) bool {
|
||||||
|
switch value {
|
||||||
|
case "accepted", "reject_output", "warn_continue", "fail_run":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadReceipt(path, pipelineID string) (Receipt, error) {
|
func loadReceipt(path, pipelineID string) (Receipt, error) {
|
||||||
@@ -177,7 +283,9 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
|
|||||||
if strings.TrimSpace(document.RunID) == "" || strings.TrimSpace(document.PipelineID) == "" ||
|
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")
|
||||||
}
|
}
|
||||||
@@ -196,16 +313,21 @@ func loadReceipt(path, pipelineID string) (Receipt, error) {
|
|||||||
return Receipt{}, fmt.Errorf("notarius receipt debug directory must be absolute when present")
|
return Receipt{}, fmt.Errorf("notarius receipt debug directory must be absolute when present")
|
||||||
}
|
}
|
||||||
return Receipt{
|
return Receipt{
|
||||||
SchemaVersion: document.SchemaVersion,
|
SchemaVersion: document.SchemaVersion,
|
||||||
RunID: document.RunID,
|
RunID: document.RunID,
|
||||||
PipelineID: document.PipelineID,
|
PipelineID: document.PipelineID,
|
||||||
OutputDirectory: filepath.Clean(document.OutputDirectory),
|
OutputDirectory: filepath.Clean(document.OutputDirectory),
|
||||||
IndexFile: document.IndexFile,
|
IndexFile: document.IndexFile,
|
||||||
NormalizedOutputCount: *document.NormalizedOutputCount,
|
NormalizedOutputCount: *document.NormalizedOutputCount,
|
||||||
RejectedOutputCount: *document.RejectedOutputCount,
|
RejectedOutputCount: *document.RejectedOutputCount,
|
||||||
WarningCount: *document.WarningCount,
|
WarningGroupCount: *document.WarningGroupCount,
|
||||||
ValidationStatus: document.ValidationStatus,
|
WarningOccurrenceCount: *document.WarningOccurrenceCount,
|
||||||
DebugDirectory: document.DebugDirectory,
|
DiagnosticGroupCount: *document.DiagnosticGroupCount,
|
||||||
|
DiagnosticOccurrenceCount: *document.DiagnosticOccurrenceCount,
|
||||||
|
DiagnosticsTruncated: *document.DiagnosticsTruncated,
|
||||||
|
ValidationStatus: document.ValidationStatus,
|
||||||
|
ValidationSummaries: validationSummaries,
|
||||||
|
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)
|
||||||
@@ -260,10 +384,11 @@ func loadIndex(bundleRoot, indexPath string) (Index, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
index := Index{
|
index := Index{
|
||||||
Path: indexPath,
|
Path: indexPath,
|
||||||
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"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
ReasonCode string `json:"reason_code"`
|
||||||
|
Origin diagnosticOriginDocument `json:"origin"`
|
||||||
|
OccurrenceCount *int `json:"occurrence_count"`
|
||||||
|
Samples *[]struct {
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
ReasonCode string `json:"reason_code"`
|
|
||||||
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 := make([]WarningSummary, 0, len(*document.Groups))
|
||||||
|
occurrences := 0
|
||||||
|
for _, group := range *document.Groups {
|
||||||
|
if err := validateFindingGroup(group); err != nil {
|
||||||
|
return nil, fmt.Errorf("notarius warning group: %w", err)
|
||||||
}
|
}
|
||||||
summaries = append(summaries, WarningSummary{Scope: item.Scope, ReasonCode: item.ReasonCode})
|
if group.Disposition != "warning" {
|
||||||
|
return nil, fmt.Errorf("notarius warning group disposition %q is invalid", group.Disposition)
|
||||||
|
}
|
||||||
|
if *group.OccurrenceCount > int(^uint(0)>>1)-occurrences {
|
||||||
|
return nil, fmt.Errorf("notarius warning occurrence count overflows")
|
||||||
|
}
|
||||||
|
occurrences += *group.OccurrenceCount
|
||||||
|
summaries = append(summaries, WarningSummary{
|
||||||
|
Disposition: group.Disposition, Category: group.Category, ReasonCode: group.ReasonCode,
|
||||||
|
Origin: diagnosticOrigin(group.Origin), OccurrenceCount: *group.OccurrenceCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if occurrences != *document.OccurrenceCount {
|
||||||
|
return nil, fmt.Errorf("notarius warning document occurrence count is inconsistent")
|
||||||
}
|
}
|
||||||
return summaries, nil
|
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)
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
// Package subprocess provides reusable process execution and generated-config helpers.
|
// Package subprocess provides reusable process execution and generated-config helpers.
|
||||||
package subprocess
|
package subprocess
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
|
func TestExecuteRunStageArtifactsUnsupportedStageFails(t *testing.T) {
|
||||||
@@ -43,8 +42,8 @@ func TestExecuteRunStagePublishPropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
@@ -115,8 +114,8 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RunStage() error = %v", err)
|
t.Fatalf("RunStage() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") {
|
if !strings.Contains(out.String(), "stage=analyze executed=1 skipped=0 force=false") {
|
||||||
t.Fatalf("output = %q, want analyze skip without force", out.String())
|
t.Fatalf("output = %q, want legacy analyze evidence rebuilt without implying force", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +143,8 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "executed=1 skipped=11") {
|
if !strings.Contains(out.String(), "executed=4 skipped=8") {
|
||||||
t.Fatalf("output = %q, want all stages skipped", out.String())
|
t.Fatalf("output = %q, want extract reconsidered and legacy analyze plus delivery rebuilt", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,8 +158,8 @@ func TestExecuteAnalyzeForceRunsAnalyze(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
@@ -200,7 +199,7 @@ func TestExecuteAnalyzePropagatesSelectedArtifacts(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, _ []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, _ BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
capturedArtifacts = append([]string(nil), opts.SelectedArtifacts...)
|
||||||
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
|
return &RunSummary{ManifestPath: filepath.Join(workspaceRoot, "manifest.json"), Executed: []string{"analyze"}}, nil
|
||||||
}
|
}
|
||||||
@@ -293,8 +292,8 @@ func TestExecutePublishForceRunsPublish(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
|
|||||||
37
internal/app/analyze_evidence_test_helpers_test.go
Normal file
37
internal/app/analyze_evidence_test_helpers_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setAppAnalyzeEvidence(m *manifest.Manifest, key, relativePath string, body []byte) {
|
||||||
|
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC)
|
||||||
|
record := m.Stages["analyze"]
|
||||||
|
if record == nil {
|
||||||
|
record = &manifest.StageRecord{Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now}
|
||||||
|
m.Stages["analyze"] = record
|
||||||
|
}
|
||||||
|
if record.AnalyzeArtifacts == nil {
|
||||||
|
record.AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{}
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256(body)
|
||||||
|
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
record.AnalyzeArtifacts[key] = manifest.AnalyzeArtifactRecord{
|
||||||
|
Key: key, Status: manifest.AnalyzeArtifactCurrent,
|
||||||
|
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
|
||||||
|
Fingerprint: strings.Repeat("1", 64),
|
||||||
|
Output: &manifest.ArtifactRecord{
|
||||||
|
Kind: "scriptorium_artifact", SourceID: artifacts.ConfiguredArtifactSourceID(key), LocalPath: relativePath,
|
||||||
|
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
|
||||||
|
ProducerRunID: "run-1", Checksum: hex.EncodeToString(digest[:]),
|
||||||
|
},
|
||||||
|
OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
134
internal/app/analyze_projection.go
Normal file
134
internal/app/analyze_projection.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type validatedAnalyzeProjection struct {
|
||||||
|
session map[string]manifest.AnalyzeArtifactRecord
|
||||||
|
invocation map[string]manifest.AnalyzeArtifactRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
type analyzeStateSnapshot struct {
|
||||||
|
version int
|
||||||
|
records map[string]manifest.AnalyzeArtifactRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureAnalyzeState(manifestValue *manifest.Manifest, stageName string) analyzeStateSnapshot {
|
||||||
|
if manifestValue == nil || stageName != "analyze" || manifestValue.Stages["analyze"] == nil {
|
||||||
|
return analyzeStateSnapshot{}
|
||||||
|
}
|
||||||
|
record := manifestValue.Stages["analyze"]
|
||||||
|
return analyzeStateSnapshot{
|
||||||
|
version: record.AnalyzeStateVersion,
|
||||||
|
records: manifest.CloneAnalyzeArtifactCollection(record.AnalyzeArtifacts),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreAnalyzeState(manifestValue *manifest.Manifest, snapshot analyzeStateSnapshot) {
|
||||||
|
if manifestValue == nil || manifestValue.Stages["analyze"] == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record := manifestValue.Stages["analyze"]
|
||||||
|
record.AnalyzeStateVersion = snapshot.version
|
||||||
|
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(snapshot.records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSuccessfulAnalyzeProjection(stageName string, result *stage.StageResult) (*validatedAnalyzeProjection, error) {
|
||||||
|
if result == nil || result.AnalyzeState == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if stageName != "analyze" {
|
||||||
|
return nil, fmt.Errorf("stage %q returned analyze-owned state projection", stageName)
|
||||||
|
}
|
||||||
|
if result.Disposition == stage.StageDispositionSkipped {
|
||||||
|
return nil, fmt.Errorf("skipped analyze result cannot contain analyze-owned state projection")
|
||||||
|
}
|
||||||
|
if len(result.Outputs) != 0 {
|
||||||
|
return nil, fmt.Errorf("analyze result with state projection cannot contain ordinary outputs")
|
||||||
|
}
|
||||||
|
return validateAndCloneAnalyzeProjection(result.AnalyzeState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateFailedAnalyzeProjection(stageName string, result *stage.StageResult) (*validatedAnalyzeProjection, error) {
|
||||||
|
if result == nil || result.AnalyzeState == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if stageName != "analyze" {
|
||||||
|
return nil, fmt.Errorf("stage %q returned analyze-owned state projection with an error", stageName)
|
||||||
|
}
|
||||||
|
if result.Disposition != stage.StageDispositionSucceeded || result.SkipReason != "" || len(result.Outputs) != 0 || len(result.Logs) != 0 || len(result.GeneratedConfigs) != 0 || len(result.Metadata) != 0 {
|
||||||
|
return nil, fmt.Errorf("analyze result with an error may contain only analyze-owned state projection")
|
||||||
|
}
|
||||||
|
return validateAndCloneAnalyzeProjection(result.AnalyzeState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAndCloneAnalyzeProjection(projection *stage.AnalyzeStateProjection) (*validatedAnalyzeProjection, error) {
|
||||||
|
session := manifest.CloneAnalyzeArtifactCollection(projection.Session)
|
||||||
|
invocation := manifest.CloneAnalyzeArtifactCollection(projection.Invocation)
|
||||||
|
if err := manifest.ValidateAnalyzeArtifactCollection(manifest.AnalyzeStateContractVersion, session); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate reconciled session analyze state: %w", err)
|
||||||
|
}
|
||||||
|
if err := manifest.ValidateAnalyzeArtifactCollection(manifest.AnalyzeStateContractVersion, invocation); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate invocation analyze state: %w", err)
|
||||||
|
}
|
||||||
|
for key, invocationRecord := range invocation {
|
||||||
|
sessionRecord, ok := session[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invocation analyze artifact %q is absent from reconciled session state", key)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(invocationRecord, sessionRecord) {
|
||||||
|
return nil, fmt.Errorf("invocation analyze artifact %q contradicts reconciled session state", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &validatedAnalyzeProjection{session: session, invocation: invocation}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAnalyzeProjection(
|
||||||
|
sessionManifest *manifest.Manifest,
|
||||||
|
runManifest *manifest.RunManifest,
|
||||||
|
projection *validatedAnalyzeProjection,
|
||||||
|
) {
|
||||||
|
if projection == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sessionManifest != nil && sessionManifest.Stages["analyze"] != nil {
|
||||||
|
record := sessionManifest.Stages["analyze"]
|
||||||
|
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(projection.session)
|
||||||
|
}
|
||||||
|
if runManifest != nil && runManifest.Stages["analyze"] != nil {
|
||||||
|
record := runManifest.Stages["analyze"]
|
||||||
|
record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(projection.invocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeProjectionOutputs(records map[string]manifest.AnalyzeArtifactRecord, producerRunID string) []manifest.ArtifactRecord {
|
||||||
|
keys := make([]string, 0, len(records))
|
||||||
|
for key, record := range records {
|
||||||
|
if record.Status != manifest.AnalyzeArtifactCurrent || record.Output == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if producerRunID != "" && record.ProducerRunID != producerRunID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
outputs := make([]manifest.ArtifactRecord, 0, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
record := manifest.CloneAnalyzeArtifactCollection(map[string]manifest.AnalyzeArtifactRecord{key: records[key]})[key]
|
||||||
|
output := *record.Output
|
||||||
|
if output.ProducerRunID == "" {
|
||||||
|
output.ProducerRunID = record.ProducerRunID
|
||||||
|
}
|
||||||
|
outputs = append(outputs, output)
|
||||||
|
}
|
||||||
|
return outputs
|
||||||
|
}
|
||||||
346
internal/app/analyze_projection_test.go
Normal file
346
internal/app/analyze_projection_test.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type projectionStage struct {
|
||||||
|
name string
|
||||||
|
run func(*stage.Env, *manifest.Manifest) (*stage.StageResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s projectionStage) Name() string { return s.name }
|
||||||
|
func (s projectionStage) Run(_ context.Context, env *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
return s.run(env, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesProjectsSeparateSessionAndInvocationAnalyzeState(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
oldAt := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
oldRecord := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", oldAt)
|
||||||
|
|
||||||
|
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
newRecord := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
staleRecord := appAnalyzeRecord("quest_log", manifest.AnalyzeArtifactStale, m.RunID, time.Now().UTC())
|
||||||
|
session := map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"player_handout": oldRecord,
|
||||||
|
"quest_log": staleRecord,
|
||||||
|
"session_recap": newRecord,
|
||||||
|
}
|
||||||
|
return &stage.StageResult{
|
||||||
|
Logs: []string{"aggregate-analyze.log"},
|
||||||
|
AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: session,
|
||||||
|
Invocation: map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"player_handout": oldRecord,
|
||||||
|
"session_recap": newRecord,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
summary, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
sessionManifest, err := store.Load(context.Background(), summary.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(session) error = %v", err)
|
||||||
|
}
|
||||||
|
analyze := sessionManifest.Stages["analyze"]
|
||||||
|
if analyze.AnalyzeStateVersion != manifest.AnalyzeStateContractVersion || len(analyze.AnalyzeArtifacts) != 3 {
|
||||||
|
t.Fatalf("session analyze state = %#v", analyze)
|
||||||
|
}
|
||||||
|
if got := analyzeArtifactOutputKeys(analyze.Outputs); !reflect.DeepEqual(got, []string{"player_handout", "session_recap"}) {
|
||||||
|
t.Fatalf("session aggregate outputs = %#v, want current records only", got)
|
||||||
|
}
|
||||||
|
if len(analyze.Logs) != 1 || analyze.Logs[0] != "aggregate-analyze.log" {
|
||||||
|
t.Fatalf("session aggregate logs = %#v", analyze.Logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
runManifest, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadRun() error = %v", err)
|
||||||
|
}
|
||||||
|
runAnalyze := runManifest.Stages["analyze"]
|
||||||
|
if len(runAnalyze.AnalyzeArtifacts) != 2 || runAnalyze.AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
|
||||||
|
t.Fatalf("invocation analyze state = %#v", runAnalyze.AnalyzeArtifacts)
|
||||||
|
}
|
||||||
|
if got := analyzeArtifactOutputKeys(runAnalyze.Outputs); !reflect.DeepEqual(got, []string{"session_recap"}) {
|
||||||
|
t.Fatalf("invocation outputs = %#v, want produced artifact only", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesPersistsRestrictedAnalyzeStateOnPartialError(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, now)
|
||||||
|
seed.Campaign = cfg.Session.Campaign
|
||||||
|
seed.MarkStageSucceeded("analyze", now, nil)
|
||||||
|
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"player_handout": appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now),
|
||||||
|
}
|
||||||
|
seed.MarkStageSucceeded("publish", now, nil)
|
||||||
|
saveBoundedManifest(t, cfg, seed)
|
||||||
|
|
||||||
|
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
unrelated := seed.Stages["analyze"].AnalyzeArtifacts["player_handout"]
|
||||||
|
completed := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
failed := appAnalyzeRecord("quest_log", manifest.AnalyzeArtifactFailed, m.RunID, time.Now().UTC())
|
||||||
|
session := map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"player_handout": unrelated,
|
||||||
|
"quest_log": failed,
|
||||||
|
"session_recap": completed,
|
||||||
|
}
|
||||||
|
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: session,
|
||||||
|
Invocation: map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"quest_log": failed,
|
||||||
|
"session_recap": completed,
|
||||||
|
},
|
||||||
|
}}, errors.New("quest log failed")
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: true})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "quest log failed") {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(session) error = %v", err)
|
||||||
|
}
|
||||||
|
analyze := loaded.Stages["analyze"]
|
||||||
|
if analyze.Status != manifest.StatusFailed || len(analyze.Outputs) != 0 {
|
||||||
|
t.Fatalf("aggregate analyze state = %#v, want failed without outputs", analyze)
|
||||||
|
}
|
||||||
|
if analyze.AnalyzeArtifacts["player_handout"].Status != manifest.AnalyzeArtifactCurrent ||
|
||||||
|
analyze.AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent ||
|
||||||
|
analyze.AnalyzeArtifacts["quest_log"].Status != manifest.AnalyzeArtifactFailed {
|
||||||
|
t.Fatalf("partial session projection = %#v", analyze.AnalyzeArtifacts)
|
||||||
|
}
|
||||||
|
if loaded.Stages["publish"].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("publish status = %q, want stale", loaded.Stages["publish"].Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
runsDir := artifacts.SessionRunsDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
entries, err := os.ReadDir(runsDir)
|
||||||
|
if err != nil || len(entries) != 1 {
|
||||||
|
t.Fatalf("run directory entries = %#v, error = %v", entries, err)
|
||||||
|
}
|
||||||
|
runManifest, err := store.LoadRun(context.Background(), filepath.Join(runsDir, entries[0].Name(), "manifest.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadRun() error = %v", err)
|
||||||
|
}
|
||||||
|
runAnalyze := runManifest.Stages["analyze"]
|
||||||
|
if runAnalyze.Status != manifest.StatusFailed || len(runAnalyze.AnalyzeArtifacts) != 2 || len(runAnalyze.Outputs) != 0 {
|
||||||
|
t.Fatalf("partial invocation projection = %#v", runAnalyze)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRejectsInvalidAnalyzeProjectionWithoutReplacingPriorState(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
prior := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now)
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, now)
|
||||||
|
seed.Campaign = cfg.Session.Campaign
|
||||||
|
seed.MarkStageSucceeded("analyze", now, nil)
|
||||||
|
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{"player_handout": prior}
|
||||||
|
saveBoundedManifest(t, cfg, seed)
|
||||||
|
|
||||||
|
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
invalid := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
invalid.Output.Checksum = "invalid"
|
||||||
|
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": invalid},
|
||||||
|
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": invalid},
|
||||||
|
}}, errors.New("analysis failed")
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: true})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "checksum") {
|
||||||
|
t.Fatalf("executeStages() error = %v, want projection validation failure", err)
|
||||||
|
}
|
||||||
|
loaded, loadErr := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("Load() error = %v", loadErr)
|
||||||
|
}
|
||||||
|
if len(loaded.Stages["analyze"].AnalyzeArtifacts) != 1 || !reflect.DeepEqual(loaded.Stages["analyze"].AnalyzeArtifacts["player_handout"], prior) {
|
||||||
|
t.Fatalf("prior state replaced by invalid projection: %#v", loaded.Stages["analyze"].AnalyzeArtifacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRollsBackAnalyzeAuthorityWhenProjectionSaveFails(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
now := time.Date(2026, 5, 3, 9, 0, 0, 0, time.UTC)
|
||||||
|
prior := appAnalyzeRecord("player_handout", manifest.AnalyzeArtifactCurrent, "old-run", now)
|
||||||
|
seed := manifest.New(cfg.Session.SessionID, now)
|
||||||
|
seed.Campaign = cfg.Session.Campaign
|
||||||
|
seed.MarkStageSucceeded("analyze", now, nil)
|
||||||
|
seed.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion
|
||||||
|
seed.Stages["analyze"].AnalyzeArtifacts = map[string]manifest.AnalyzeArtifactRecord{"player_handout": prior}
|
||||||
|
saveBoundedManifest(t, cfg, seed)
|
||||||
|
|
||||||
|
stageReturned := false
|
||||||
|
store := &analyzeProjectionFailingStore{delegate: &manifest.LocalStore{}, shouldFail: func(m *manifest.Manifest) bool {
|
||||||
|
return stageReturned && m.Stages["analyze"] != nil && m.Stages["analyze"].Status == manifest.StatusSucceeded && m.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status == manifest.AnalyzeArtifactCurrent
|
||||||
|
}}
|
||||||
|
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
stageReturned = true
|
||||||
|
newRecord := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: map[string]manifest.AnalyzeArtifactRecord{
|
||||||
|
"player_handout": prior,
|
||||||
|
"session_recap": newRecord,
|
||||||
|
},
|
||||||
|
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": newRecord},
|
||||||
|
}}, nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{
|
||||||
|
Force: true,
|
||||||
|
Env: &Env{ManifestStore: store},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "injected analyze projection save failure") {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if !store.failed {
|
||||||
|
t.Fatal("projection persistence failure was not injected")
|
||||||
|
}
|
||||||
|
loaded, loadErr := store.delegate.Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("Load() error = %v", loadErr)
|
||||||
|
}
|
||||||
|
analyze := loaded.Stages["analyze"]
|
||||||
|
if analyze.Status != manifest.StatusFailed || len(analyze.AnalyzeArtifacts) != 1 || !reflect.DeepEqual(analyze.AnalyzeArtifacts["player_handout"], prior) {
|
||||||
|
t.Fatalf("durable analyze state after rollback = %#v", analyze)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRejectsAnalyzeProjectionFromOtherStage(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
stageToRun := projectionStage{name: "prepare", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
record := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
return &stage.StageResult{AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
|
||||||
|
}}, nil
|
||||||
|
}}
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "returned analyze-owned state projection") {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRejectsContradictoryAnalyzeResultWithError(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
stageToRun := projectionStage{name: "analyze", run: func(_ *stage.Env, m *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
record := appAnalyzeRecord("session_recap", manifest.AnalyzeArtifactCurrent, m.RunID, time.Now().UTC())
|
||||||
|
return &stage.StageResult{
|
||||||
|
Outputs: []artifacts.Ref{{Kind: "session_recap", RelativePath: "artifacts/session-recap.md"}},
|
||||||
|
AnalyzeState: &stage.AnalyzeStateProjection{
|
||||||
|
Session: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
|
||||||
|
Invocation: map[string]manifest.AnalyzeArtifactRecord{"session_recap": record},
|
||||||
|
},
|
||||||
|
}, errors.New("analysis failed")
|
||||||
|
}}
|
||||||
|
_, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "may contain only analyze-owned state projection") {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesExposesSelectedForceDecisionToStage(t *testing.T) {
|
||||||
|
for _, force := range []bool{false, true} {
|
||||||
|
t.Run(strings.ToLower(strings.TrimSpace(map[bool]string{false: "ordinary", true: "forced"}[force])), func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
captured := !force
|
||||||
|
stageToRun := projectionStage{name: "prepare", run: func(env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
captured = env.Force
|
||||||
|
return &stage.StageResult{}, nil
|
||||||
|
}}
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{stageToRun}, RunOptions{Force: force}); err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if captured != force {
|
||||||
|
t.Fatalf("stage env force = %v, want %v", captured, force)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appAnalyzeRecord(key string, status manifest.AnalyzeArtifactStatus, producerRunID string, at time.Time) manifest.AnalyzeArtifactRecord {
|
||||||
|
record := manifest.AnalyzeArtifactRecord{
|
||||||
|
Key: key,
|
||||||
|
Status: status,
|
||||||
|
ProducerRunID: producerRunID,
|
||||||
|
UpdatedAt: at,
|
||||||
|
}
|
||||||
|
if status == manifest.AnalyzeArtifactFailed {
|
||||||
|
record.Error = "scriptorium failed"
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
if status != manifest.AnalyzeArtifactCurrent {
|
||||||
|
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
|
||||||
|
record.Fingerprint = strings.Repeat("b", 64)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
|
||||||
|
record.Fingerprint = strings.Repeat("a", 64)
|
||||||
|
record.OutputSize = 42
|
||||||
|
record.Output = &manifest.ArtifactRecord{
|
||||||
|
Kind: key,
|
||||||
|
SourceID: artifacts.ConfiguredArtifactSourceID(key),
|
||||||
|
LocalPath: "artifacts/" + strings.ReplaceAll(key, "_", "-") + ".md",
|
||||||
|
ProducerRunID: producerRunID,
|
||||||
|
Checksum: strings.Repeat("c", 64),
|
||||||
|
Contract: &artifactmodel.ContractMetadata{
|
||||||
|
MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeArtifactOutputKeys(outputs []manifest.ArtifactRecord) []string {
|
||||||
|
keys := make([]string, 0, len(outputs))
|
||||||
|
for _, output := range outputs {
|
||||||
|
keys = append(keys, strings.TrimPrefix(output.SourceID, "narratio.artifact."))
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
type analyzeProjectionFailingStore struct {
|
||||||
|
delegate *manifest.LocalStore
|
||||||
|
shouldFail func(*manifest.Manifest) bool
|
||||||
|
failed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *analyzeProjectionFailingStore) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
|
||||||
|
return s.delegate.Create(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *analyzeProjectionFailingStore) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
|
||||||
|
return s.delegate.Load(ctx, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *analyzeProjectionFailingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
|
||||||
|
if !s.failed && s.shouldFail != nil && s.shouldFail(m) {
|
||||||
|
s.failed = true
|
||||||
|
return errors.New("injected analyze projection save failure")
|
||||||
|
}
|
||||||
|
return s.delegate.Save(ctx, path, m)
|
||||||
|
}
|
||||||
295
internal/app/assembled_workflow_test.go
Normal file
295
internal/app/assembled_workflow_test.go
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAssembledFullRunUsesCanonicalOrderAndBoundedRunManifests(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
canonical := []string{
|
||||||
|
"prepare", "transcribe", "merge", "polish", "normalize", "trim",
|
||||||
|
"render", "extract", "analyze", "publish", "notify",
|
||||||
|
}
|
||||||
|
var order []string
|
||||||
|
stages := make([]stage.Stage, 0, len(canonical))
|
||||||
|
for _, name := range canonical {
|
||||||
|
stages = append(stages, resultStage{name: name, result: &stage.StageResult{}, order: &order})
|
||||||
|
}
|
||||||
|
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(order, canonical) || !reflect.DeepEqual(summary.Executed, canonical) {
|
||||||
|
t.Fatalf("execution order=%#v summary=%#v, want %#v", order, summary.Executed, canonical)
|
||||||
|
}
|
||||||
|
runManifest, err := (&manifest.LocalStore{}).LoadRun(context.Background(), summary.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(runManifest.RequestedStages, canonical) {
|
||||||
|
t.Fatalf("requested stages = %#v, want canonical order", runManifest.RequestedStages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembledCanonicalAndAliasArtifactRegenerationRequestsMatch(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
type capturedRequest struct {
|
||||||
|
stages []string
|
||||||
|
artifacts []string
|
||||||
|
force bool
|
||||||
|
}
|
||||||
|
var captured []capturedRequest
|
||||||
|
original := executeStagesFn
|
||||||
|
t.Cleanup(func() { executeStagesFn = original })
|
||||||
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
||||||
|
captured = append(captured, capturedRequest{
|
||||||
|
stages: plan.Names(), artifacts: append([]string(nil), options.SelectedArtifacts...), force: options.Force,
|
||||||
|
})
|
||||||
|
return &RunSummary{SessionID: "2026-05-03", ManifestPath: manifestPathForConfig(workspaceRoot)}, nil
|
||||||
|
}
|
||||||
|
base := []string{
|
||||||
|
"2026-05-03", "--force", "--from", "extract", "--through", "analyze",
|
||||||
|
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||||
|
}
|
||||||
|
if err := Run(context.Background(), base, &bytes.Buffer{}); err != nil {
|
||||||
|
t.Fatalf("canonical unselected Run() error = %v", err)
|
||||||
|
}
|
||||||
|
selected := append(append([]string(nil), base...), "--artifacts", "session_recap")
|
||||||
|
if err := Run(context.Background(), selected, &bytes.Buffer{}); err != nil {
|
||||||
|
t.Fatalf("canonical selected Run() error = %v", err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
alias := []string{
|
||||||
|
"regenerate-artifacts", "2026-05-03", "--artifacts", "session_recap",
|
||||||
|
"--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||||
|
}
|
||||||
|
if code := Execute(alias, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("alias exit=%d stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if len(captured) != 3 {
|
||||||
|
t.Fatalf("captured requests = %#v", captured)
|
||||||
|
}
|
||||||
|
wantStages := []string{"extract", "analyze"}
|
||||||
|
if !reflect.DeepEqual(captured[0].stages, wantStages) || len(captured[0].artifacts) != 0 || !captured[0].force {
|
||||||
|
t.Fatalf("unselected request = %#v", captured[0])
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(captured[1], captured[2]) || !reflect.DeepEqual(captured[1].stages, wantStages) ||
|
||||||
|
!reflect.DeepEqual(captured[1].artifacts, []string{"session_recap"}) || !captured[1].force {
|
||||||
|
t.Fatalf("canonical=%#v alias=%#v, want identical bounded request", captured[1], captured[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembledForcedSiblingIndependenceAndFailureBoundary(t *testing.T) {
|
||||||
|
for _, selected := range []string{"render", "extract"} {
|
||||||
|
t.Run(selected, func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
seedAllStagesSucceeded(t, cfg)
|
||||||
|
plan := mustBoundedPlan(t, selected, selected)
|
||||||
|
runs := 0
|
||||||
|
plan.stages = []stage.Stage{countingStage{name: selected, runs: &runs}}
|
||||||
|
summary, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
loaded := loadAssembledManifest(t, cfg)
|
||||||
|
sibling := "render"
|
||||||
|
if selected == "render" {
|
||||||
|
sibling = "extract"
|
||||||
|
}
|
||||||
|
if loaded.Stages[sibling].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("%s sibling = %#v, want succeeded", sibling, loaded.Stages[sibling])
|
||||||
|
}
|
||||||
|
for _, dependent := range []string{"analyze", "publish", "notify"} {
|
||||||
|
if loaded.Stages[dependent].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("%s status = %q, want stale", dependent, loaded.Stages[dependent].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runManifest, err := (&manifest.LocalStore{}).LoadRun(context.Background(), summary.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(runManifest.RequestedStages, []string{selected}) || len(runManifest.Stages) != 1 {
|
||||||
|
t.Fatalf("bounded run manifest = %#v", runManifest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("stop on failure", func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
seedAllStagesSucceeded(t, cfg)
|
||||||
|
plan := mustBoundedPlan(t, "render", "render")
|
||||||
|
plan.stages = []stage.Stage{failingStage{name: "render", err: context.Canceled}}
|
||||||
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("forced render failure returned nil")
|
||||||
|
}
|
||||||
|
loaded := loadAssembledManifest(t, cfg)
|
||||||
|
if loaded.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("extract sibling = %#v", loaded.Stages["extract"])
|
||||||
|
}
|
||||||
|
for _, outside := range []string{"analyze", "publish", "notify"} {
|
||||||
|
if loaded.Stages[outside].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("outside stage %s = %#v, want stale and unexecuted", outside, loaded.Stages[outside])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssembledLegacyAnalyzeTransitionPublishesOnlyCurrentRecords(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
cfg.Pipeline.Scriptorium = &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
||||||
|
"player_handout": {Enabled: true, PromptID: "dnd.player_handout", OutputPath: "artifacts/player_handout.md"},
|
||||||
|
"session_recap": {Enabled: true, PromptID: "dnd.session_recap", OutputPath: "artifacts/session_recap.md"},
|
||||||
|
}}
|
||||||
|
cfg.Pipeline.Storage.Backend = config.StorageBackendS3
|
||||||
|
cfg.Pipeline.Storage.S3 = &config.StorageS3Config{Bucket: "archive", RootPrefix: "dnd"}
|
||||||
|
cfg.Pipeline.Publish = &config.PublishConfig{
|
||||||
|
Enabled: boolPtr(true), UploadRun: boolPtr(true),
|
||||||
|
Outputs: []config.PublishOutputRule{
|
||||||
|
{Source: artifacts.ConfiguredArtifactSourceID("player_handout"), Dest: "artifacts/player_handout.md", Required: boolPtr(true)},
|
||||||
|
{Source: artifacts.ConfiguredArtifactSourceID("session_recap"), Dest: "artifacts/session_recap.md", Required: boolPtr(true)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
paths, err := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
legacyHandout := []byte("legacy handout\n")
|
||||||
|
legacyRecap := []byte("legacy recap\n")
|
||||||
|
mustWriteTestFile(t, filepath.Join(paths.ArtifactsDir, "player_handout.md"), string(legacyHandout))
|
||||||
|
mustWriteTestFile(t, filepath.Join(paths.ArtifactsDir, "session_recap.md"), string(legacyRecap))
|
||||||
|
|
||||||
|
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||||
|
m := manifest.New(cfg.Session.SessionID, now)
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
for index, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
|
||||||
|
m.MarkStageSucceeded(name, now.Add(time.Duration(index)*time.Minute), nil)
|
||||||
|
}
|
||||||
|
// Historical manifests can show extract completing before render. Status,
|
||||||
|
// not the old relative timestamps, is the compatibility authority.
|
||||||
|
m.MarkStageSucceeded("extract", now.Add(10*time.Minute), nil)
|
||||||
|
m.MarkStageSucceeded("render", now.Add(11*time.Minute), nil)
|
||||||
|
m.MarkStageSucceeded("analyze", now.Add(12*time.Minute), []manifest.ArtifactRecord{
|
||||||
|
{Kind: "player_handout", LocalPath: "artifacts/player_handout.md"},
|
||||||
|
{Kind: "session_recap", LocalPath: "artifacts/session_recap.md"},
|
||||||
|
})
|
||||||
|
if err := (&manifest.LocalStore{}).Save(context.Background(), paths.ManifestPath, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
analyze, err := stage.Select("analyze")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fake := &scriptorium.FakeRunner{}
|
||||||
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
|
||||||
|
SelectedArtifacts: []string{"session_recap"}, Env: &Env{Scriptorium: fake},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("partial legacy regeneration: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 1 || fake.RunRequests[0].PromptID != "dnd.session_recap" {
|
||||||
|
t.Fatalf("partial requests = %#v", fake.RunRequests)
|
||||||
|
}
|
||||||
|
afterPartial := loadAssembledManifest(t, cfg)
|
||||||
|
if len(afterPartial.Stages["analyze"].AnalyzeArtifacts) != 1 ||
|
||||||
|
afterPartial.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
|
||||||
|
t.Fatalf("partial state = %#v", afterPartial.Stages["analyze"].AnalyzeArtifacts)
|
||||||
|
}
|
||||||
|
for _, transcriptStage := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"} {
|
||||||
|
if afterPartial.Stages[transcriptStage].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("legacy transition invalidated %s: %#v", transcriptStage, afterPartial.Stages[transcriptStage])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configured := artifacts.ConfiguredArtifactDefinitions(cfg.Pipeline.Scriptorium.Artifacts)
|
||||||
|
effective, err := artifacts.ResolveEffectiveArtifactSet(configured, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog, err := artifacts.BootstrapRuntimeCatalog(configured, effective, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, afterPartial, configured)
|
||||||
|
if entry, ok := catalog.Lookup(artifacts.ConfiguredArtifactSourceID("player_handout")); !ok || entry.Available {
|
||||||
|
t.Fatalf("legacy unselected handout catalog entry = %#v, present=%v", entry, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
full, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{Env: &Env{Scriptorium: fake}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("full regeneration: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 2 || fake.RunRequests[1].PromptID != "dnd.player_handout" {
|
||||||
|
t.Fatalf("full requests = %#v, want only missing handout added", fake.RunRequests)
|
||||||
|
}
|
||||||
|
fullRun, err := (&manifest.LocalStore{}).LoadRun(context.Background(), full.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := analyzeArtifactOutputKeys(fullRun.Stages["analyze"].Outputs); !reflect.DeepEqual(got, []string{"player_handout"}) {
|
||||||
|
t.Fatalf("full invocation outputs = %#v, want newly generated handout only", got)
|
||||||
|
}
|
||||||
|
afterFull := loadAssembledManifest(t, cfg)
|
||||||
|
for _, key := range []string{"player_handout", "session_recap"} {
|
||||||
|
if afterFull.Stages["analyze"].AnalyzeArtifacts[key].Status != manifest.AnalyzeArtifactCurrent {
|
||||||
|
t.Fatalf("%s state = %#v", key, afterFull.Stages["analyze"].AnalyzeArtifacts[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
publish, err := stage.Select("publish")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
remote := &storage.FakeBackend{}
|
||||||
|
published, err := executeStages(context.Background(), cfg, []stage.Stage{publish}, RunOptions{Env: &Env{ObjectStore: remote}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("publish current records: %v", err)
|
||||||
|
}
|
||||||
|
afterPublish := loadAssembledManifest(t, cfg)
|
||||||
|
if got := afterPublish.Stages["publish"].Metadata["published_files_uploaded"]; got != float64(2) {
|
||||||
|
t.Fatalf("published files = %#v, want 2", got)
|
||||||
|
}
|
||||||
|
publishRun, err := (&manifest.LocalStore{}).LoadRun(context.Background(), published.RunManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(publishRun.RequestedStages, []string{"publish"}) || publishRun.Stages["analyze"] != nil {
|
||||||
|
t.Fatalf("publish run manifest = %#v", publishRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedAllStagesSucceeded(t *testing.T, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
for _, name := range canonicalStageNames() {
|
||||||
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
saveBoundedManifest(t, cfg, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAssembledManifest(t *testing.T, cfg *config.Config) *manifest.Manifest {
|
||||||
|
t.Helper()
|
||||||
|
m, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func manifestPathForConfig(workspaceRoot string) string {
|
||||||
|
return artifacts.SessionManifestPathForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
||||||
|
}
|
||||||
62
internal/app/bounded_prerequisites.go
Normal file
62
internal/app/bounded_prerequisites.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateBoundedPrerequisites(plan BoundedPlan, m *manifest.Manifest) error {
|
||||||
|
if !plan.HasExplicitBounds() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, name := range plan.PrefixNames() {
|
||||||
|
status := "absent"
|
||||||
|
if m != nil && m.Stages != nil && m.Stages[name] != nil {
|
||||||
|
stageStatus := m.Stages[name].Status
|
||||||
|
if stageStatus == manifest.StatusSucceeded || stageStatus == manifest.StatusSkipped {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stageStatus != "" {
|
||||||
|
status = string(stageStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"prerequisite stage %q has unusable status %q before selected start %q; widen the range with --from %s or recover %s explicitly",
|
||||||
|
name,
|
||||||
|
status,
|
||||||
|
plan.From(),
|
||||||
|
name,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inspectBoundedPrerequisites(ctx context.Context, cfg *config.Config, plan BoundedPlan, store manifest.Store) error {
|
||||||
|
if !plan.HasExplicitBounds() || len(plan.PrefixNames()) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
|
||||||
|
return fmt.Errorf("bounded prerequisite inspection requires resolved pipeline and session configuration")
|
||||||
|
}
|
||||||
|
if store == nil {
|
||||||
|
store = &manifest.LocalStore{}
|
||||||
|
}
|
||||||
|
path := artifacts.SessionManifestPathForCampaign(
|
||||||
|
cfg.Pipeline.Workspace.Root,
|
||||||
|
cfg.Session.Campaign,
|
||||||
|
cfg.Session.SessionID,
|
||||||
|
)
|
||||||
|
m, present, err := loadManifestAtPathIfPresent(ctx, store, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !present {
|
||||||
|
m = nil
|
||||||
|
}
|
||||||
|
return validateBoundedPrerequisites(plan, m)
|
||||||
|
}
|
||||||
345
internal/app/bounded_prerequisites_test.go
Normal file
345
internal/app/bounded_prerequisites_test.go
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateBoundedPrerequisitesRejectsFirstUnusablePrefixStatus(t *testing.T) {
|
||||||
|
plan := mustBoundedPlan(t, "render", "extract")
|
||||||
|
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
status manifest.StageStatus
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "absent", want: "absent"},
|
||||||
|
{name: "pending", status: manifest.StatusPending, want: "pending"},
|
||||||
|
{name: "running", status: manifest.StatusRunning, want: "running"},
|
||||||
|
{name: "failed", status: manifest.StatusFailed, want: "failed"},
|
||||||
|
{name: "stale", status: manifest.StatusStale, want: "stale"},
|
||||||
|
{name: "interrupted", status: manifest.StatusInterrupted, want: "interrupted"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
m := manifest.New("session", now)
|
||||||
|
if test.status != "" {
|
||||||
|
m.Stages["prepare"] = &manifest.StageRecord{Name: "prepare", Status: test.status}
|
||||||
|
}
|
||||||
|
// A later terminal prefix must not hide the first unusable one.
|
||||||
|
m.MarkStageSucceeded("transcribe", now, nil)
|
||||||
|
err := validateBoundedPrerequisites(plan, m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("validateBoundedPrerequisites() error = nil")
|
||||||
|
}
|
||||||
|
for _, detail := range []string{`stage "prepare"`, `status "` + test.want + `"`, `selected start "render"`, "--from prepare", "recover prepare"} {
|
||||||
|
if !strings.Contains(err.Error(), detail) {
|
||||||
|
t.Fatalf("error = %q, want detail %q", err, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBoundedPrerequisitesAcceptsSucceededAndSkippedPrefix(t *testing.T) {
|
||||||
|
plan := mustBoundedPlan(t, "render", "render")
|
||||||
|
m := manifest.New("session", time.Now().UTC())
|
||||||
|
for index, name := range plan.PrefixNames() {
|
||||||
|
if index%2 == 0 {
|
||||||
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
} else {
|
||||||
|
m.MarkStageSkipped(name, time.Now().UTC(), "not applicable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateBoundedPrerequisites(plan, m); err != nil {
|
||||||
|
t.Fatalf("validateBoundedPrerequisites() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateBoundedPrerequisitesHasNoPrefixAtPrepareAndIgnoresSuffix(t *testing.T) {
|
||||||
|
preparePlan := mustBoundedPlan(t, "prepare", "prepare")
|
||||||
|
if err := validateBoundedPrerequisites(preparePlan, nil); err != nil {
|
||||||
|
t.Fatalf("prepare prerequisite validation error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPlan := mustBoundedPlan(t, "render", "render")
|
||||||
|
m := manifest.New("session", time.Now().UTC())
|
||||||
|
markPrefixSucceeded(m, renderPlan)
|
||||||
|
m.MarkStageFailed("analyze", time.Now().UTC(), "later failure")
|
||||||
|
if err := validateBoundedPrerequisites(renderPlan, m); err != nil {
|
||||||
|
t.Fatalf("suffix status affected prerequisite validation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRejectsBoundedPrerequisitesBeforePersistentMutation(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
plan := mustBoundedPlan(t, "render", "render")
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
m.MarkStageRunning("prepare", time.Now().UTC())
|
||||||
|
manifestPath := saveBoundedManifest(t, cfg, m)
|
||||||
|
before, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read seeded manifest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &prerequisiteMutationSpy{local: &manifest.LocalStore{}}
|
||||||
|
runs := 0
|
||||||
|
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
|
||||||
|
_, err = executePlan(context.Background(), cfg, plan, RunOptions{
|
||||||
|
Env: &Env{ManifestStore: store},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `stage "prepare" has unusable status "running"`) {
|
||||||
|
t.Fatalf("executeStages() error = %v, want running prerequisite", err)
|
||||||
|
}
|
||||||
|
if runs != 0 || store.creates != 0 || store.saves != 0 {
|
||||||
|
t.Fatalf("runs=%d manifest creates=%d saves=%d, want no mutation", runs, store.creates, store.saves)
|
||||||
|
}
|
||||||
|
after, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read manifest after rejection: %v", err)
|
||||||
|
}
|
||||||
|
if string(after) != string(before) {
|
||||||
|
t.Fatal("manifest changed after prerequisite rejection")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(artifacts.SessionRunsDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("runs directory stat error = %v, want not exist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesRechecksBoundedPrerequisitesUnderSessionLock(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
plan := mustBoundedPlan(t, "render", "render")
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
markPrefixSucceeded(m, plan)
|
||||||
|
saveBoundedManifest(t, cfg, m)
|
||||||
|
|
||||||
|
store := &prerequisiteChangingStore{local: &manifest.LocalStore{}}
|
||||||
|
runs := 0
|
||||||
|
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
|
||||||
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{
|
||||||
|
Env: &Env{ManifestStore: store},
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `stage "prepare" has unusable status "running"`) {
|
||||||
|
t.Fatalf("executeStages() error = %v, want changed prerequisite rejection", err)
|
||||||
|
}
|
||||||
|
if store.loads != 2 {
|
||||||
|
t.Fatalf("manifest loads = %d, want preflight and locked reload", store.loads)
|
||||||
|
}
|
||||||
|
if runs != 0 || store.creates != 0 || store.saves != 0 {
|
||||||
|
t.Fatalf("runs=%d manifest creates=%d saves=%d, want no run or manifest mutation", runs, store.creates, store.saves)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesBoundedCompositionUsesOnlySelectedCollaborators(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
stageName string
|
||||||
|
configure func(*config.Config)
|
||||||
|
assertProbe func(*testing.T, *stage.Env)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "render",
|
||||||
|
stageName: "render",
|
||||||
|
assertProbe: func(t *testing.T, env *stage.Env) {
|
||||||
|
if env.Seriatim == nil || env.Notarius != nil || env.Scriptorium != nil {
|
||||||
|
t.Fatalf("render collaborators: seriatim=%v notarius=%v scriptorium=%v", env.Seriatim, env.Notarius, env.Scriptorium)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extract",
|
||||||
|
stageName: "extract",
|
||||||
|
configure: func(cfg *config.Config) {
|
||||||
|
cfg.Pipeline.Notarius = &config.NotariusConfig{Enabled: true}
|
||||||
|
},
|
||||||
|
assertProbe: func(t *testing.T, env *stage.Env) {
|
||||||
|
if env.Notarius == nil || env.Scriptorium != nil || env.Seriatim != nil {
|
||||||
|
t.Fatalf("extract collaborators: notarius=%v scriptorium=%v seriatim=%v", env.Notarius, env.Scriptorium, env.Seriatim)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "analyze",
|
||||||
|
stageName: "analyze",
|
||||||
|
assertProbe: func(t *testing.T, env *stage.Env) {
|
||||||
|
if env.Scriptorium == nil || env.Notarius != nil || env.Seriatim != nil || env.WhisperX != nil || env.Audita != nil {
|
||||||
|
t.Fatalf("analyze collaborators: scriptorium=%v notarius=%v seriatim=%v whisperx=%v audita=%v", env.Scriptorium, env.Notarius, env.Seriatim, env.WhisperX, env.Audita)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
if test.configure != nil {
|
||||||
|
test.configure(cfg)
|
||||||
|
}
|
||||||
|
plan := mustBoundedPlan(t, test.stageName, test.stageName)
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
markPrefixSucceeded(m, plan)
|
||||||
|
saveBoundedManifest(t, cfg, m)
|
||||||
|
|
||||||
|
var captured *stage.Env
|
||||||
|
plan.stages = []stage.Stage{collaboratorProbeStage{name: test.stageName, captured: &captured}}
|
||||||
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if captured == nil {
|
||||||
|
t.Fatal("selected stage did not run")
|
||||||
|
}
|
||||||
|
test.assertProbe(t, captured)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesBoundedForceStalesButDoesNotRunDependentsOutsideRange(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
plan := mustBoundedPlan(t, "render", "render")
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
|
||||||
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
saveBoundedManifest(t, cfg, m)
|
||||||
|
runs := 0
|
||||||
|
plan.stages = []stage.Stage{countingStage{name: "render", runs: &runs}}
|
||||||
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{Force: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if runs != 1 {
|
||||||
|
t.Fatalf("selected render runs = %d, want 1", runs)
|
||||||
|
}
|
||||||
|
loaded, err := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load manifest: %v", err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"analyze", "publish", "notify"} {
|
||||||
|
if loaded.Stages[name].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("stage %q status = %q, want stale", name, loaded.Stages[name].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loaded.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("extract status = %q, want succeeded", loaded.Stages["extract"].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteStagesBoundedFailureStopsWithinSelectedRange(t *testing.T) {
|
||||||
|
cfg := testConfig(t)
|
||||||
|
plan := mustBoundedPlan(t, "render", "extract")
|
||||||
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
m.Campaign = cfg.Session.Campaign
|
||||||
|
markPrefixSucceeded(m, plan)
|
||||||
|
saveBoundedManifest(t, cfg, m)
|
||||||
|
extractRuns := 0
|
||||||
|
plan.stages = []stage.Stage{
|
||||||
|
failingStage{name: "render", err: errors.New("render failed")},
|
||||||
|
countingStage{name: "extract", runs: &extractRuns},
|
||||||
|
}
|
||||||
|
_, err := executePlan(context.Background(), cfg, plan, RunOptions{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "render failed") {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if extractRuns != 0 {
|
||||||
|
t.Fatalf("extract runs = %d, want 0", extractRuns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type collaboratorProbeStage struct {
|
||||||
|
name string
|
||||||
|
captured **stage.Env
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s collaboratorProbeStage) Name() string { return s.name }
|
||||||
|
func (s collaboratorProbeStage) Run(_ context.Context, env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
*s.captured = env
|
||||||
|
return &stage.StageResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type prerequisiteMutationSpy struct {
|
||||||
|
local *manifest.LocalStore
|
||||||
|
creates int
|
||||||
|
saves int
|
||||||
|
}
|
||||||
|
|
||||||
|
type prerequisiteChangingStore struct {
|
||||||
|
local *manifest.LocalStore
|
||||||
|
loads int
|
||||||
|
creates int
|
||||||
|
saves int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteChangingStore) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
|
||||||
|
s.creates++
|
||||||
|
return s.local.Create(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteChangingStore) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
|
||||||
|
s.loads++
|
||||||
|
m, err := s.local.Load(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if s.loads == 2 {
|
||||||
|
m.MarkStageRunning("prepare", time.Now().UTC())
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteChangingStore) Save(ctx context.Context, path string, m *manifest.Manifest) error {
|
||||||
|
s.saves++
|
||||||
|
return s.local.Save(ctx, path, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteMutationSpy) Create(ctx context.Context, sessionID string) (*manifest.Manifest, error) {
|
||||||
|
s.creates++
|
||||||
|
return s.local.Create(ctx, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteMutationSpy) Load(ctx context.Context, path string) (*manifest.Manifest, error) {
|
||||||
|
return s.local.Load(ctx, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *prerequisiteMutationSpy) Save(ctx context.Context, path string, m *manifest.Manifest) error {
|
||||||
|
s.saves++
|
||||||
|
return s.local.Save(ctx, path, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustBoundedPlan(t *testing.T, from, through string) BoundedPlan {
|
||||||
|
t.Helper()
|
||||||
|
plan, err := BuildBoundedPlan(from, through)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(%q, %q) error = %v", from, through, err)
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
func markPrefixSucceeded(m *manifest.Manifest, plan BoundedPlan) {
|
||||||
|
for _, name := range plan.PrefixNames() {
|
||||||
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveBoundedManifest(t *testing.T, cfg *config.Config, m *manifest.Manifest) string {
|
||||||
|
t.Helper()
|
||||||
|
path := manifestPathFor(cfg)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("create manifest directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := (&manifest.LocalStore{}).Save(context.Background(), path, m); err != nil {
|
||||||
|
t.Fatalf("save manifest: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
110
internal/app/bounded_run.go
Normal file
110
internal/app/bounded_run.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type boundedRunRequest struct {
|
||||||
|
Config commonConfigFlags
|
||||||
|
Plan BoundedPlan
|
||||||
|
Force bool
|
||||||
|
SelectedArtifacts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type singletonStringFlag struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
set bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *singletonStringFlag) String() string { return f.value }
|
||||||
|
|
||||||
|
func (f *singletonStringFlag) Set(value string) error {
|
||||||
|
if f.set {
|
||||||
|
return fmt.Errorf("--%s may be specified only once", f.name)
|
||||||
|
}
|
||||||
|
f.value = value
|
||||||
|
f.set = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type singletonBoolFlag struct {
|
||||||
|
name string
|
||||||
|
value bool
|
||||||
|
set bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *singletonBoolFlag) String() string { return strconv.FormatBool(f.value) }
|
||||||
|
func (f *singletonBoolFlag) IsBoolFlag() bool { return true }
|
||||||
|
|
||||||
|
func (f *singletonBoolFlag) Set(raw string) error {
|
||||||
|
if f.set {
|
||||||
|
return fmt.Errorf("--%s may be specified only once", f.name)
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("--%s requires a boolean value: %w", f.name, err)
|
||||||
|
}
|
||||||
|
f.value = value
|
||||||
|
f.set = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBoundedRunRequest(command string, args []string, help io.Writer) (boundedRunRequest, error) {
|
||||||
|
fs := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||||
|
fs.SetOutput(help)
|
||||||
|
|
||||||
|
var configFlags commonConfigFlags
|
||||||
|
var from singletonStringFlag
|
||||||
|
var through singletonStringFlag
|
||||||
|
var force singletonBoolFlag
|
||||||
|
var selectedArtifacts artifactSelectionFlag
|
||||||
|
from.name = "from"
|
||||||
|
through.name = "through"
|
||||||
|
force.name = "force"
|
||||||
|
|
||||||
|
addCommonConfigFlags(fs, &configFlags)
|
||||||
|
fs.Var(&from, "from", "first canonical stage to select (inclusive)")
|
||||||
|
fs.Var(&through, "through", "last canonical stage to select (inclusive)")
|
||||||
|
fs.Var(&force, "force", "rerun selected stages even when already succeeded")
|
||||||
|
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
|
||||||
|
fs.Usage = func() {
|
||||||
|
invocation := "narratio run"
|
||||||
|
if command == "plan" {
|
||||||
|
invocation = "narratio session plan"
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(help, "Usage: %s <session_id> [--from <stage>] [--through <stage>] [--force] [--artifacts <name[,name...]>] [common config flags]\n\n", invocation)
|
||||||
|
_, _ = fmt.Fprintln(help, "Bounds are inclusive; omitted --from or --through selects the beginning or end of the canonical pipeline.")
|
||||||
|
_, _ = fmt.Fprintln(help)
|
||||||
|
_, _ = fmt.Fprintln(help, "Flags:")
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := parseSessionAwareFlags(command, fs, args, &configFlags.sessionID); err != nil {
|
||||||
|
return boundedRunRequest{}, err
|
||||||
|
}
|
||||||
|
if configFlags.sessionID == "" {
|
||||||
|
return boundedRunRequest{}, fmt.Errorf("%s: session_id is required", command)
|
||||||
|
}
|
||||||
|
plan, err := BuildBoundedPlan(from.value, through.value)
|
||||||
|
if err != nil {
|
||||||
|
return boundedRunRequest{}, fmt.Errorf("%s: %w", command, err)
|
||||||
|
}
|
||||||
|
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
||||||
|
if err != nil {
|
||||||
|
return boundedRunRequest{}, fmt.Errorf("%s: invalid --artifacts: %w", command, err)
|
||||||
|
}
|
||||||
|
if len(normalizedArtifacts) > 0 && !plan.Contains("analyze") && !plan.Contains("publish") {
|
||||||
|
return boundedRunRequest{}, fmt.Errorf("%s: --artifacts requires a selected range containing analyze or publish", command)
|
||||||
|
}
|
||||||
|
|
||||||
|
return boundedRunRequest{
|
||||||
|
Config: configFlags,
|
||||||
|
Plan: plan,
|
||||||
|
Force: force.value,
|
||||||
|
SelectedArtifacts: normalizedArtifacts,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
197
internal/app/bounded_run_test.go
Normal file
197
internal/app/bounded_run_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBoundedRunParsingIsSharedByRunAndPlan(t *testing.T) {
|
||||||
|
args := []string{
|
||||||
|
"2026-05-03",
|
||||||
|
"--from", "extract",
|
||||||
|
"--through=publish",
|
||||||
|
"--force",
|
||||||
|
"--artifacts", "session_recap,player_handout",
|
||||||
|
"--artifacts=session_recap",
|
||||||
|
"--config", "pipeline.yml",
|
||||||
|
}
|
||||||
|
runRequest, err := parseBoundedRunRequest("run", args, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse run request: %v", err)
|
||||||
|
}
|
||||||
|
planRequest, err := parseBoundedRunRequest("plan", args, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse plan request: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(runRequest.Plan.Names(), planRequest.Plan.Names()) ||
|
||||||
|
runRequest.Plan.From() != planRequest.Plan.From() ||
|
||||||
|
runRequest.Plan.Through() != planRequest.Plan.Through() ||
|
||||||
|
runRequest.Force != planRequest.Force ||
|
||||||
|
!reflect.DeepEqual(runRequest.SelectedArtifacts, planRequest.SelectedArtifacts) ||
|
||||||
|
runRequest.Config != planRequest.Config {
|
||||||
|
t.Fatalf("run request = %#v, plan request = %#v", runRequest, planRequest)
|
||||||
|
}
|
||||||
|
wantArtifacts := []string{"player_handout", "session_recap"}
|
||||||
|
if !reflect.DeepEqual(runRequest.SelectedArtifacts, wantArtifacts) {
|
||||||
|
t.Fatalf("artifacts = %#v, want %#v", runRequest.SelectedArtifacts, wantArtifacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "from separate", args: []string{"session", "--from", "render", "--from", "extract"}, want: "--from may be specified only once"},
|
||||||
|
{name: "from equals", args: []string{"session", "--from=render", "--from=extract"}, want: "--from may be specified only once"},
|
||||||
|
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
|
||||||
|
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
|
||||||
|
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := parseBoundedRunRequest("run", test.args, io.Discard)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) {
|
||||||
|
args := []string{"session", "--from", "publish", "--through", "render"}
|
||||||
|
runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard)
|
||||||
|
planRequest, planErr := parseBoundedRunRequest("plan", args, io.Discard)
|
||||||
|
if runErr == nil || planErr == nil {
|
||||||
|
t.Fatalf("run request=%#v error=%v; plan request=%#v error=%v", runRequest, runErr, planRequest, planErr)
|
||||||
|
}
|
||||||
|
runDetail := strings.TrimPrefix(runErr.Error(), "run: ")
|
||||||
|
planDetail := strings.TrimPrefix(planErr.Error(), "plan: ")
|
||||||
|
if runDetail != planDetail || !strings.Contains(runDetail, `from stage "publish" occurs after through stage "render"`) {
|
||||||
|
t.Fatalf("run error = %q, plan error = %q", runErr, planErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunParsingGatesArtifactSelectionByRange(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
through string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "render only", through: "render", wantErr: true},
|
||||||
|
{name: "analyze only", through: "analyze"},
|
||||||
|
{name: "publish only", through: "publish"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := parseBoundedRunRequest("run", []string{
|
||||||
|
"session", "--from", test.through, "--through", test.through, "--artifacts", "session_recap",
|
||||||
|
}, io.Discard)
|
||||||
|
if test.wantErr && (err == nil || !strings.Contains(err.Error(), "range containing analyze or publish")) {
|
||||||
|
t.Fatalf("error = %v, want artifact/range error", err)
|
||||||
|
}
|
||||||
|
if !test.wantErr && err != nil {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPassesBoundedPlanToRunner(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
|
var capturedStages []string
|
||||||
|
var capturedPlan BoundedPlan
|
||||||
|
var capturedOptions RunOptions
|
||||||
|
original := executeStagesFn
|
||||||
|
t.Cleanup(func() { executeStagesFn = original })
|
||||||
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
||||||
|
capturedStages = plan.Names()
|
||||||
|
capturedPlan = plan
|
||||||
|
capturedOptions = options
|
||||||
|
return &RunSummary{SessionID: "2026-05-03", ManifestPath: filepath.Join(workspaceRoot, "manifest.json")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := Run(context.Background(), []string{
|
||||||
|
"2026-05-03",
|
||||||
|
"--from", "render",
|
||||||
|
"--through", "extract",
|
||||||
|
"--force",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath,
|
||||||
|
}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"render", "extract"}
|
||||||
|
if !reflect.DeepEqual(capturedStages, want) || !reflect.DeepEqual(capturedPlan.Names(), want) || !capturedOptions.Force {
|
||||||
|
t.Fatalf("stages = %#v options = %#v", capturedStages, capturedOptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanPrintsOnlyBoundedRange(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||||
|
m.Campaign = "sample-campaign"
|
||||||
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
|
||||||
|
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||||
|
}
|
||||||
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
|
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPath, m); err != nil {
|
||||||
|
t.Fatalf("save prerequisite manifest: %v", err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := Plan(context.Background(), []string{
|
||||||
|
"2026-05-03",
|
||||||
|
"--from", "render",
|
||||||
|
"--through", "extract",
|
||||||
|
"--config", pipelinePath,
|
||||||
|
"--campaign-file", campaignPath,
|
||||||
|
"--session", sessionPath,
|
||||||
|
}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Plan() error = %v", err)
|
||||||
|
}
|
||||||
|
got := out.String()
|
||||||
|
if !strings.Contains(got, "render: run\nextract: run\ntotals: run=2 skip=0") {
|
||||||
|
t.Fatalf("output = %q, want bounded decisions", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "trim: ") || strings.Contains(got, "analyze: ") {
|
||||||
|
t.Fatalf("output = %q, contains excluded stages", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunCommandHelp(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "run", args: []string{"run", "--help"}, want: "Usage: narratio run <session_id> [--from <stage>] [--through <stage>]"},
|
||||||
|
{name: "plan", args: []string{"session", "plan", "--help"}, want: "Usage: narratio session plan <session_id> [--from <stage>] [--through <stage>]"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute(test.args, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), test.want) || stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stdout = %q stderr = %q, want %q", stdout.String(), stderr.String(), test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var supportedCommands = []string{"run", "run-stage", "analyze", "publish", "clean", "session"}
|
var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session"}
|
||||||
|
|
||||||
|
var runCommandFn = Run
|
||||||
|
|
||||||
// Execute dispatches CLI commands and returns a process exit code.
|
// Execute dispatches CLI commands and returns a process exit code.
|
||||||
func Execute(args []string, stdout, stderr io.Writer) int {
|
func Execute(args []string, stdout, stderr io.Writer) int {
|
||||||
@@ -22,8 +24,12 @@ func Execute(args []string, stdout, stderr io.Writer) int {
|
|||||||
|
|
||||||
var err error
|
var err error
|
||||||
switch cmd {
|
switch cmd {
|
||||||
|
case "version":
|
||||||
|
err = Version(cmdArgs, stdout)
|
||||||
case "run":
|
case "run":
|
||||||
err = Run(ctx, cmdArgs, stdout)
|
err = runCommandFn(ctx, cmdArgs, stdout)
|
||||||
|
case "regenerate-artifacts":
|
||||||
|
err = RegenerateArtifacts(ctx, cmdArgs, stdout)
|
||||||
case "run-stage":
|
case "run-stage":
|
||||||
err = RunStage(ctx, cmdArgs, stdout)
|
err = RunStage(ctx, cmdArgs, stdout)
|
||||||
case "analyze":
|
case "analyze":
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func TestExecuteValidCommands(t *testing.T) {
|
|||||||
wantOut string
|
wantOut string
|
||||||
}{
|
}{
|
||||||
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=11 skipped=1; manifest="},
|
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=11 skipped=1; manifest="},
|
||||||
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nextract: run\nrender: skip\nanalyze: skip\npublish: skip\nnotify: skip"},
|
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "analyze: skip\n targets: none\n prerequisites: none\n execute: none\n reuse: none\npublish: skip\nnotify: skip"},
|
||||||
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
|
||||||
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -38,32 +71,47 @@ func (r *materializingNotariusRunner) Run(_ context.Context, req notarius.RunReq
|
|||||||
return notarius.RunResult{}, err
|
return notarius.RunResult{}, err
|
||||||
}
|
}
|
||||||
for path, content := range map[string]string{
|
for path, content := range map[string]string{
|
||||||
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,185 @@ func TestExtractLifecycleDisabledThenEnabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractLifecyclePrepareBindsVerifiedReferenceSnapshots(t *testing.T) {
|
||||||
|
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||||
|
originalPaths := configureLifecycleReferences(t, cfg)
|
||||||
|
|
||||||
|
summary, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(summary.Executed) != 2 || len(runner.requests) != 1 {
|
||||||
|
t.Fatalf("summary = %#v requests=%d", summary, len(runner.requests))
|
||||||
|
}
|
||||||
|
paths, err := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureLayoutFor() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []struct {
|
||||||
|
selector string
|
||||||
|
sourceID string
|
||||||
|
filename string
|
||||||
|
}{
|
||||||
|
{selector: "glossary", sourceID: artifactpolicy.SourceInputGlossary, filename: "glossary.yml"},
|
||||||
|
{selector: "party", sourceID: artifactpolicy.SourceInputParty, filename: "party.yml"},
|
||||||
|
{selector: "players", sourceID: artifactpolicy.SourceInputPlayers, filename: "players.yml"},
|
||||||
|
{selector: "spells", sourceID: artifactpolicy.SourceInputSpellCatalog, filename: "spell_catalog.json"},
|
||||||
|
}
|
||||||
|
request := runner.requests[0]
|
||||||
|
if len(request.References) != len(want) {
|
||||||
|
t.Fatalf("references = %#v", request.References)
|
||||||
|
}
|
||||||
|
loaded := loadLifecycleManifest(t, cfg)
|
||||||
|
for index, expected := range want {
|
||||||
|
binding := request.References[index]
|
||||||
|
canonical := filepath.Join(paths.InputsDir, expected.filename)
|
||||||
|
snapshot := filepath.Join(
|
||||||
|
artifacts.SessionRunNotariusReferencesDirForCampaign(
|
||||||
|
cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID, loaded.RunID,
|
||||||
|
),
|
||||||
|
expected.filename,
|
||||||
|
)
|
||||||
|
if binding.Selector != expected.selector || binding.Path != snapshot || binding.Path == canonical || binding.Path == originalPaths[expected.sourceID] {
|
||||||
|
t.Fatalf("reference[%d] = %#v, want selector %q snapshot %q and not prepared/source paths", index, binding, expected.selector, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extract := loaded.Stages["extract"]
|
||||||
|
if extract == nil || extract.Status != manifest.StatusSucceeded || extract.Metadata["reference_count"] != float64(len(want)) {
|
||||||
|
t.Fatalf("extract record = %#v", extract)
|
||||||
|
}
|
||||||
|
references, ok := extract.Metadata["references"].([]any)
|
||||||
|
if !ok || len(references) != len(want) || len(references) > config.MaxNotariusReferenceBindings {
|
||||||
|
t.Fatalf("reference metadata = %#v", extract.Metadata["references"])
|
||||||
|
}
|
||||||
|
for index, raw := range references {
|
||||||
|
entry, ok := raw.(map[string]any)
|
||||||
|
if !ok || len(entry) != 5 || entry["selector"] != want[index].selector || entry["source_id"] != want[index].sourceID {
|
||||||
|
t.Fatalf("reference metadata[%d] = %#v", index, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLifecyclePreparedReferenceChangeRerunsExtractionAndInvalidatesDownstream(t *testing.T) {
|
||||||
|
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||||
|
originalPaths := configureLifecycleReferences(t, cfg)
|
||||||
|
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
||||||
|
t.Fatalf("initial executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
before := loadLifecycleManifest(t, cfg)
|
||||||
|
beforeChecksum := lifecycleInputChecksum(t, before, "party")
|
||||||
|
for _, name := range []string{"render", "analyze", "publish"} {
|
||||||
|
before.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), before); err != nil {
|
||||||
|
t.Fatalf("Save(downstream success) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(originalPaths[artifactpolicy.SourceInputParty], []byte("changed party bytes\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(party source) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared := loadLifecycleManifest(t, cfg)
|
||||||
|
prepare, err := stage.Select("prepare")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stage.Select(prepare) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := prepare.Run(context.Background(), env, prepared); err != nil {
|
||||||
|
t.Fatalf("prepare.Run() error = %v", err)
|
||||||
|
}
|
||||||
|
if lifecycleInputChecksum(t, prepared, "party") == beforeChecksum {
|
||||||
|
t.Fatal("prepared party checksum did not change")
|
||||||
|
}
|
||||||
|
if err := (&manifest.LocalStore{}).Save(context.Background(), manifestPathFor(cfg), prepared); err != nil {
|
||||||
|
t.Fatalf("Save(reprepared manifest) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := BuildSingleStagePlan("extract")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
|
||||||
|
}
|
||||||
|
run, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rerun executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(run.Executed) != 1 || len(run.Skipped) != 0 || len(runner.requests) != 2 {
|
||||||
|
t.Fatalf("rerun summary = %#v requests=%d", run, len(runner.requests))
|
||||||
|
}
|
||||||
|
after := loadLifecycleManifest(t, cfg)
|
||||||
|
if after.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("extract status = %#v", after.Stages["extract"])
|
||||||
|
}
|
||||||
|
if after.Stages["render"] == nil || after.Stages["render"].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("render status = %#v, want succeeded sibling", after.Stages["render"])
|
||||||
|
}
|
||||||
|
for _, name := range []string{"analyze", "publish"} {
|
||||||
|
if after.Stages[name] == nil || after.Stages[name].Status != manifest.StatusStale {
|
||||||
|
t.Fatalf("%s status = %#v, want stale", name, after.Stages[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLifecycleSessionOverrideBytesReachCanonicalReference(t *testing.T) {
|
||||||
|
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||||
|
configureLifecycleReferences(t, cfg)
|
||||||
|
overridePath := filepath.Join(filepath.Dir(cfg.SessionPath), "session-party.yml")
|
||||||
|
if err := os.WriteFile(overridePath, []byte("session override party\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(session override) error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.StableInputs.PartyFile = config.ResolvedInputFile{
|
||||||
|
Path: "./session-party.yml", ConfigPath: cfg.SessionPath, Source: "session_config",
|
||||||
|
}
|
||||||
|
cfg.Session.Inputs.PartyFile = "./session-party.yml"
|
||||||
|
|
||||||
|
if _, err := executeStages(context.Background(), cfg, prepareExtractLifecyclePlan(t), RunOptions{Env: env}); err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(runner.requests) != 1 {
|
||||||
|
t.Fatalf("requests = %d", len(runner.requests))
|
||||||
|
}
|
||||||
|
var partyPath string
|
||||||
|
for _, binding := range runner.requests[0].References {
|
||||||
|
if binding.Selector == "party" {
|
||||||
|
partyPath = binding.Path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(partyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(prepared party) error = %v", err)
|
||||||
|
}
|
||||||
|
if string(contents) != "session override party\n" || partyPath == overridePath {
|
||||||
|
t.Fatalf("prepared party path=%q contents=%q override=%q", partyPath, contents, overridePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLifecycleEmptyReferencesPreserveAllDndExtractionSources(t *testing.T) {
|
||||||
|
cfg, env, runner := extractionLifecycleFixture(t, true)
|
||||||
|
cfg.Pipeline.Notarius.Outputs = lifecycleDndOutputs()
|
||||||
|
keys := make([]string, 0, len(cfg.Pipeline.Notarius.Outputs))
|
||||||
|
for key := range cfg.Pipeline.Notarius.Outputs {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
analyzeRuns := 0
|
||||||
|
extractPlan, err := BuildSingleStagePlan("extract")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
|
||||||
|
}
|
||||||
|
plan := append(extractPlan, assertExtractionSourcesStage{keys: keys, runs: &analyzeRuns})
|
||||||
|
|
||||||
|
summary, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(summary.Executed) != 2 || len(runner.requests) != 1 || len(runner.requests[0].References) != 0 || analyzeRuns != 1 {
|
||||||
|
t.Fatalf("summary=%#v requests=%#v analyze=%d", summary, runner.requests, analyzeRuns)
|
||||||
|
}
|
||||||
|
loaded := loadLifecycleManifest(t, cfg)
|
||||||
|
if got := len(loaded.Stages["extract"].Outputs); got != len(keys)+1 {
|
||||||
|
t.Fatalf("extract outputs = %d, want %d lanes plus index", got, len(keys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractLifecycleChangedOutcomeRerunsSucceededDownstream(t *testing.T) {
|
func TestExtractLifecycleChangedOutcomeRerunsSucceededDownstream(t *testing.T) {
|
||||||
cfg, env, runner := extractionLifecycleFixture(t, false)
|
cfg, env, runner := extractionLifecycleFixture(t, false)
|
||||||
analyzeRuns := 0
|
analyzeRuns := 0
|
||||||
@@ -394,6 +621,73 @@ func markLifecycleStageSucceeded(t *testing.T, cfg *config.Config, name string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prepareExtractLifecyclePlan(t *testing.T) []stage.Stage {
|
||||||
|
t.Helper()
|
||||||
|
prepare, err := BuildSingleStagePlan("prepare")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildSingleStagePlan(prepare) error = %v", err)
|
||||||
|
}
|
||||||
|
extract, err := BuildSingleStagePlan("extract")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildSingleStagePlan(extract) error = %v", err)
|
||||||
|
}
|
||||||
|
return append(prepare, extract...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureLifecycleReferences(t *testing.T, cfg *config.Config) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
cfg.Pipeline.Notarius.References = map[string]string{
|
||||||
|
"party": artifactpolicy.SourceInputParty,
|
||||||
|
"players": artifactpolicy.SourceInputPlayers,
|
||||||
|
"glossary": artifactpolicy.SourceInputGlossary,
|
||||||
|
"spells": artifactpolicy.SourceInputSpellCatalog,
|
||||||
|
}
|
||||||
|
spellPath := filepath.Join(filepath.Dir(cfg.CampaignPath), "spells.json")
|
||||||
|
if err := os.WriteFile(spellPath, []byte(`{"spells":[]}`+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(spell catalog) error = %v", err)
|
||||||
|
}
|
||||||
|
cfg.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
|
||||||
|
Path: "./spells.json", ConfigPath: cfg.CampaignPath, Source: "campaign_config",
|
||||||
|
}
|
||||||
|
cfg.Session.Inputs.SpellCatalogFile = "./spells.json"
|
||||||
|
|
||||||
|
return map[string]string{
|
||||||
|
artifactpolicy.SourceInputParty: filepath.Join(filepath.Dir(cfg.CampaignPath), "party.yml"),
|
||||||
|
artifactpolicy.SourceInputPlayers: filepath.Join(filepath.Dir(cfg.CampaignPath), "players.yml"),
|
||||||
|
artifactpolicy.SourceInputGlossary: filepath.Join(filepath.Dir(cfg.CampaignPath), "glossary.yml"),
|
||||||
|
artifactpolicy.SourceInputSpellCatalog: spellPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lifecycleInputChecksum(t *testing.T, m *manifest.Manifest, kind string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, input := range m.Inputs {
|
||||||
|
if input.Kind == kind {
|
||||||
|
if strings.TrimSpace(input.Checksum) == "" {
|
||||||
|
t.Fatalf("input %q has no checksum: %#v", kind, input)
|
||||||
|
}
|
||||||
|
return input.Checksum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("manifest input %q not found: %#v", kind, m.Inputs)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func lifecycleDndOutputs() map[string]config.NotariusOutputConfig {
|
||||||
|
return map[string]config.NotariusOutputConfig{
|
||||||
|
"item_registry": {LaneID: "item-registry", MediaType: "application/json", SchemaID: "notarius.dnd.item_registry", SchemaVersion: "v1", ModuleKey: "dnd/item-registry"},
|
||||||
|
"npc_registry": {LaneID: "npc-registry", MediaType: "application/json", SchemaID: "notarius.dnd.npc_registry", SchemaVersion: "v1", ModuleKey: "dnd/npc-registry"},
|
||||||
|
"location_registry": {LaneID: "location-registry", MediaType: "application/json", SchemaID: "notarius.dnd.location_registry", SchemaVersion: "v1", ModuleKey: "dnd/location-registry"},
|
||||||
|
"scene_descriptions": {LaneID: "scene-descriptions", MediaType: "application/json", SchemaID: "notarius.dnd.scene_descriptions", SchemaVersion: "v1", ModuleKey: "dnd/scene-descriptions"},
|
||||||
|
"item_occurrences": {LaneID: "item-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.item_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/item-occurrences"},
|
||||||
|
"spells": {LaneID: "spells", MediaType: "application/json", SchemaID: "notarius.dnd.spells", SchemaVersion: "v1", ModuleKey: "dnd/spells"},
|
||||||
|
"combat_turns": {LaneID: "combat-turns", MediaType: "application/json", SchemaID: "notarius.dnd.combat_turns", SchemaVersion: "v1", ModuleKey: "dnd/combat-turns"},
|
||||||
|
"npc_occurrences": {LaneID: "npc-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.npc_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/npc-occurrences"},
|
||||||
|
"location_occurrences": {LaneID: "location-occurrences", MediaType: "application/json", SchemaID: "notarius.dnd.location_occurrences", SchemaVersion: "v1", ModuleKey: "dnd/location-occurrences"},
|
||||||
|
"enemy_events": {LaneID: "enemy-events", MediaType: "application/json", SchemaID: "notarius.dnd.enemy_events", SchemaVersion: "v1", ModuleKey: "dnd/enemy-events"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *stage.Env, *materializingNotariusRunner) {
|
func extractionLifecycleFixture(t *testing.T, enabled bool) (*config.Config, *stage.Env, *materializingNotariusRunner) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func buildHelperArtifactCatalog(cfg *config.Config, m *manifest.Manifest) (*arti
|
|||||||
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
|
if cfg.Pipeline.Notarius != nil && cfg.Pipeline.Notarius.Enabled {
|
||||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||||
}
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
return catalog, nil
|
return catalog, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,11 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
|
|||||||
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
||||||
fmt.Fprintln(out, "Configured:")
|
fmt.Fprintln(out, "Configured:")
|
||||||
for _, entry := range catalog.ListConfigured() {
|
for _, entry := range catalog.ListConfigured() {
|
||||||
writeArtifactLine(out, entry.SourceID, lockSet)
|
state := "unavailable"
|
||||||
|
if entry.Available {
|
||||||
|
state = "available"
|
||||||
|
}
|
||||||
|
writeExtractionArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(out, "Extraction:")
|
fmt.Fprintln(out, "Extraction:")
|
||||||
for _, entry := range catalog.ListExtraction() {
|
for _, entry := range catalog.ListExtraction() {
|
||||||
|
|||||||
54
internal/app/operator_artifact_rendering_test.go
Normal file
54
internal/app/operator_artifact_rendering_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildHelperArtifactCatalogUsesAnalyzeManifestEvidence(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
cfg := &config.Config{
|
||||||
|
Pipeline: &config.PipelineConfig{
|
||||||
|
Workspace: config.WorkspaceConfig{Root: root},
|
||||||
|
Scriptorium: &config.ScriptoriumConfig{Artifacts: map[string]config.ScriptoriumArtifactConfig{
|
||||||
|
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Session: &config.SessionConfig{Campaign: "campaign", SessionID: "session"},
|
||||||
|
}
|
||||||
|
paths := artifacts.NewLocalStore(root).SessionPathsFor("campaign", "session")
|
||||||
|
outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := []byte("# recap\n")
|
||||||
|
if err := os.WriteFile(outputPath, body, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := manifest.New("session", time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
incidental, err := buildHelperArtifactCatalog(cfg, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, _ := incidental.Lookup(artifacts.ConfiguredArtifactSourceID("session_recap"))
|
||||||
|
if entry.Available {
|
||||||
|
t.Fatal("operator catalog advertised incidental configured artifact")
|
||||||
|
}
|
||||||
|
|
||||||
|
setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", body)
|
||||||
|
current, err := buildHelperArtifactCatalog(cfg, m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, _ = current.Lookup(artifacts.ConfiguredArtifactSourceID("session_recap"))
|
||||||
|
if !entry.Available || entry.Provenance != artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
|
||||||
|
t.Fatalf("operator catalog entry = %#v", entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -53,23 +53,28 @@ type effectiveLocksCheck struct {
|
|||||||
|
|
||||||
func inspectStableInputs(cfg *config.Config) []stableInputCheck {
|
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")
|
||||||
|
}
|
||||||
@@ -2,34 +2,30 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"strings"
|
||||||
"os"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/logging"
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Plan validates configuration, prepares the local workdir, and prints stage order.
|
// Plan validates configuration and prints a read-only execution preview.
|
||||||
func Plan(ctx context.Context, args []string, out io.Writer) error {
|
func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
|
request, err := parseBoundedRunRequest("plan", args, out)
|
||||||
fs.SetOutput(io.Discard)
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
var flags commonConfigFlags
|
return nil
|
||||||
var force bool
|
}
|
||||||
addCommonConfigFlags(fs, &flags)
|
|
||||||
fs.BoolVar(&force, "force", false, "show all stages as scheduled to rerun")
|
|
||||||
|
|
||||||
if err := parseSessionAwareFlags("plan", fs, args, &flags.sessionID); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if flags.sessionID == "" {
|
flags := request.Config
|
||||||
return fmt.Errorf("plan: session_id is required")
|
|
||||||
}
|
|
||||||
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
@@ -39,38 +35,75 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := config.Validate(cfg); err != nil {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := loadSecretsFromConfig(cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)); err != nil {
|
effective, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
m, err := loadManifestIfPresent(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateBoundedPrerequisites(request.Plan, m); err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
|
||||||
paths, err := store.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
paths := store.SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
|
model, err := cloneManifestForPlan(m, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: prepare workdir: %w", err)
|
return fmt.Errorf("plan: clone session state: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stages := BuildFullPlan()
|
stages := request.Plan.Stages()
|
||||||
var m *manifest.Manifest
|
stageEnv := &stage.Env{
|
||||||
m, err = loadManifestIfPresent(ctx, cfg)
|
Config: cfg, SelectedArtifactKeys: append([]string(nil), request.SelectedArtifacts...),
|
||||||
if err != nil {
|
EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force,
|
||||||
return fmt.Errorf("plan: %w", err)
|
|
||||||
}
|
}
|
||||||
decisions := decideStageActions(stages, m, force)
|
|
||||||
|
|
||||||
runCount := 0
|
runCount := 0
|
||||||
skipCount := 0
|
skipCount := 0
|
||||||
if _, err := fmt.Fprintf(out, "narratio session plan: workdir prepared at %s\n", paths.Root); err != nil {
|
if _, err := fmt.Fprintf(out, "narratio session plan: read-only workdir at %s\n", paths.Root); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, d := range decisions {
|
for _, selectedStage := range stages {
|
||||||
if d.Action == stageActionRun {
|
action := decideStageAction(selectedStage, model, request.Force)
|
||||||
|
var validation *stage.ResumeValidation
|
||||||
|
if validator, ok := selectedStage.(stage.ResumeValidator); ok &&
|
||||||
|
(action == stageActionSkip || selectedStage.Name() == "analyze") {
|
||||||
|
checked, validationErr := validator.ValidateResume(ctx, stageEnv, model)
|
||||||
|
if validationErr != nil {
|
||||||
|
return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr)
|
||||||
|
}
|
||||||
|
checked = checked.Normalized()
|
||||||
|
validation = &checked
|
||||||
|
if action == stageActionSkip && !checked.Resumable {
|
||||||
|
at := time.Now().UTC()
|
||||||
|
model.MarkStageStale(selectedStage.Name(), at, checked.Reason)
|
||||||
|
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(
|
||||||
|
model, selectedStage.Name(), at, staleReasonNotResumable,
|
||||||
|
); invalidationErr != nil {
|
||||||
|
return fmt.Errorf("plan: model resume invalidation for stage %q: %w", selectedStage.Name(), invalidationErr)
|
||||||
|
}
|
||||||
|
action = stageActionRun
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action == stageActionRun {
|
||||||
runCount++
|
runCount++
|
||||||
} else {
|
} else {
|
||||||
skipCount++
|
skipCount++
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprintf(out, "%s: %s\n", d.Stage.Name(), d.Action); err != nil {
|
if _, err := fmt.Fprintf(out, "%s: %s\n", selectedStage.Name(), action); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if validation != nil && validation.Analyze != nil {
|
||||||
|
if err := writeAnalyzePlanDetails(out, validation.Analyze); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action == stageActionRun {
|
||||||
|
if err := modelPlannedStageRun(model, selectedStage, cfg, request.Force); err != nil {
|
||||||
|
return fmt.Errorf("plan: model stage %q: %w", selectedStage.Name(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil {
|
if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -78,3 +111,102 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneManifestForPlan(source *manifest.Manifest, cfg *config.Config) (*manifest.Manifest, error) {
|
||||||
|
if source == nil {
|
||||||
|
created := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
created.Campaign = cfg.Session.Campaign
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(source)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cloned manifest.Manifest
|
||||||
|
if err := json.Unmarshal(data, &cloned); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &cloned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, cfg *config.Config, force bool) error {
|
||||||
|
prior := capturePriorStageOutcome(model, selectedStage.Name())
|
||||||
|
at := time.Now().UTC()
|
||||||
|
model.MarkStageRunning(selectedStage.Name(), at)
|
||||||
|
if force {
|
||||||
|
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||||
|
model, selectedStage.Name(), at, staleReasonForcedReplacement,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reason := plannedSelfSkipReason(selectedStage.Name(), cfg); reason != "" {
|
||||||
|
model.MarkStageSkipped(selectedStage.Name(), at, reason)
|
||||||
|
if !prior.isSameSelfSkip(reason) {
|
||||||
|
_, err := invalidateDependentSucceededStagesWithReason(
|
||||||
|
model, selectedStage.Name(), at, staleReasonSelfSkip,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
model.MarkStageSucceeded(selectedStage.Name(), at, nil)
|
||||||
|
if !prior.exists || prior.status != manifest.StatusSucceeded {
|
||||||
|
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||||
|
model, selectedStage.Name(), at, staleReasonChangedResult,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func plannedSelfSkipReason(stageName string, cfg *config.Config) string {
|
||||||
|
if stageName == "extract" && cfg != nil && cfg.Pipeline != nil &&
|
||||||
|
(cfg.Pipeline.Notarius == nil || !cfg.Pipeline.Notarius.Enabled) {
|
||||||
|
return "notarius_disabled"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAnalyzePlanDetails(out io.Writer, summary *stage.AnalyzeResumeSummary) error {
|
||||||
|
if summary == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, " targets: %s\n", planStringList(summary.ExplicitTargets)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, " prerequisites: %s\n", planArtifactList(summary.PrerequisiteWork)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, " execute: %s\n", planArtifactList(summary.ExecutionOrder)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(out, " reuse: %s\n", planArtifactList(summary.ReusedCurrent))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func planStringList(values []string) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
return strings.Join(values, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func planArtifactList(values []stage.AnalyzeResumeArtifact) string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
detail := value.Role
|
||||||
|
if value.Reason != "" {
|
||||||
|
detail += ":" + value.Reason
|
||||||
|
}
|
||||||
|
if value.Forced {
|
||||||
|
detail += ":forced"
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s(%s)", value.Key, detail))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
func TestPlanDoesNotCreateWorkdir(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
|
||||||
@@ -24,10 +29,10 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
|||||||
t.Fatalf("first Plan() error = %v", err)
|
t.Fatalf("first Plan() error = %v", err)
|
||||||
}
|
}
|
||||||
got := out.String()
|
got := out.String()
|
||||||
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
|
if !strings.Contains(got, "narratio session plan: read-only workdir at") {
|
||||||
t.Fatalf("first output = %q, want workdir prepared", got)
|
t.Fatalf("first output = %q, want read-only workdir", got)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
|
||||||
if !strings.Contains(got, name+": run") {
|
if !strings.Contains(got, name+": run") {
|
||||||
t.Fatalf("first output = %q, missing stage %q", got, name)
|
t.Fatalf("first output = %q, missing stage %q", got, name)
|
||||||
}
|
}
|
||||||
@@ -37,26 +42,16 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
|
||||||
expectedDirs := []string{
|
if _, err := os.Stat(sessionWorkdir); !errors.Is(err, os.ErrNotExist) {
|
||||||
sessionWorkdir,
|
t.Fatalf("workdir stat error = %v, want absent", err)
|
||||||
filepath.Join(sessionWorkdir, "inputs"),
|
|
||||||
filepath.Join(sessionWorkdir, "audio"),
|
|
||||||
filepath.Join(sessionWorkdir, "transcripts", "raw"),
|
|
||||||
filepath.Join(sessionWorkdir, "transcripts", "trimmed"),
|
|
||||||
filepath.Join(sessionWorkdir, "artifacts"),
|
|
||||||
filepath.Join(sessionWorkdir, "config"),
|
|
||||||
filepath.Join(sessionWorkdir, "logs"),
|
|
||||||
}
|
|
||||||
for _, dir := range expectedDirs {
|
|
||||||
assertDir(t, dir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out.Reset()
|
out.Reset()
|
||||||
if err := Plan(context.Background(), args, &out); err != nil {
|
if err := Plan(context.Background(), args, &out); err != nil {
|
||||||
t.Fatalf("second Plan() error = %v", err)
|
t.Fatalf("second Plan() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "narratio session plan: workdir prepared at") {
|
if !strings.Contains(out.String(), "narratio session plan: read-only workdir at") {
|
||||||
t.Fatalf("second output = %q, want workdir prepared", out.String())
|
t.Fatalf("second output = %q, want read-only workdir", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +84,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlanFailsWhenConfiguredSecretsDirMissing(t *testing.T) {
|
func TestPlanDoesNotLoadConfiguredSecrets(t *testing.T) {
|
||||||
workspaceRoot := t.TempDir()
|
workspaceRoot := t.TempDir()
|
||||||
configDir := t.TempDir()
|
configDir := t.TempDir()
|
||||||
pipelinePath := filepath.Join(configDir, "pipeline.yml")
|
pipelinePath := filepath.Join(configDir, "pipeline.yml")
|
||||||
@@ -130,21 +125,125 @@ inputs:
|
|||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatalf("Plan() error = %v, want missing runtime secrets ignored", err)
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "validate secrets env_dir") {
|
|
||||||
t.Fatalf("error = %q, want secrets validation error context", err.Error())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertDir(t *testing.T, path string) {
|
func TestPlanAndRunShareAnalyzeArtifactDecisionsWithoutPlanSideEffects(t *testing.T) {
|
||||||
t.Helper()
|
workspaceRoot := t.TempDir()
|
||||||
info, err := os.Stat(path)
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||||
|
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Stat(%q) error = %v", path, err)
|
t.Fatalf("load config: %v", err)
|
||||||
}
|
}
|
||||||
if !info.IsDir() {
|
analyze, err := stage.Select("analyze")
|
||||||
t.Fatalf("%q is not a directory", path)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
fake := &scriptorium.FakeRunner{}
|
||||||
|
first, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
|
||||||
|
Env: &Env{Scriptorium: fake},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("initial analyze: %v", err)
|
||||||
|
}
|
||||||
|
if len(fake.RunRequests) != 2 {
|
||||||
|
t.Fatalf("initial adapter requests = %d, want 2", len(fake.RunRequests))
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &manifest.LocalStore{}
|
||||||
|
m, err := store.Load(context.Background(), first.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
|
||||||
|
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
|
}
|
||||||
|
m.MarkStageStale("render", time.Now().UTC(), "upstream selection requires reconsideration")
|
||||||
|
m.MarkStageSkipped("extract", time.Now().UTC(), "notarius_disabled")
|
||||||
|
if err := store.Save(context.Background(), first.ManifestPath, m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifestBefore, err := os.ReadFile(first.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
filesBefore := planFixtureFiles(t, filepath.Dir(first.ManifestPath))
|
||||||
|
marker := filepath.Join(t.TempDir(), "adapter-invoked")
|
||||||
|
binaryDir := t.TempDir()
|
||||||
|
binary := filepath.Join(binaryDir, "scriptorium")
|
||||||
|
if err := os.WriteFile(binary, []byte("#!/bin/sh\ntouch \""+marker+"\"\nexit 99\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", binaryDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
err = Plan(context.Background(), []string{
|
||||||
|
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||||
|
"--from", "render", "--through", "analyze",
|
||||||
|
}, &out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Plan() error = %v", err)
|
||||||
|
}
|
||||||
|
got := out.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"render: run", "extract: run", "analyze: run",
|
||||||
|
" targets: player_handout, session_recap",
|
||||||
|
" prerequisites: none", " execute: none",
|
||||||
|
"player_handout(target:current)", "session_recap(target:current)",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("plan output = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifestAfter, err := os.ReadFile(first.ManifestPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(manifestBefore, manifestAfter) {
|
||||||
|
t.Fatal("plan modified the session manifest")
|
||||||
|
}
|
||||||
|
if filesAfter := planFixtureFiles(t, filepath.Dir(first.ManifestPath)); !reflect.DeepEqual(filesAfter, filesBefore) {
|
||||||
|
t.Fatalf("plan files = %#v, want unchanged %#v", filesAfter, filesBefore)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("adapter marker stat = %v, want absent", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fake.RunRequests = nil
|
||||||
|
actual, err := executeStages(context.Background(), cfg, []stage.Stage{
|
||||||
|
resultStage{name: "render", result: &stage.StageResult{}},
|
||||||
|
resultStage{name: "extract", result: &stage.StageResult{Disposition: stage.StageDispositionSkipped, SkipReason: "notarius_disabled"}},
|
||||||
|
analyze,
|
||||||
|
}, RunOptions{
|
||||||
|
Env: &Env{Scriptorium: fake},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("actual analyze: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(actual.Executed, []string{"render", "extract", "analyze"}) ||
|
||||||
|
!reflect.DeepEqual(actual.Skipped, []string{"extract"}) || len(fake.RunRequests) != 0 {
|
||||||
|
t.Fatalf("actual decision: executed=%#v skipped=%#v adapter_requests=%d, want planned stage decisions with artifact reuse", actual.Executed, actual.Skipped, len(fake.RunRequests))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func planFixtureFiles(t *testing.T, root string) []string {
|
||||||
|
t.Helper()
|
||||||
|
var files []string
|
||||||
|
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
relative, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files = append(files, relative+":"+entry.Type().String())
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,134 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BoundedPlan is one validated inclusive range of the canonical pipeline.
|
||||||
|
// It owns effective endpoints and membership so command, runner, and
|
||||||
|
// composition callers do not independently interpret range bounds.
|
||||||
|
type BoundedPlan struct {
|
||||||
|
stages []stage.Stage
|
||||||
|
canonicalNames []string
|
||||||
|
startIndex int
|
||||||
|
endIndex int
|
||||||
|
explicitFrom bool
|
||||||
|
explicitThrough bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildBoundedPlan selects an inclusive contiguous range of the canonical
|
||||||
|
// pipeline. Empty endpoints default to the beginning or end respectively.
|
||||||
|
func BuildBoundedPlan(from, through string) (BoundedPlan, error) {
|
||||||
|
registry := stage.All()
|
||||||
|
names := make([]string, len(registry))
|
||||||
|
indices := make(map[string]int, len(registry))
|
||||||
|
for index, candidate := range registry {
|
||||||
|
if candidate == nil {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage %d is nil", index)
|
||||||
|
}
|
||||||
|
name := candidate.Name()
|
||||||
|
if _, duplicate := indices[name]; duplicate {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: duplicate canonical stage %q", name)
|
||||||
|
}
|
||||||
|
names[index] = name
|
||||||
|
indices[name] = index
|
||||||
|
}
|
||||||
|
if len(registry) == 0 {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: canonical stage registry is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
start := 0
|
||||||
|
if from != "" {
|
||||||
|
var ok bool
|
||||||
|
start, ok = indices[from]
|
||||||
|
if !ok {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown from stage %q; valid stages: %s", from, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end := len(registry) - 1
|
||||||
|
if through != "" {
|
||||||
|
var ok bool
|
||||||
|
end, ok = indices[through]
|
||||||
|
if !ok {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: unknown through stage %q; valid stages: %s", through, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start > end {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build bounded plan: from stage %q occurs after through stage %q; valid stages: %s", from, through, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return BoundedPlan{
|
||||||
|
stages: append([]stage.Stage(nil), registry[start:end+1]...),
|
||||||
|
canonicalNames: append([]string(nil), names...),
|
||||||
|
startIndex: start,
|
||||||
|
endIndex: end,
|
||||||
|
explicitFrom: from != "",
|
||||||
|
explicitThrough: through != "",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stages returns a copy of the selected canonical stages.
|
||||||
|
func (p BoundedPlan) Stages() []stage.Stage {
|
||||||
|
return append([]stage.Stage(nil), p.stages...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names returns selected stage names in canonical order.
|
||||||
|
func (p BoundedPlan) Names() []string {
|
||||||
|
out := make([]string, 0, len(p.stages))
|
||||||
|
for _, candidate := range p.stages {
|
||||||
|
out = append(out, candidate.Name())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// From returns the effective inclusive start stage.
|
||||||
|
func (p BoundedPlan) From() string {
|
||||||
|
if len(p.canonicalNames) == 0 || p.startIndex < 0 || p.startIndex >= len(p.canonicalNames) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.canonicalNames[p.startIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Through returns the effective inclusive end stage.
|
||||||
|
func (p BoundedPlan) Through() string {
|
||||||
|
if len(p.canonicalNames) == 0 || p.endIndex < 0 || p.endIndex >= len(p.canonicalNames) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p.canonicalNames[p.endIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contains reports whether a canonical stage is selected by the range.
|
||||||
|
func (p BoundedPlan) Contains(name string) bool {
|
||||||
|
for _, candidate := range p.stages {
|
||||||
|
if candidate.Name() == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrefixNames returns canonical stages excluded before the selected start.
|
||||||
|
func (p BoundedPlan) PrefixNames() []string {
|
||||||
|
if p.startIndex <= 0 || p.startIndex > len(p.canonicalNames) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]string(nil), p.canonicalNames[:p.startIndex]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasExplicitBounds reports whether either endpoint was supplied by the caller.
|
||||||
|
func (p BoundedPlan) HasExplicitBounds() bool {
|
||||||
|
return p.explicitFrom || p.explicitThrough
|
||||||
|
}
|
||||||
|
|
||||||
// BuildFullPlan returns the canonical full stage list in deterministic order.
|
// BuildFullPlan returns the canonical full stage list in deterministic order.
|
||||||
func BuildFullPlan() []stage.Stage {
|
func BuildFullPlan() []stage.Stage {
|
||||||
return stage.All()
|
plan, err := BuildBoundedPlan("", "")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return plan.Stages()
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSingleStagePlan returns a one-stage plan for an exact stage name.
|
// BuildSingleStagePlan returns a one-stage plan for an exact stage name.
|
||||||
@@ -19,3 +140,22 @@ func BuildSingleStagePlan(name string) ([]stage.Stage, error) {
|
|||||||
}
|
}
|
||||||
return []stage.Stage{s}, nil
|
return []stage.Stage{s}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildSingleStageExecutionPlan selects one canonical stage without applying
|
||||||
|
// bounded-run prefix prerequisites. The run-stage family validates the stage's
|
||||||
|
// concrete inputs and intentionally retains its established direct-execution
|
||||||
|
// semantics.
|
||||||
|
func buildSingleStageExecutionPlan(name string) (BoundedPlan, error) {
|
||||||
|
stages, err := BuildSingleStagePlan(name)
|
||||||
|
if err != nil {
|
||||||
|
return BoundedPlan{}, err
|
||||||
|
}
|
||||||
|
plan, err := BuildBoundedPlan(name, name)
|
||||||
|
if err != nil {
|
||||||
|
return BoundedPlan{}, fmt.Errorf("build stage plan: %w", err)
|
||||||
|
}
|
||||||
|
plan.stages = stages
|
||||||
|
plan.explicitFrom = false
|
||||||
|
plan.explicitThrough = false
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
func TestBuildFullPlanOrder(t *testing.T) {
|
func TestBuildFullPlanOrder(t *testing.T) {
|
||||||
got := BuildFullPlan()
|
got := BuildFullPlan()
|
||||||
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"}
|
want := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}
|
||||||
if len(got) != len(want) {
|
if len(got) != len(want) {
|
||||||
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
|
||||||
}
|
}
|
||||||
@@ -34,3 +40,113 @@ func TestBuildSingleStagePlanUnknown(t *testing.T) {
|
|||||||
t.Fatal("expected error for unknown stage, got nil")
|
t.Fatal("expected error for unknown stage, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildBoundedPlanEndpoints(t *testing.T) {
|
||||||
|
canonical := stageNames(BuildFullPlan())
|
||||||
|
for index, name := range canonical {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
one, err := BuildBoundedPlan(name, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(%q, %q) error = %v", name, name, err)
|
||||||
|
}
|
||||||
|
if got := one.Names(); !reflect.DeepEqual(got, []string{name}) {
|
||||||
|
t.Fatalf("one-stage names = %#v, want %q", got, name)
|
||||||
|
}
|
||||||
|
if one.From() != name || one.Through() != name || !one.Contains(name) || !one.HasExplicitBounds() {
|
||||||
|
t.Fatalf("one-stage plan endpoints or membership = %#v", one)
|
||||||
|
}
|
||||||
|
|
||||||
|
from, err := BuildBoundedPlan(name, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(%q, empty) error = %v", name, err)
|
||||||
|
}
|
||||||
|
if got := from.Names(); !reflect.DeepEqual(got, canonical[index:]) {
|
||||||
|
t.Fatalf("from names = %#v, want %#v", got, canonical[index:])
|
||||||
|
}
|
||||||
|
|
||||||
|
through, err := BuildBoundedPlan("", name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan(empty, %q) error = %v", name, err)
|
||||||
|
}
|
||||||
|
if got := through.Names(); !reflect.DeepEqual(got, canonical[:index+1]) {
|
||||||
|
t.Fatalf("through names = %#v, want %#v", got, canonical[:index+1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBoundedPlanDefaultsToFullCanonicalPlan(t *testing.T) {
|
||||||
|
plan, err := BuildBoundedPlan("", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan() error = %v", err)
|
||||||
|
}
|
||||||
|
want := stageNames(BuildFullPlan())
|
||||||
|
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("bounded names = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if plan.From() != want[0] || plan.Through() != want[len(want)-1] || plan.HasExplicitBounds() {
|
||||||
|
t.Fatalf("default endpoints = %q through %q explicit=%t", plan.From(), plan.Through(), plan.HasExplicitBounds())
|
||||||
|
}
|
||||||
|
if got := plan.PrefixNames(); len(got) != 0 {
|
||||||
|
t.Fatalf("default prefix = %#v, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildBoundedPlanRejectsInvalidBounds(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
from string
|
||||||
|
through string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{name: "unknown from", from: "missing", through: "analyze", want: []string{"unknown from stage", "missing", "prepare", "notify"}},
|
||||||
|
{name: "unknown through", from: "extract", through: "missing", want: []string{"unknown through stage", "missing", "prepare", "notify"}},
|
||||||
|
{name: "reversed", from: "publish", through: "render", want: []string{"publish", "occurs after", "render", "prepare", "notify"}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := BuildBoundedPlan(test.from, test.through)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("BuildBoundedPlan() error = nil")
|
||||||
|
}
|
||||||
|
for _, fragment := range test.want {
|
||||||
|
if !strings.Contains(err.Error(), fragment) {
|
||||||
|
t.Fatalf("error = %q, want fragment %q", err, fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoundedPlanIsContiguousAndCannotMutateRegistry(t *testing.T) {
|
||||||
|
before := stageNames(BuildFullPlan())
|
||||||
|
plan, err := BuildBoundedPlan("trim", "analyze")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildBoundedPlan() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"trim", "render", "extract", "analyze"}
|
||||||
|
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("names = %#v, want contiguous %#v", got, want)
|
||||||
|
}
|
||||||
|
if got := plan.PrefixNames(); !reflect.DeepEqual(got, before[:5]) {
|
||||||
|
t.Fatalf("prefix = %#v, want %#v", got, before[:5])
|
||||||
|
}
|
||||||
|
stages := plan.Stages()
|
||||||
|
stages[0] = nil
|
||||||
|
names := plan.Names()
|
||||||
|
names[0] = "changed"
|
||||||
|
if got := plan.Names(); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("mutated plan names = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if got := stageNames(BuildFullPlan()); !reflect.DeepEqual(got, before) {
|
||||||
|
t.Fatalf("canonical registry changed = %#v, want %#v", got, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stageNames(stages []stage.Stage) []string {
|
||||||
|
names := make([]string, 0, len(stages))
|
||||||
|
for _, candidate := range stages {
|
||||||
|
names = append(names, candidate.Name())
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,7 +146,12 @@ func TestPostPublishCleanupRetriesWhenInitialObligationSaveFails(t *testing.T) {
|
|||||||
|
|
||||||
store.fail = nil
|
store.fail = nil
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("retry executeStages() error = %v", err)
|
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
assertExists(t, seed.spoolAudioDir)
|
||||||
|
assertCleanupPending(t, cfg)
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
assertMissing(t, seed.spoolAudioDir)
|
assertMissing(t, seed.spoolAudioDir)
|
||||||
assertCleanupComplete(t, cfg)
|
assertCleanupComplete(t, cfg)
|
||||||
@@ -178,7 +183,12 @@ func TestPostPublishCleanupRetriesFailedDeletionWithoutTouchingOtherRuns(t *test
|
|||||||
|
|
||||||
removeRunScopedDirFn = originalRemove
|
removeRunScopedDirFn = originalRemove
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("retry executeStages() error = %v", err)
|
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
assertExists(t, seed.runWorkDir)
|
||||||
|
assertCleanupPending(t, cfg)
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
assertMissing(t, seed.runWorkDir)
|
assertMissing(t, seed.runWorkDir)
|
||||||
assertExists(t, seed.otherRunDir)
|
assertExists(t, seed.otherRunDir)
|
||||||
@@ -218,12 +228,16 @@ func TestPostPublishCleanupRetriesWhenCompletionEvidenceSaveFails(t *testing.T)
|
|||||||
|
|
||||||
store.fail = nil
|
store.fail = nil
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("retry executeStages() error = %v", err)
|
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
assertCleanupPending(t, cfg)
|
||||||
|
if _, err := executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("publish retry executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
assertCleanupComplete(t, cfg)
|
assertCleanupComplete(t, cfg)
|
||||||
|
|
||||||
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
if _, err := executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ManifestStore: store, ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
t.Fatalf("idempotent retry executeStages() error = %v", err)
|
t.Fatalf("idempotent non-publish executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
assertMissing(t, seed.spoolAudioDir)
|
assertMissing(t, seed.spoolAudioDir)
|
||||||
}
|
}
|
||||||
@@ -328,7 +342,10 @@ func TestPostPublishCleanupFailsOnUnsafePath(t *testing.T) {
|
|||||||
t.Fatalf("Save() error = %v", err)
|
t.Fatalf("Save() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
if _, err = executeStages(context.Background(), cfg, nil, RunOptions{Env: &Env{ObjectStore: &storage.FakeBackend{}}}); err != nil {
|
||||||
|
t.Fatalf("non-publish executeStages() error = %v", err)
|
||||||
|
}
|
||||||
|
_, err = executeStages(context.Background(), cfg, []stage.Stage{publishSuccessStage{}}, RunOptions{Force: true, Env: &Env{ObjectStore: &storage.FakeBackend{}}})
|
||||||
if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") {
|
if err == nil || !strings.Contains(err.Error(), "refusing to delete path outside root") {
|
||||||
t.Fatalf("executeStages() error = %v, want safe-path failure", err)
|
t.Fatalf("executeStages() error = %v, want safe-path failure", err)
|
||||||
}
|
}
|
||||||
@@ -525,6 +542,7 @@ func publishStageCleanupFixture(t *testing.T) (*config.Config, cleanupSeed, stri
|
|||||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
|
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
|
||||||
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
seedManifest.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||||
}
|
}
|
||||||
|
setAppAnalyzeEvidence(seedManifest, "session_recap", "artifacts/session_recap.md", []byte("# recap\n"))
|
||||||
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
seedManifest.S3SessionPrefix = artifacts.S3SessionPrefix("dnd", cfg.Session.Campaign, cfg.Session.SessionID)
|
||||||
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
seedManifest.S3RunPrefix = artifacts.S3RunPrefix(seedManifest.S3SessionPrefix, runID)
|
||||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seedManifest); err != nil {
|
||||||
|
|||||||
43
internal/app/regenerate_artifacts.go
Normal file
43
internal/app/regenerate_artifacts.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegenerateArtifacts expands the convenience command into its canonical run
|
||||||
|
// invocation. The run command remains the sole owner of parsing and execution.
|
||||||
|
func RegenerateArtifacts(ctx context.Context, args []string, out io.Writer) error {
|
||||||
|
if containsHelpOption(args) {
|
||||||
|
printRegenerateArtifactsHelp(out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded := make([]string, 0, len(args)+5)
|
||||||
|
if len(args) > 0 && !isCLIFlagToken(args[0]) {
|
||||||
|
expanded = append(expanded, args[0])
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
expanded = append(expanded, "--force", "--from", "extract", "--through", "analyze")
|
||||||
|
expanded = append(expanded, args...)
|
||||||
|
return runCommandFn(ctx, expanded, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsHelpOption(args []string) bool {
|
||||||
|
for _, arg := range args {
|
||||||
|
if arg == "-h" || arg == "--help" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func printRegenerateArtifactsHelp(out io.Writer) {
|
||||||
|
_, _ = fmt.Fprintln(out, "Usage: narratio regenerate-artifacts <session_id> [--artifacts <name[,name...]>] [common config flags]")
|
||||||
|
_, _ = fmt.Fprintln(out)
|
||||||
|
_, _ = fmt.Fprintln(out, "Exactly equivalent to:")
|
||||||
|
_, _ = fmt.Fprintln(out, " narratio run <session_id> --force --from extract --through analyze [caller options]")
|
||||||
|
_, _ = fmt.Fprintln(out)
|
||||||
|
_, _ = fmt.Fprintln(out, "Extraction always runs; selected analysis artifacts and their required prerequisites are rebuilt. Publish and notify never run.")
|
||||||
|
}
|
||||||
135
internal/app/regenerate_artifacts_test.go
Normal file
135
internal/app/regenerate_artifacts_test.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
|
||||||
|
original := runCommandFn
|
||||||
|
t.Cleanup(func() { runCommandFn = original })
|
||||||
|
var captured []string
|
||||||
|
runCommandFn = func(_ context.Context, args []string, _ io.Writer) error {
|
||||||
|
captured = append([]string(nil), args...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
code := Execute([]string{
|
||||||
|
"regenerate-artifacts", "2026-05-03",
|
||||||
|
"--artifacts", "session_recap,player_handout",
|
||||||
|
"--artifacts=player_handout",
|
||||||
|
"--config", "pipeline.yml",
|
||||||
|
"--campaign", "sample-campaign",
|
||||||
|
}, io.Discard, io.Discard)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Execute() code = %d, want 0", code)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"2026-05-03", "--force", "--from", "extract", "--through", "analyze",
|
||||||
|
"--artifacts", "session_recap,player_handout",
|
||||||
|
"--artifacts=player_handout",
|
||||||
|
"--config", "pipeline.yml",
|
||||||
|
"--campaign", "sample-campaign",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(captured, want) {
|
||||||
|
t.Fatalf("forwarded args = %#v, want %#v", captured, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegenerateArtifactsForwardsSessionIDCompatibilityFlag(t *testing.T) {
|
||||||
|
original := runCommandFn
|
||||||
|
t.Cleanup(func() { runCommandFn = original })
|
||||||
|
var captured []string
|
||||||
|
runCommandFn = func(_ context.Context, args []string, _ io.Writer) error {
|
||||||
|
captured = append([]string(nil), args...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
code := Execute([]string{
|
||||||
|
"regenerate-artifacts",
|
||||||
|
"--config", "pipeline.yml",
|
||||||
|
"--session-id", "2026-05-03",
|
||||||
|
"--artifacts", "session_recap",
|
||||||
|
}, io.Discard, io.Discard)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("Execute() code = %d, want 0", code)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"--force", "--from", "extract", "--through", "analyze",
|
||||||
|
"--config", "pipeline.yml",
|
||||||
|
"--session-id", "2026-05-03",
|
||||||
|
"--artifacts", "session_recap",
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(captured, want) {
|
||||||
|
t.Fatalf("forwarded args = %#v, want %#v", captured, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegenerateArtifactsHelpDoesNotInvokeRun(t *testing.T) {
|
||||||
|
original := runCommandFn
|
||||||
|
t.Cleanup(func() { runCommandFn = original })
|
||||||
|
called := false
|
||||||
|
runCommandFn = func(_ context.Context, _ []string, _ io.Writer) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute([]string{"regenerate-artifacts", "--help"}, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("Execute() code = %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("help invoked canonical run handler")
|
||||||
|
}
|
||||||
|
for _, detail := range []string{
|
||||||
|
"narratio run <session_id> --force --from extract --through analyze",
|
||||||
|
"Extraction always runs",
|
||||||
|
"Publish and notify never run",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(stdout.String(), detail) {
|
||||||
|
t.Fatalf("help = %q, want %q", stdout.String(), detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegenerateArtifactsOwnedOptionsFailThroughRunParser(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
owned string
|
||||||
|
}{
|
||||||
|
{name: "force", args: []string{"--force"}, owned: "force"},
|
||||||
|
{name: "from", args: []string{"--from=render"}, owned: "from"},
|
||||||
|
{name: "through", args: []string{"--through", "publish"}, owned: "through"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
args := []string{"regenerate-artifacts", "2026-05-03"}
|
||||||
|
args = append(args, test.args...)
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute(args, io.Discard, &stderr); code == 0 {
|
||||||
|
t.Fatalf("Execute(%#v) code = 0", args)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "--"+test.owned+" may be specified only once") {
|
||||||
|
t.Fatalf("stderr = %q, want shared duplicate %s error", stderr.String(), test.owned)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegenerateArtifactsRejectsUnknownOptionsThroughRunParser(t *testing.T) {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute([]string{"regenerate-artifacts", "2026-05-03", "--regenerate-only"}, io.Discard, &stderr); code == 0 {
|
||||||
|
t.Fatal("Execute() code = 0")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "flag provided but not defined") || !strings.Contains(stderr.String(), "regenerate-only") {
|
||||||
|
t.Fatalf("stderr = %q, want canonical parser unknown-option error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
|
func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
|
||||||
@@ -37,7 +36,7 @@ inputs:
|
|||||||
if storeInitCalls != 1 {
|
if storeInitCalls != 1 {
|
||||||
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
|
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "narratio session plan: workdir prepared") {
|
if !strings.Contains(stdout.String(), "narratio session plan: read-only workdir") {
|
||||||
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
t.Fatalf("stdout = %q, want plan output", stdout.String())
|
||||||
}
|
}
|
||||||
if _, ok := fake.Objects[remoteKey]; !ok {
|
if _, ok := fake.Objects[remoteKey]; !ok {
|
||||||
@@ -85,7 +84,7 @@ inputs:
|
|||||||
`,
|
`,
|
||||||
command: []string{"run", "2026-05-03"},
|
command: []string{"run", "2026-05-03"},
|
||||||
configureRun: func() {
|
configureRun: func() {
|
||||||
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
|
||||||
return nil, errors.New("adapter failed")
|
return nil, errors.New("adapter failed")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -99,7 +98,7 @@ inputs:
|
|||||||
`,
|
`,
|
||||||
command: []string{"run", "2026-05-03"},
|
command: []string{"run", "2026-05-03"},
|
||||||
configureRun: func() {
|
configureRun: func() {
|
||||||
executeStagesFn = func(context.Context, *config.Config, []stage.Stage, RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(context.Context, *config.Config, BoundedPlan, RunOptions) (*RunSummary, error) {
|
||||||
return nil, context.Canceled
|
return nil, context.Canceled
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
||||||
@@ -34,12 +33,12 @@ func TestRestoreThenRunStageForceAnalyzeUsesRestoredDurableState(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
if opts.Env == nil {
|
if opts.Env == nil {
|
||||||
opts.Env = &Env{}
|
opts.Env = &Env{}
|
||||||
}
|
}
|
||||||
opts.Env.Scriptorium = &scriptorium.NoopRunner{}
|
opts.Env.Scriptorium = &scriptorium.NoopRunner{}
|
||||||
return executeStages(ctx, cfg, stages, opts)
|
return executePlan(ctx, cfg, plan, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
@@ -224,12 +223,12 @@ previous_session_id: 2026-04-26
|
|||||||
executeStagesFn = origExecuteStagesFn
|
executeStagesFn = origExecuteStagesFn
|
||||||
newObjectStoreFromConfigFn = origObjectStoreFn
|
newObjectStoreFromConfigFn = origObjectStoreFn
|
||||||
})
|
})
|
||||||
executeStagesFn = func(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
if opts.Env == nil {
|
if opts.Env == nil {
|
||||||
opts.Env = &Env{}
|
opts.Env = &Env{}
|
||||||
}
|
}
|
||||||
opts.Env.Scriptorium = scriptoriumFake
|
opts.Env.Scriptorium = scriptoriumFake
|
||||||
return executeStages(ctx, cfg, stages, opts)
|
return executePlan(ctx, cfg, plan, opts)
|
||||||
}
|
}
|
||||||
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
newObjectStoreFromConfigFn = func(context.Context, *config.Config) (storage.ObjectStore, error) {
|
||||||
objectStoreConstructed = true
|
objectStoreConstructed = true
|
||||||
@@ -282,6 +281,7 @@ func restoreWorkflowManifestJSON(t *testing.T, sessionID, campaign string) []byt
|
|||||||
for i, stageName := range stages {
|
for i, stageName := range stages {
|
||||||
m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil)
|
m.MarkStageSucceeded(stageName, now.Add(time.Duration(i+1)*time.Minute), nil)
|
||||||
}
|
}
|
||||||
|
setAppAnalyzeEvidence(m, "session_recap", "artifacts/session_recap.md", []byte("# restored recap\n"))
|
||||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||||
if err := store.Save(context.Background(), path, m); err != nil {
|
if err := store.Save(context.Background(), path, m); err != nil {
|
||||||
t.Fatalf("save workflow manifest fixture: %v", err)
|
t.Fatalf("save workflow manifest fixture: %v", err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -11,22 +12,14 @@ import (
|
|||||||
|
|
||||||
// Run executes the pipeline plan and persists manifest state.
|
// Run executes the pipeline plan and persists manifest state.
|
||||||
func Run(ctx context.Context, args []string, out io.Writer) error {
|
func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
request, err := parseBoundedRunRequest("run", args, out)
|
||||||
fs.SetOutput(io.Discard)
|
if err != nil {
|
||||||
|
if errors.Is(err, flag.ErrHelp) {
|
||||||
var flags commonConfigFlags
|
return nil
|
||||||
var force bool
|
}
|
||||||
var selectedArtifacts artifactSelectionFlag
|
|
||||||
addCommonConfigFlags(fs, &flags)
|
|
||||||
fs.BoolVar(&force, "force", false, "rerun stages even when already succeeded")
|
|
||||||
fs.Var(&selectedArtifacts, "artifacts", "configured artifact names to execute and publish (comma-separated or repeatable)")
|
|
||||||
|
|
||||||
if err := parseSessionAwareFlags("run", fs, args, &flags.sessionID); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if flags.sessionID == "" {
|
flags := request.Config
|
||||||
return fmt.Errorf("run: session_id is required")
|
|
||||||
}
|
|
||||||
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
loaded, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
@@ -36,18 +29,13 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if err := config.Validate(cfg); err != nil {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
normalizedArtifacts, err := selectedArtifacts.Normalize()
|
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("run: invalid --artifacts: %w", err)
|
|
||||||
}
|
|
||||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, normalizedArtifacts)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
stages := BuildFullPlan()
|
summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{
|
||||||
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
|
Force: request.Force,
|
||||||
Force: force,
|
SelectedArtifacts: request.SelectedArtifacts,
|
||||||
SelectedArtifacts: normalizedArtifacts,
|
|
||||||
EffectiveArtifacts: effectiveArtifacts,
|
EffectiveArtifacts: effectiveArtifacts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,11 +19,6 @@ const (
|
|||||||
stageActionSkip stageAction = "skip"
|
stageActionSkip stageAction = "skip"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stageDecision struct {
|
|
||||||
Stage stage.Stage
|
|
||||||
Action stageAction
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
staleReasonForcedReplacement = "upstream stage was force-run"
|
staleReasonForcedReplacement = "upstream stage was force-run"
|
||||||
staleReasonChangedResult = "upstream stage result changed"
|
staleReasonChangedResult = "upstream stage result changed"
|
||||||
@@ -39,19 +34,9 @@ type priorStageOutcome struct {
|
|||||||
outputs int
|
outputs int
|
||||||
}
|
}
|
||||||
|
|
||||||
func decideStageActions(stages []stage.Stage, m *manifest.Manifest, force bool) []stageDecision {
|
|
||||||
out := make([]stageDecision, 0, len(stages))
|
|
||||||
for _, s := range stages {
|
|
||||||
out = append(out, stageDecision{
|
|
||||||
Stage: s,
|
|
||||||
Action: decideStageAction(s, m, force),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func decideStageAction(s stage.Stage, m *manifest.Manifest, force bool) stageAction {
|
func decideStageAction(s stage.Stage, m *manifest.Manifest, force bool) stageAction {
|
||||||
// TODO: incorporate stale detection once checksum/input change tracking is implemented.
|
// A succeeded aggregate record is the initial skip candidate. The runner and
|
||||||
|
// planner then let stage-owned resume validation refine that decision.
|
||||||
if !force && stageSucceeded(m, s.Name()) {
|
if !force && stageSucceeded(m, s.Name()) {
|
||||||
return stageActionSkip
|
return stageActionSkip
|
||||||
}
|
}
|
||||||
@@ -122,30 +107,153 @@ func canonicalStageNames() []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func downstreamStageNames(stageName string) []string {
|
type invalidationRelation struct {
|
||||||
names := canonicalStageNames()
|
canonical []string
|
||||||
for i, name := range names {
|
direct map[string][]string
|
||||||
if name != stageName {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return append([]string(nil), names[i+1:]...)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func invalidateDownstreamSucceededStagesWithReason(m *manifest.Manifest, upstreamStage string, at time.Time, reason string) []string {
|
var canonicalInvalidationEdges = map[string][]string{
|
||||||
if m == nil || m.Stages == nil {
|
"prepare": {"transcribe"},
|
||||||
|
"transcribe": {"merge"},
|
||||||
|
"merge": {"polish"},
|
||||||
|
"polish": {"normalize"},
|
||||||
|
"normalize": {"trim"},
|
||||||
|
"trim": {"render", "extract"},
|
||||||
|
"render": {"analyze"},
|
||||||
|
"extract": {"analyze"},
|
||||||
|
"analyze": {"publish"},
|
||||||
|
"publish": {"notify"},
|
||||||
|
"notify": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInvalidationRelation(registry []stage.Stage, direct map[string][]string) (*invalidationRelation, error) {
|
||||||
|
canonical := make([]string, 0, len(registry))
|
||||||
|
known := make(map[string]struct{}, len(registry))
|
||||||
|
for index, candidate := range registry {
|
||||||
|
if candidate == nil {
|
||||||
|
return nil, fmt.Errorf("canonical stage registry entry %d is nil", index)
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(candidate.Name())
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("canonical stage registry entry %d has an empty name", index)
|
||||||
|
}
|
||||||
|
if _, duplicate := known[name]; duplicate {
|
||||||
|
return nil, fmt.Errorf("canonical stage registry contains duplicate stage %q", name)
|
||||||
|
}
|
||||||
|
known[name] = struct{}{}
|
||||||
|
canonical = append(canonical, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := make(map[string][]string, len(direct))
|
||||||
|
for source, targets := range direct {
|
||||||
|
if _, ok := known[source]; !ok {
|
||||||
|
return nil, fmt.Errorf("invalidation relation classifies unknown stage %q", source)
|
||||||
|
}
|
||||||
|
cloned[source] = []string{}
|
||||||
|
seenTargets := make(map[string]struct{}, len(targets))
|
||||||
|
for _, target := range targets {
|
||||||
|
if _, ok := known[target]; !ok {
|
||||||
|
return nil, fmt.Errorf("invalidation relation edge %q -> %q references an unknown stage", source, target)
|
||||||
|
}
|
||||||
|
if _, duplicate := seenTargets[target]; duplicate {
|
||||||
|
return nil, fmt.Errorf("invalidation relation contains duplicate edge %q -> %q", source, target)
|
||||||
|
}
|
||||||
|
seenTargets[target] = struct{}{}
|
||||||
|
cloned[source] = append(cloned[source], target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range canonical {
|
||||||
|
if _, classified := direct[name]; !classified {
|
||||||
|
return nil, fmt.Errorf("invalidation relation is missing classification for stage %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
relation := &invalidationRelation{canonical: canonical, direct: cloned}
|
||||||
|
visiting := make(map[string]bool, len(canonical))
|
||||||
|
visited := make(map[string]bool, len(canonical))
|
||||||
|
var visit func(string) error
|
||||||
|
visit = func(name string) error {
|
||||||
|
if visiting[name] {
|
||||||
|
return fmt.Errorf("invalidation relation contains a cycle involving stage %q", name)
|
||||||
|
}
|
||||||
|
if visited[name] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
visiting[name] = true
|
||||||
|
for _, target := range relation.direct[name] {
|
||||||
|
if err := visit(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visiting[name] = false
|
||||||
|
visited[name] = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
for _, name := range canonical {
|
||||||
|
if err := visit(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return relation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalInvalidationRelation() (*invalidationRelation, error) {
|
||||||
|
return newInvalidationRelation(stage.All(), canonicalInvalidationEdges)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *invalidationRelation) Dependents(stageName string) ([]string, error) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("invalidation relation is nil")
|
||||||
|
}
|
||||||
|
if _, ok := r.direct[stageName]; !ok {
|
||||||
|
return nil, fmt.Errorf("unknown stage %q in invalidation relation", stageName)
|
||||||
|
}
|
||||||
|
reachable := make(map[string]bool, len(r.canonical))
|
||||||
|
var collect func(string)
|
||||||
|
collect = func(name string) {
|
||||||
|
for _, target := range r.direct[name] {
|
||||||
|
if reachable[target] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
reachable[target] = true
|
||||||
|
collect(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect(stageName)
|
||||||
|
out := make([]string, 0, len(reachable))
|
||||||
|
for _, name := range r.canonical {
|
||||||
|
if reachable[name] {
|
||||||
|
out = append(out, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dependentStageNames(stageName string) ([]string, error) {
|
||||||
|
relation, err := canonicalInvalidationRelation()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return relation.Dependents(stageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateDependentSucceededStagesWithReason(m *manifest.Manifest, upstreamStage string, at time.Time, reason string) ([]string, error) {
|
||||||
|
dependents, err := dependentStageNames(upstreamStage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if m == nil || m.Stages == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
invalidated := make([]string, 0)
|
invalidated := make([]string, 0)
|
||||||
for _, downstream := range downstreamStageNames(upstreamStage) {
|
for _, dependent := range dependents {
|
||||||
sr := m.Stages[downstream]
|
sr := m.Stages[dependent]
|
||||||
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
if sr == nil || sr.Status != manifest.StatusSucceeded {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.MarkStageStale(downstream, at, reason)
|
m.MarkStageStale(dependent, at, reason)
|
||||||
invalidated = append(invalidated, downstream)
|
invalidated = append(invalidated, dependent)
|
||||||
}
|
}
|
||||||
return invalidated
|
return invalidated, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,109 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDecideStageActions(t *testing.T) {
|
func TestDecideStageAction(t *testing.T) {
|
||||||
stages := BuildFullPlan()[:2]
|
stages := BuildFullPlan()[:2]
|
||||||
m := manifest.New("2026-05-03", time.Now().UTC())
|
m := manifest.New("2026-05-03", time.Now().UTC())
|
||||||
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
m.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
||||||
|
|
||||||
got := decideStageActions(stages, m, false)
|
if got := decideStageAction(stages[0], m, false); got != stageActionSkip {
|
||||||
if len(got) != 2 {
|
t.Fatalf("prepare action = %q, want %q", got, stageActionSkip)
|
||||||
t.Fatalf("len(decisions) = %d, want 2", len(got))
|
|
||||||
}
|
}
|
||||||
if got[0].Action != stageActionSkip {
|
if got := decideStageAction(stages[1], m, false); got != stageActionRun {
|
||||||
t.Fatalf("prepare action = %q, want %q", got[0].Action, stageActionSkip)
|
t.Fatalf("transcribe action = %q, want %q", got, stageActionRun)
|
||||||
}
|
|
||||||
if got[1].Action != stageActionRun {
|
|
||||||
t.Fatalf("transcribe action = %q, want %q", got[1].Action, stageActionRun)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
forced := decideStageActions(stages, m, true)
|
if got := decideStageAction(stages[0], m, true); got != stageActionRun {
|
||||||
if forced[0].Action != stageActionRun {
|
t.Fatalf("forced prepare action = %q, want %q", got, stageActionRun)
|
||||||
t.Fatalf("forced prepare action = %q, want %q", forced[0].Action, stageActionRun)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDownstreamStageNames(t *testing.T) {
|
func TestInvalidationDependents(t *testing.T) {
|
||||||
got := downstreamStageNames("polish")
|
tests := []struct {
|
||||||
want := []string{"normalize", "trim", "extract", "render", "analyze", "publish", "notify"}
|
stage string
|
||||||
if !reflect.DeepEqual(got, want) {
|
want []string
|
||||||
t.Fatalf("downstreamStageNames(polish) = %#v, want %#v", got, want)
|
}{
|
||||||
|
{"prepare", []string{"transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"transcribe", []string{"merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"merge", []string{"polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"polish", []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"normalize", []string{"trim", "render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"trim", []string{"render", "extract", "analyze", "publish", "notify"}},
|
||||||
|
{"render", []string{"analyze", "publish", "notify"}},
|
||||||
|
{"extract", []string{"analyze", "publish", "notify"}},
|
||||||
|
{"analyze", []string{"publish", "notify"}},
|
||||||
|
{"publish", []string{"notify"}},
|
||||||
|
{"notify", []string{}},
|
||||||
}
|
}
|
||||||
|
for _, test := range tests {
|
||||||
missing := downstreamStageNames("unknown")
|
t.Run(test.stage, func(t *testing.T) {
|
||||||
if len(missing) != 0 {
|
got, err := dependentStageNames(test.stage)
|
||||||
t.Fatalf("downstreamStageNames(unknown) = %#v, want empty", missing)
|
if err != nil {
|
||||||
|
t.Fatalf("dependentStageNames(%q) error = %v", test.stage, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, test.want) {
|
||||||
|
t.Fatalf("dependentStageNames(%q) = %#v, want %#v", test.stage, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := dependentStageNames("unknown"); err == nil || !strings.Contains(err.Error(), "unknown stage") {
|
||||||
|
t.Fatalf("dependentStageNames(unknown) error = %v, want unknown-stage error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInvalidateDownstreamSucceededStagesWithReason(t *testing.T) {
|
func TestInvalidationRelationRejectsInvalidInventory(t *testing.T) {
|
||||||
|
canonical := []stage.Stage{
|
||||||
|
invalidationTestStage("one"),
|
||||||
|
invalidationTestStage("two"),
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
registry []stage.Stage
|
||||||
|
edges map[string][]string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "duplicate registry name", registry: append(canonical, invalidationTestStage("one")), edges: map[string][]string{"one": {"two"}, "two": {}}, want: "duplicate stage"},
|
||||||
|
{name: "unknown source", registry: canonical, edges: map[string][]string{"one": {"two"}, "two": {}, "three": {}}, want: "unknown stage"},
|
||||||
|
{name: "unknown target", registry: canonical, edges: map[string][]string{"one": {"three"}, "two": {}}, want: "unknown stage"},
|
||||||
|
{name: "missing classification", registry: canonical, edges: map[string][]string{"one": {"two"}}, want: "missing classification"},
|
||||||
|
{name: "cycle", registry: canonical, edges: map[string][]string{"one": {"two"}, "two": {"one"}}, want: "cycle"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := newInvalidationRelation(test.registry, test.edges)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("newInvalidationRelation() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidateDependentSucceededStagesWithReason(t *testing.T) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
m := manifest.New("2026-05-03", now)
|
m := manifest.New("2026-05-03", now)
|
||||||
m.MarkStageSucceeded("prepare", now, nil)
|
for _, name := range canonicalStageNames() {
|
||||||
m.MarkStageSucceeded("transcribe", now, nil)
|
m.MarkStageSucceeded(name, now, nil)
|
||||||
m.MarkStageSucceeded("merge", now, nil)
|
|
||||||
m.MarkStageSucceeded("polish", now, nil)
|
|
||||||
m.MarkStageSucceeded("normalize", now, nil)
|
|
||||||
m.MarkStageSucceeded("trim", now, nil)
|
|
||||||
m.MarkStageSucceeded("extract", now, nil)
|
|
||||||
m.MarkStageSucceeded("render", now, nil)
|
|
||||||
m.MarkStageFailed("analyze", now, "analysis failed")
|
|
||||||
m.MarkStageSucceeded("publish", now, nil)
|
|
||||||
m.MarkStageSucceeded("notify", now, nil)
|
|
||||||
|
|
||||||
got := invalidateDownstreamSucceededStagesWithReason(m, "polish", now.Add(1*time.Second), staleReasonChangedResult)
|
|
||||||
want := []string{"normalize", "trim", "extract", "render", "publish", "notify"}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("invalidateDownstreamSucceededStagesWithReason() = %#v, want %#v", got, want)
|
|
||||||
}
|
}
|
||||||
|
m.MarkStageFailed("analyze", now, "analysis failed")
|
||||||
|
|
||||||
|
got, err := invalidateDependentSucceededStagesWithReason(m, "polish", now.Add(time.Second), staleReasonChangedResult)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invalidateDependentSucceededStagesWithReason() error = %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"normalize", "trim", "render", "extract", "publish", "notify"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("invalidateDependentSucceededStagesWithReason() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
for _, stageName := range want {
|
for _, stageName := range want {
|
||||||
if m.Stages[stageName].Status != manifest.StatusStale {
|
if m.Stages[stageName].Status != manifest.StatusStale {
|
||||||
t.Fatalf("%s status = %q, want stale", stageName, m.Stages[stageName].Status)
|
t.Fatalf("%s status = %q, want stale", stageName, m.Stages[stageName].Status)
|
||||||
@@ -72,34 +112,38 @@ func TestInvalidateDownstreamSucceededStagesWithReason(t *testing.T) {
|
|||||||
if m.Stages["analyze"].Status != manifest.StatusFailed {
|
if m.Stages["analyze"].Status != manifest.StatusFailed {
|
||||||
t.Fatalf("analyze status = %q, want failed", m.Stages["analyze"].Status)
|
t.Fatalf("analyze status = %q, want failed", m.Stages["analyze"].Status)
|
||||||
}
|
}
|
||||||
if m.Stages["prepare"].Status != manifest.StatusSucceeded {
|
|
||||||
t.Fatalf("prepare status = %q, want succeeded", m.Stages["prepare"].Status)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractionPositionControlsForceInvalidation(t *testing.T) {
|
func TestRenderAndExtractInvalidationAreIndependent(t *testing.T) {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
tests := []struct {
|
for _, upstream := range []string{"render", "extract"} {
|
||||||
upstream string
|
t.Run(upstream, func(t *testing.T) {
|
||||||
want []string
|
|
||||||
}{
|
|
||||||
{upstream: "trim", want: []string{"extract", "render", "analyze", "publish", "notify"}},
|
|
||||||
{upstream: "extract", want: []string{"render", "analyze", "publish", "notify"}},
|
|
||||||
{upstream: "render", want: []string{"analyze", "publish", "notify"}},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.upstream, func(t *testing.T) {
|
|
||||||
m := manifest.New("2026-05-03", now)
|
m := manifest.New("2026-05-03", now)
|
||||||
for _, name := range canonicalStageNames() {
|
for _, name := range canonicalStageNames() {
|
||||||
m.MarkStageSucceeded(name, now, nil)
|
m.MarkStageSucceeded(name, now, nil)
|
||||||
}
|
}
|
||||||
got := invalidateDownstreamSucceededStagesWithReason(m, test.upstream, now.Add(time.Second), staleReasonForcedReplacement)
|
got, err := invalidateDependentSucceededStagesWithReason(m, upstream, now.Add(time.Second), staleReasonForcedReplacement)
|
||||||
if !reflect.DeepEqual(got, test.want) {
|
if err != nil {
|
||||||
t.Fatalf("invalidated = %#v, want %#v", got, test.want)
|
t.Fatalf("invalidate dependents: %v", err)
|
||||||
}
|
}
|
||||||
if test.upstream == "render" && m.Stages["extract"].Status != manifest.StatusSucceeded {
|
want := []string{"analyze", "publish", "notify"}
|
||||||
t.Fatalf("forcing render changed extract: %#v", m.Stages["extract"])
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("invalidated = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
sibling := "render"
|
||||||
|
if upstream == "render" {
|
||||||
|
sibling = "extract"
|
||||||
|
}
|
||||||
|
if m.Stages[sibling].Status != manifest.StatusSucceeded {
|
||||||
|
t.Fatalf("%s invalidated sibling %s: %#v", upstream, sibling, m.Stages[sibling])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type invalidationTestStage string
|
||||||
|
|
||||||
|
func (s invalidationTestStage) Name() string { return string(s) }
|
||||||
|
func (s invalidationTestStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||||
|
return &stage.StageResult{}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ type singleStageCommand struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSummary, error) {
|
func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSummary, error) {
|
||||||
stages, err := BuildSingleStagePlan(req.StageName)
|
plan, err := buildSingleStageExecutionPlan(req.StageName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
}
|
}
|
||||||
@@ -220,7 +220,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
}
|
}
|
||||||
summary, err := executeStagesFn(ctx, cfg, stages, RunOptions{
|
summary, err := executeStagesFn(ctx, cfg, plan, RunOptions{
|
||||||
Force: req.Force,
|
Force: req.Force,
|
||||||
SelectedArtifacts: req.SelectedArtifacts,
|
SelectedArtifacts: req.SelectedArtifacts,
|
||||||
EffectiveArtifacts: effectiveArtifacts,
|
EffectiveArtifacts: effectiveArtifacts,
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load manifest after force: %v", err)
|
t.Fatalf("load manifest after force: %v", err)
|
||||||
}
|
}
|
||||||
for _, name := range []string{"normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
for _, name := range []string{"normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
|
||||||
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
if afterForce.Stages[name] == nil || afterForce.Stages[name].Status != manifest.StatusStale {
|
||||||
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
t.Fatalf("stage %q = %#v, want stale", name, afterForce.Stages[name])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,9 +40,17 @@ type RunSummary struct {
|
|||||||
Skipped []string
|
Skipped []string
|
||||||
}
|
}
|
||||||
|
|
||||||
var executeStagesFn = executeStages
|
var executeStagesFn = executePlan
|
||||||
|
|
||||||
func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (summary *RunSummary, resultErr error) {
|
func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts RunOptions) (summary *RunSummary, resultErr error) {
|
||||||
|
stages := plan.Stages()
|
||||||
|
var prerequisiteStore manifest.Store
|
||||||
|
if opts.Env != nil {
|
||||||
|
prerequisiteStore = opts.Env.ManifestStore
|
||||||
|
}
|
||||||
|
if err := inspectBoundedPrerequisites(ctx, cfg, plan, prerequisiteStore); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate bounded run prerequisites: %w", err)
|
||||||
|
}
|
||||||
effectiveArtifacts := opts.EffectiveArtifacts
|
effectiveArtifacts := opts.EffectiveArtifacts
|
||||||
if !effectiveArtifacts.Resolved() && cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
|
if !effectiveArtifacts.Resolved() && cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Scriptorium != nil {
|
||||||
var err error
|
var err error
|
||||||
@@ -131,6 +139,13 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
return nil, fmt.Errorf("create manifest: %w", err)
|
return nil, fmt.Errorf("create manifest: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The preflight prerequisite inspection avoids creating run state for an
|
||||||
|
// already-invalid request. Recheck the manifest protected by the session
|
||||||
|
// lock because another invocation may have changed prerequisite state while
|
||||||
|
// this invocation waited to acquire the lock.
|
||||||
|
if err := validateBoundedPrerequisites(plan, m); err != nil {
|
||||||
|
return nil, fmt.Errorf("validate bounded run prerequisites under session lock: %w", err)
|
||||||
|
}
|
||||||
identity.applyToSessionManifest(m)
|
identity.applyToSessionManifest(m)
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
||||||
@@ -169,7 +184,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
fmt.Errorf("load secrets from files: %w", err),
|
fmt.Errorf("load secrets from files: %w", err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if env.WhisperX == nil {
|
if env.WhisperX == nil && stagesContainAny(stages, "transcribe") {
|
||||||
client, err := buildDefaultWhisperXClient(env.Config)
|
client, err := buildDefaultWhisperXClient(env.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -179,7 +194,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
env.WhisperX = client
|
env.WhisperX = client
|
||||||
}
|
}
|
||||||
if env.Seriatim == nil {
|
if env.Seriatim == nil && stagesContainAny(stages, "merge", "normalize", "trim", "render") {
|
||||||
runner, err := buildDefaultSeriatimRunner(env.Config)
|
runner, err := buildDefaultSeriatimRunner(env.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -189,7 +204,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
env.Seriatim = runner
|
env.Seriatim = runner
|
||||||
}
|
}
|
||||||
if env.Audita == nil {
|
if env.Audita == nil && stagesContainAny(stages, "polish") {
|
||||||
runner, err := buildDefaultAuditaRunner(env.Config)
|
runner, err := buildDefaultAuditaRunner(env.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -202,7 +217,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
if env.Notarius == nil && needsNotariusForRun(env.Config, stages) {
|
if env.Notarius == nil && needsNotariusForRun(env.Config, stages) {
|
||||||
env.Notarius = notarius.NewSubprocessRunner()
|
env.Notarius = notarius.NewSubprocessRunner()
|
||||||
}
|
}
|
||||||
if env.Scriptorium == nil {
|
if env.Scriptorium == nil && stagesContainAny(stages, "trim", "analyze") {
|
||||||
env.Scriptorium = scriptorium.NewSubprocessRunner()
|
env.Scriptorium = scriptorium.NewSubprocessRunner()
|
||||||
}
|
}
|
||||||
if env.ObjectStore == nil && needsObjectStoreForRun(env.Config, stages, effectiveArtifacts) {
|
if env.ObjectStore == nil && needsObjectStoreForRun(env.Config, stages, effectiveArtifacts) {
|
||||||
@@ -232,23 +247,21 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
return config.MergePublishLockRules(staticLocks, remote.Locks), nil
|
return config.MergePublishLockRules(staticLocks, remote.Locks), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if env.Notifier == nil {
|
if env.Notifier == nil && stagesContainAny(stages, "notify") {
|
||||||
env.Notifier = ¬ify.NoopSender{}
|
env.Notifier = ¬ify.NoopSender{}
|
||||||
}
|
}
|
||||||
|
|
||||||
stageEnv := env
|
stageEnv := env
|
||||||
|
|
||||||
decisions := decideStageActions(stages, m, opts.Force)
|
runNames := make([]string, 0, len(stages))
|
||||||
|
executed := make([]string, 0, len(stages))
|
||||||
runNames := make([]string, 0, len(decisions))
|
skipped := make([]string, 0, len(stages))
|
||||||
executed := make([]string, 0, len(decisions))
|
for _, s := range stages {
|
||||||
skipped := make([]string, 0, len(decisions))
|
stageEnv.Force = opts.Force
|
||||||
for _, d := range decisions {
|
|
||||||
s := d.Stage
|
|
||||||
runNames = append(runNames, s.Name())
|
runNames = append(runNames, s.Name())
|
||||||
d.Action = decideStageAction(s, m, opts.Force)
|
action := decideStageAction(s, m, opts.Force)
|
||||||
|
|
||||||
if d.Action == stageActionSkip {
|
if action == stageActionSkip {
|
||||||
if validator, ok := s.(stage.ResumeValidator); ok {
|
if validator, ok := s.(stage.ResumeValidator); ok {
|
||||||
validation, err := validator.ValidateResume(ctx, stageEnv, m)
|
validation, err := validator.ValidateResume(ctx, stageEnv, m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -261,9 +274,14 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
if !validation.Resumable {
|
if !validation.Resumable {
|
||||||
staleAt := nowUTC()
|
staleAt := nowUTC()
|
||||||
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
|
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
|
||||||
invalidateDownstreamSucceededStagesWithReason(
|
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||||
m, s.Name(), staleAt, staleReasonNotResumable,
|
m, s.Name(), staleAt, staleReasonNotResumable,
|
||||||
)
|
); err != nil {
|
||||||
|
return nil, persistTerminalFailure(
|
||||||
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
|
fmt.Errorf("invalidate dependents after resume validation for stage %q: %w", s.Name(), err),
|
||||||
|
)
|
||||||
|
}
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
@@ -271,12 +289,12 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
env.Logger.Info("stage result is not resumable", "stage", s.Name(), "reason", validation.Reason)
|
env.Logger.Info("stage result is not resumable", "stage", s.Name(), "reason", validation.Reason)
|
||||||
d.Action = stageActionRun
|
action = stageActionRun
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.Action == stageActionSkip {
|
if action == stageActionSkip {
|
||||||
skipped = append(skipped, s.Name())
|
skipped = append(skipped, s.Name())
|
||||||
skipAt := nowUTC()
|
skipAt := nowUTC()
|
||||||
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
|
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
|
||||||
@@ -292,6 +310,7 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
executed = append(executed, s.Name())
|
executed = append(executed, s.Name())
|
||||||
priorOutcome := capturePriorStageOutcome(m, s.Name())
|
priorOutcome := capturePriorStageOutcome(m, s.Name())
|
||||||
|
priorAnalyzeState := captureAnalyzeState(m, s.Name())
|
||||||
|
|
||||||
now := nowUTC()
|
now := nowUTC()
|
||||||
runManifest.SetStageAction(s.Name(), manifest.RunStageActionRun, now)
|
runManifest.SetStageAction(s.Name(), manifest.RunStageActionRun, now)
|
||||||
@@ -305,13 +324,20 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}
|
}
|
||||||
m.MarkStageRunning(s.Name(), now)
|
m.MarkStageRunning(s.Name(), now)
|
||||||
if opts.Force {
|
if opts.Force {
|
||||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), now, staleReasonForcedReplacement)
|
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), now, staleReasonForcedReplacement); err != nil {
|
||||||
|
return nil, persistTerminalFailure(
|
||||||
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
|
fmt.Errorf("invalidate dependents before forced stage %q: %w", s.Name(), err),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
env.Logger.Info("starting stage", "stage", s.Name())
|
env.Logger.Info("starting stage", "stage", s.Name())
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
operationErr := fmt.Errorf("save manifest before stage %q: %w", s.Name(), err)
|
operationErr := fmt.Errorf("save manifest before stage %q: %w", s.Name(), err)
|
||||||
m.MarkStageFailed(s.Name(), nowUTC(), operationErr.Error())
|
m.MarkStageFailed(s.Name(), nowUTC(), operationErr.Error())
|
||||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), nowUTC(), staleReasonFailure)
|
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), nowUTC(), staleReasonFailure); invalidationErr != nil {
|
||||||
|
operationErr = errors.Join(operationErr, fmt.Errorf("invalidate dependents after stage %q persistence failure: %w", s.Name(), invalidationErr))
|
||||||
|
}
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest, operationErr,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest, operationErr,
|
||||||
)
|
)
|
||||||
@@ -319,13 +345,28 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "running", "path", manifestPath)
|
env.Logger.Debug("manifest saved", "stage", s.Name(), "transition", "running", "path", manifestPath)
|
||||||
|
|
||||||
result, err := s.Run(ctx, stageEnv, m)
|
result, err := s.Run(ctx, stageEnv, m)
|
||||||
|
var analyzeProjection *validatedAnalyzeProjection
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = validateStageResult(result)
|
err = validateStageResult(result)
|
||||||
|
if err == nil {
|
||||||
|
analyzeProjection, err = validateSuccessfulAnalyzeProjection(s.Name(), result)
|
||||||
|
}
|
||||||
|
} else if result != nil && result.AnalyzeState != nil {
|
||||||
|
var projectionErr error
|
||||||
|
analyzeProjection, projectionErr = validateFailedAnalyzeProjection(s.Name(), result)
|
||||||
|
if projectionErr != nil {
|
||||||
|
err = errors.Join(err, projectionErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if analyzeProjection != nil {
|
||||||
|
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
||||||
|
}
|
||||||
failedAt := nowUTC()
|
failedAt := nowUTC()
|
||||||
m.MarkStageFailed(s.Name(), failedAt, err.Error())
|
m.MarkStageFailed(s.Name(), failedAt, err.Error())
|
||||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure)
|
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure); invalidationErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("invalidate dependents after stage %q failure: %w", s.Name(), invalidationErr))
|
||||||
|
}
|
||||||
runManifest.MarkStageFailed(s.Name(), failedAt, err.Error())
|
runManifest.MarkStageFailed(s.Name(), failedAt, err.Error())
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
env.Logger.Info("stage failed", "stage", s.Name(), "error", err)
|
env.Logger.Info("stage failed", "stage", s.Name(), "error", err)
|
||||||
@@ -340,7 +381,12 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||||
applyStageResultToManifest(m, s.Name(), result)
|
applyStageResultToManifest(m, s.Name(), result)
|
||||||
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
|
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
|
||||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip)
|
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip); err != nil {
|
||||||
|
return nil, persistTerminalFailure(
|
||||||
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
|
fmt.Errorf("invalidate dependents after stage %q self-skip: %w", s.Name(), err),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -362,21 +408,42 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
outputs := mapResultOutputs(s.Name(), result, runID)
|
sessionOutputs := mapResultOutputs(s.Name(), result, runID)
|
||||||
|
runOutputs := sessionOutputs
|
||||||
|
if analyzeProjection != nil {
|
||||||
|
sessionOutputs = analyzeProjectionOutputs(analyzeProjection.session, "")
|
||||||
|
runOutputs = analyzeProjectionOutputs(analyzeProjection.invocation, runID)
|
||||||
|
}
|
||||||
succeededAt := nowUTC()
|
succeededAt := nowUTC()
|
||||||
m.MarkStageSucceeded(s.Name(), succeededAt, outputs)
|
m.MarkStageSucceeded(s.Name(), succeededAt, sessionOutputs)
|
||||||
|
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
||||||
applyStageResultToManifest(m, s.Name(), result)
|
applyStageResultToManifest(m, s.Name(), result)
|
||||||
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
|
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
|
||||||
invalidateDownstreamSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult)
|
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult); err != nil {
|
||||||
|
return nil, persistTerminalFailure(
|
||||||
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
|
fmt.Errorf("invalidate dependents after changed stage %q result: %w", s.Name(), err),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
|
operationErr := fmt.Errorf("save manifest after stage %q: %w", s.Name(), err)
|
||||||
|
if analyzeProjection != nil {
|
||||||
|
restoreAnalyzeState(m, priorAnalyzeState)
|
||||||
|
failedAt := nowUTC()
|
||||||
|
m.MarkStageFailed(s.Name(), failedAt, operationErr.Error())
|
||||||
|
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(m, s.Name(), failedAt, staleReasonFailure); invalidationErr != nil {
|
||||||
|
operationErr = errors.Join(operationErr, fmt.Errorf("invalidate dependents after analyze projection persistence failure: %w", invalidationErr))
|
||||||
|
}
|
||||||
|
runManifest.MarkStageFailed(s.Name(), failedAt, operationErr.Error())
|
||||||
|
}
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
fmt.Errorf("save manifest after stage %q: %w", s.Name(), err),
|
operationErr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
runManifest.MarkStageSucceeded(s.Name(), succeededAt, outputs)
|
runManifest.MarkStageSucceeded(s.Name(), succeededAt, runOutputs)
|
||||||
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
@@ -401,11 +468,13 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
// The run record lives inside the run work directory, which cleanup may
|
// The run record lives inside the run work directory, which cleanup may
|
||||||
// remove. Persist its completed publishing result before cleanup starts so a
|
// remove. Persist its completed publishing result before cleanup starts so a
|
||||||
// successful deletion cannot be undone by a later diagnostic write.
|
// successful deletion cannot be undone by a later diagnostic write.
|
||||||
if err := runPostPublishCleanup(ctx, env, manifestPath, m, executed); err != nil {
|
if containsStage(executed, "publish") {
|
||||||
return nil, persistPostPublishCleanupFailure(
|
if err := runPostPublishCleanup(ctx, env, manifestPath, m, executed); err != nil {
|
||||||
ctx, env.ManifestStore, manifestPath, m,
|
return nil, persistPostPublishCleanupFailure(
|
||||||
fmt.Errorf("post-publish cleanup incomplete: %w", err),
|
ctx, env.ManifestStore, manifestPath, m,
|
||||||
)
|
fmt.Errorf("post-publish cleanup incomplete: %w", err),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &RunSummary{
|
return &RunSummary{
|
||||||
@@ -419,6 +488,22 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stagesContainAny(stages []stage.Stage, names ...string) bool {
|
||||||
|
wanted := make(map[string]struct{}, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
wanted[name] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, candidate := range stages {
|
||||||
|
if candidate == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := wanted[candidate.Name()]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func persistPostPublishCleanupFailure(
|
func persistPostPublishCleanupFailure(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
sessionStore manifest.Store,
|
sessionStore manifest.Store,
|
||||||
|
|||||||
@@ -564,12 +564,12 @@ func TestExecuteStagesUsesOptionalResumeValidation(t *testing.T) {
|
|||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
seed.MarkStageSucceeded("checked", time.Now().UTC(), nil)
|
seed.MarkStageSucceeded("extract", time.Now().UTC(), nil)
|
||||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||||
t.Fatalf("Save() error = %v", err)
|
t.Fatalf("Save() error = %v", err)
|
||||||
}
|
}
|
||||||
runs := 0
|
runs := 0
|
||||||
candidate := resumeCheckingStage{name: "checked", validation: test.validation, runs: &runs}
|
candidate := resumeCheckingStage{name: "extract", validation: test.validation, runs: &runs}
|
||||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
@@ -581,7 +581,7 @@ func TestExecuteStagesUsesOptionalResumeValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecuteStagesNonResumableResultRerunsSucceededDownstream(t *testing.T) {
|
func TestExecuteStagesNonResumableResultPreservesSucceededSibling(t *testing.T) {
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
@@ -599,7 +599,7 @@ func TestExecuteStagesNonResumableResultRerunsSucceededDownstream(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
if extractRuns != 1 || renderRuns != 1 || len(summary.Executed) != 2 || len(summary.Skipped) != 0 {
|
if extractRuns != 1 || renderRuns != 0 || len(summary.Executed) != 1 || len(summary.Skipped) != 1 {
|
||||||
t.Fatalf("extract runs=%d render runs=%d summary=%#v", extractRuns, renderRuns, summary)
|
t.Fatalf("extract runs=%d render runs=%d summary=%#v", extractRuns, renderRuns, summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1019,14 +1019,14 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
manifestPath := manifestPathFor(cfg)
|
manifestPath := manifestPathFor(cfg)
|
||||||
seed := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
seed := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||||
seed.MarkStageSucceeded("optional", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{
|
seed.MarkStageSucceeded("extract", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{
|
||||||
Kind: "old_output",
|
Kind: "old_output",
|
||||||
SourceID: "narratio.example.old",
|
SourceID: "narratio.example.old",
|
||||||
LocalPath: "artifacts/old.json",
|
LocalPath: "artifacts/old.json",
|
||||||
}})
|
}})
|
||||||
seed.Stages["optional"].Logs = []string{"old.log"}
|
seed.Stages["extract"].Logs = []string{"old.log"}
|
||||||
seed.Stages["optional"].GeneratedConfigs = []string{"old.yml"}
|
seed.Stages["extract"].GeneratedConfigs = []string{"old.yml"}
|
||||||
seed.Stages["optional"].Metadata = map[string]any{"old": true}
|
seed.Stages["extract"].Metadata = map[string]any{"old": true}
|
||||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1038,7 +1038,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
optionalRuns := 0
|
optionalRuns := 0
|
||||||
stages := []stage.Stage{
|
stages := []stage.Stage{
|
||||||
resultStage{
|
resultStage{
|
||||||
name: "optional",
|
name: "extract",
|
||||||
runs: &optionalRuns,
|
runs: &optionalRuns,
|
||||||
order: &order,
|
order: &order,
|
||||||
result: &stage.StageResult{
|
result: &stage.StageResult{
|
||||||
@@ -1049,16 +1049,16 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
Metadata: map[string]any{"enabled": false},
|
Metadata: map[string]any{"enabled": false},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resultStage{name: "later", order: &order, result: &stage.StageResult{}},
|
resultStage{name: "analyze", order: &order, result: &stage.StageResult{}},
|
||||||
}
|
}
|
||||||
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{Force: true})
|
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{Force: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("executeStages() error = %v", err)
|
t.Fatalf("executeStages() error = %v", err)
|
||||||
}
|
}
|
||||||
if strings.Join(order, ",") != "optional,later" {
|
if strings.Join(order, ",") != "extract,analyze" {
|
||||||
t.Fatalf("execution order = %v, want optional then later", order)
|
t.Fatalf("execution order = %v, want extract then analyze", order)
|
||||||
}
|
}
|
||||||
if len(summary.Executed) != 2 || len(summary.Skipped) != 1 || summary.Skipped[0] != "optional" {
|
if len(summary.Executed) != 2 || len(summary.Skipped) != 1 || summary.Skipped[0] != "extract" {
|
||||||
t.Fatalf("summary = %#v, want optional executed and self-skipped before later", summary)
|
t.Fatalf("summary = %#v, want optional executed and self-skipped before later", summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,7 +1066,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load() session manifest error = %v", err)
|
t.Fatalf("Load() session manifest error = %v", err)
|
||||||
}
|
}
|
||||||
selfSkipped := sessionManifest.Stages["optional"]
|
selfSkipped := sessionManifest.Stages["extract"]
|
||||||
if selfSkipped == nil || selfSkipped.Status != manifest.StatusSkipped {
|
if selfSkipped == nil || selfSkipped.Status != manifest.StatusSkipped {
|
||||||
t.Fatalf("optional stage = %#v, want skipped", selfSkipped)
|
t.Fatalf("optional stage = %#v, want skipped", selfSkipped)
|
||||||
}
|
}
|
||||||
@@ -1081,7 +1081,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
selfSkipped.Metadata["enabled"] != false || selfSkipped.Metadata["old"] != nil {
|
selfSkipped.Metadata["enabled"] != false || selfSkipped.Metadata["old"] != nil {
|
||||||
t.Fatalf("optional result details = %#v, want current bounded diagnostics and metadata", selfSkipped)
|
t.Fatalf("optional result details = %#v, want current bounded diagnostics and metadata", selfSkipped)
|
||||||
}
|
}
|
||||||
if later := sessionManifest.Stages["later"]; later == nil || later.Status != manifest.StatusSucceeded {
|
if later := sessionManifest.Stages["analyze"]; later == nil || later.Status != manifest.StatusSucceeded {
|
||||||
t.Fatalf("later stage = %#v, want succeeded", later)
|
t.Fatalf("later stage = %#v, want succeeded", later)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,7 +1089,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadRun() error = %v", err)
|
t.Fatalf("LoadRun() error = %v", err)
|
||||||
}
|
}
|
||||||
runStage := runManifest.Stages["optional"]
|
runStage := runManifest.Stages["extract"]
|
||||||
if runStage == nil || runStage.Action != manifest.RunStageActionRun || runStage.Status != manifest.StatusSkipped {
|
if runStage == nil || runStage.Action != manifest.RunStageActionRun || runStage.Status != manifest.StatusSkipped {
|
||||||
t.Fatalf("run optional stage = %#v, want run action with skipped status", runStage)
|
t.Fatalf("run optional stage = %#v, want run action with skipped status", runStage)
|
||||||
}
|
}
|
||||||
@@ -1108,7 +1108,7 @@ func TestExecuteStagesPersistsSelfSkipAndContinues(t *testing.T) {
|
|||||||
|
|
||||||
func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
||||||
cfg := testConfig(t)
|
cfg := testConfig(t)
|
||||||
invalid := resultStage{name: "optional", result: &stage.StageResult{
|
invalid := resultStage{name: "extract", result: &stage.StageResult{
|
||||||
Disposition: stage.StageDispositionSkipped,
|
Disposition: stage.StageDispositionSkipped,
|
||||||
SkipReason: "integration_disabled",
|
SkipReason: "integration_disabled",
|
||||||
Outputs: []artifacts.Ref{{Kind: "unexpected"}},
|
Outputs: []artifacts.Ref{{Kind: "unexpected"}},
|
||||||
@@ -1129,7 +1129,7 @@ func TestExecuteStagesRejectsSkippedResultWithOutputs(t *testing.T) {
|
|||||||
if loadErr != nil {
|
if loadErr != nil {
|
||||||
t.Fatalf("Load() session manifest error = %v", loadErr)
|
t.Fatalf("Load() session manifest error = %v", loadErr)
|
||||||
}
|
}
|
||||||
if got := loaded.Stages["optional"]; got == nil || got.Status != manifest.StatusFailed {
|
if got := loaded.Stages["extract"]; got == nil || got.Status != manifest.StatusFailed {
|
||||||
t.Fatalf("optional stage = %#v, want failed", got)
|
t.Fatalf("optional stage = %#v, want failed", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
internal/app/runner_test_helpers_test.go
Normal file
15
internal/app/runner_test_helpers_test.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// executeStages keeps runner tests focused on controlled stage doubles. The
|
||||||
|
// production command path always supplies one validated BoundedPlan directly.
|
||||||
|
func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
||||||
|
plan := BoundedPlan{stages: append([]stage.Stage(nil), stages...)}
|
||||||
|
return executePlan(ctx, cfg, plan, opts)
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
||||||
@@ -21,7 +20,7 @@ func TestExecuteRunAcceptsPositionalSessionID(t *testing.T) {
|
|||||||
var capturedSessionID string
|
var capturedSessionID string
|
||||||
origExecuteStagesFn := executeStagesFn
|
origExecuteStagesFn := executeStagesFn
|
||||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||||
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
|
||||||
capturedSessionID = cfg.Session.SessionID
|
capturedSessionID = cfg.Session.SessionID
|
||||||
return &RunSummary{
|
return &RunSummary{
|
||||||
SessionID: cfg.Session.SessionID,
|
SessionID: cfg.Session.SessionID,
|
||||||
@@ -117,7 +116,7 @@ inputs:
|
|||||||
`)
|
`)
|
||||||
origExecuteStagesFn := executeStagesFn
|
origExecuteStagesFn := executeStagesFn
|
||||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||||
executeStagesFn = func(_ context.Context, cfg *config.Config, _ []stage.Stage, _ RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
|
||||||
return &RunSummary{
|
return &RunSummary{
|
||||||
SessionID: cfg.Session.SessionID,
|
SessionID: cfg.Session.SessionID,
|
||||||
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
|
ManifestPath: filepath.Join(workspaceRoot, "manifest.json"),
|
||||||
@@ -194,8 +193,8 @@ func TestExecuteWorkflowCommandsAcceptPositionalSessionID(t *testing.T) {
|
|||||||
var capturedArtifacts []string
|
var capturedArtifacts []string
|
||||||
origExecuteStagesFn := executeStagesFn
|
origExecuteStagesFn := executeStagesFn
|
||||||
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
t.Cleanup(func() { executeStagesFn = origExecuteStagesFn })
|
||||||
executeStagesFn = func(_ context.Context, _ *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
|
executeStagesFn = func(_ context.Context, _ *config.Config, plan BoundedPlan, opts RunOptions) (*RunSummary, error) {
|
||||||
for _, s := range stages {
|
for _, s := range plan.Stages() {
|
||||||
capturedStages = append(capturedStages, s.Name())
|
capturedStages = append(capturedStages, s.Name())
|
||||||
}
|
}
|
||||||
capturedForce = opts.Force
|
capturedForce = opts.Force
|
||||||
@@ -257,7 +256,7 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "plan",
|
name: "plan",
|
||||||
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||||
want: "narratio session plan: workdir prepared",
|
want: "narratio session plan: read-only workdir",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "artifacts",
|
name: "artifacts",
|
||||||
|
|||||||
17
internal/app/version.go
Normal file
17
internal/app/version.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/buildinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version prints the version embedded in the current Narratio binary.
|
||||||
|
func Version(args []string, out io.Writer) error {
|
||||||
|
if len(args) != 0 {
|
||||||
|
return fmt.Errorf("version: unexpected arguments")
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintf(out, "narratio %s\n", buildinfo.Version)
|
||||||
|
return err
|
||||||
|
}
|
||||||
38
internal/app/version_test.go
Normal file
38
internal/app/version_test.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/buildinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExecuteVersionReportsEmbeddedBuildVersion(t *testing.T) {
|
||||||
|
original := buildinfo.Version
|
||||||
|
buildinfo.Version = "v1.5.0-test"
|
||||||
|
t.Cleanup(func() { buildinfo.Version = original })
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute([]string{"version"}, &stdout, &stderr); code != 0 {
|
||||||
|
t.Fatalf("Execute() code = %d, stderr = %q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if got, want := stdout.String(), "narratio v1.5.0-test\n"; got != want {
|
||||||
|
t.Fatalf("stdout = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if stderr.Len() != 0 {
|
||||||
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteVersionRejectsArguments(t *testing.T) {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
if code := Execute([]string{"version", "extra"}, io.Discard, &stderr); code == 0 {
|
||||||
|
t.Fatal("Execute() code = 0, want failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "version: unexpected arguments") {
|
||||||
|
t.Fatalf("stderr = %q, want argument error", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@ import (
|
|||||||
const (
|
const (
|
||||||
SourceBoundsSession = "narratio.bounds.session"
|
SourceBoundsSession = "narratio.bounds.session"
|
||||||
|
|
||||||
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": {}}
|
||||||
|
|
||||||
|
|||||||
144
internal/artifacts/analyze_evidence.go
Normal file
144
internal/artifacts/analyze_evidence.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnalyzeEvidenceState distinguishes a verified current configured artifact
|
||||||
|
// from every form of unavailable evidence.
|
||||||
|
type AnalyzeEvidenceState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AnalyzeEvidenceCurrent AnalyzeEvidenceState = "current"
|
||||||
|
AnalyzeEvidenceNonCurrent AnalyzeEvidenceState = "non_current"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnalyzeEvidence is the read-only result of inspecting one configured
|
||||||
|
// artifact's manifest record and durable output.
|
||||||
|
type AnalyzeEvidence struct {
|
||||||
|
State AnalyzeEvidenceState
|
||||||
|
Reason string
|
||||||
|
SourceID string
|
||||||
|
Path string
|
||||||
|
ProducerRunID string
|
||||||
|
Contract *artifactmodel.ContractMetadata
|
||||||
|
Checksum string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectAnalyzeEvidence verifies that one configured artifact has supported,
|
||||||
|
// current manifest evidence for the exact canonical bytes on disk.
|
||||||
|
func InspectAnalyzeEvidence(
|
||||||
|
paths SessionPaths,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
key string,
|
||||||
|
configured ConfiguredArtifactDefinition,
|
||||||
|
) AnalyzeEvidence {
|
||||||
|
normalizedKey := strings.TrimSpace(key)
|
||||||
|
sourceID := ConfiguredArtifactSourceID(normalizedKey)
|
||||||
|
nonCurrent := func(reason string) AnalyzeEvidence {
|
||||||
|
return AnalyzeEvidence{State: AnalyzeEvidenceNonCurrent, Reason: reason, SourceID: sourceID}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m == nil {
|
||||||
|
return nonCurrent("analyze manifest evidence is absent; regenerate the artifact")
|
||||||
|
}
|
||||||
|
stageRecord := m.Stages["analyze"]
|
||||||
|
if stageRecord == nil || stageRecord.Name != "analyze" {
|
||||||
|
return nonCurrent("analyze manifest evidence is absent; regenerate the artifact")
|
||||||
|
}
|
||||||
|
if !stageRecord.HasVersionedAnalyzeState() {
|
||||||
|
return nonCurrent("analyze manifest evidence is legacy or unsupported; regenerate the artifact")
|
||||||
|
}
|
||||||
|
record, ok := stageRecord.AnalyzeArtifacts[normalizedKey]
|
||||||
|
if !ok {
|
||||||
|
return nonCurrent("configured artifact has no analyze manifest record; regenerate the artifact")
|
||||||
|
}
|
||||||
|
if record.Status != manifest.AnalyzeArtifactCurrent {
|
||||||
|
return nonCurrent(fmt.Sprintf("configured artifact manifest status is %q; regenerate the artifact", record.Status))
|
||||||
|
}
|
||||||
|
if err := manifest.ValidateAnalyzeArtifactCollection(
|
||||||
|
stageRecord.AnalyzeStateVersion,
|
||||||
|
map[string]manifest.AnalyzeArtifactRecord{normalizedKey: record},
|
||||||
|
); err != nil {
|
||||||
|
return nonCurrent("configured artifact manifest evidence is malformed; regenerate the artifact")
|
||||||
|
}
|
||||||
|
|
||||||
|
configuredPath, err := pathsafe.NormalizeRelativeDestination(strings.TrimSpace(configured.OutputPath))
|
||||||
|
if err != nil {
|
||||||
|
return nonCurrent("configured artifact output path is unsafe; correct the configuration")
|
||||||
|
}
|
||||||
|
if record.Output.LocalPath != configuredPath {
|
||||||
|
return nonCurrent("configured artifact manifest path differs from current configuration; regenerate the artifact")
|
||||||
|
}
|
||||||
|
if record.Output.SourceID != sourceID || record.Output.Kind != "scriptorium_artifact" {
|
||||||
|
return nonCurrent("configured artifact manifest identity is incompatible; regenerate the artifact")
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := fileops.OpenConfinedRegularFile(paths.Root, configuredPath)
|
||||||
|
if err != nil {
|
||||||
|
return nonCurrent("configured artifact output is missing or unsafe; regenerate the artifact")
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil || !info.Mode().IsRegular() {
|
||||||
|
return nonCurrent("configured artifact output is not a safe regular file; regenerate the artifact")
|
||||||
|
}
|
||||||
|
hash := sha256.New()
|
||||||
|
size, err := io.Copy(hash, file)
|
||||||
|
if err != nil {
|
||||||
|
return nonCurrent("configured artifact output could not be verified; regenerate the artifact")
|
||||||
|
}
|
||||||
|
if size != info.Size() || size != record.OutputSize {
|
||||||
|
return nonCurrent("configured artifact output size differs from manifest evidence; regenerate the artifact")
|
||||||
|
}
|
||||||
|
if hex.EncodeToString(hash.Sum(nil)) != record.Output.Checksum {
|
||||||
|
return nonCurrent("configured artifact output checksum differs from manifest evidence; regenerate the artifact")
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnalyzeEvidence{
|
||||||
|
State: AnalyzeEvidenceCurrent,
|
||||||
|
SourceID: sourceID,
|
||||||
|
Path: filepath.Join(paths.Root, filepath.FromSlash(configuredPath)),
|
||||||
|
ProducerRunID: record.ProducerRunID,
|
||||||
|
Contract: cloneArtifactContract(record.Output.Contract),
|
||||||
|
Checksum: record.Output.Checksum,
|
||||||
|
Size: record.OutputSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HydrateAnalyzeArtifacts makes configured sources available only from
|
||||||
|
// validated current manifest evidence. It never mutates manifest state.
|
||||||
|
func (c *ArtifactCatalog) HydrateAnalyzeArtifacts(
|
||||||
|
paths SessionPaths,
|
||||||
|
m *manifest.Manifest,
|
||||||
|
configured map[string]ConfiguredArtifactDefinition,
|
||||||
|
) {
|
||||||
|
if c == nil || len(configured) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range c.ListConfigured() {
|
||||||
|
definition, ok := configured[entry.ConfiguredKey]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
evidence := InspectAnalyzeEvidence(paths, m, entry.ConfiguredKey, definition)
|
||||||
|
if evidence.State != AnalyzeEvidenceCurrent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = c.markAvailableFromAnalyzeManifest(
|
||||||
|
evidence.SourceID, evidence.Path, evidence.ProducerRunID,
|
||||||
|
evidence.Checksum, evidence.Size, evidence.Contract,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
234
internal/artifacts/analyze_evidence_test.go
Normal file
234
internal/artifacts/analyze_evidence_test.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package artifacts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInspectAnalyzeEvidenceAcceptsCurrentCanonicalOutput(t *testing.T) {
|
||||||
|
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
|
||||||
|
body := []byte("recap\n")
|
||||||
|
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", body)
|
||||||
|
|
||||||
|
got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"})
|
||||||
|
if got.State != AnalyzeEvidenceCurrent {
|
||||||
|
t.Fatalf("State = %q, reason = %q", got.State, got.Reason)
|
||||||
|
}
|
||||||
|
if got.SourceID != ConfiguredArtifactSourceID("session_recap") || got.Path != filepath.Join(paths.ArtifactsDir, "session_recap.md") || got.ProducerRunID != "run-1" {
|
||||||
|
t.Fatalf("evidence = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectAnalyzeEvidenceRejectsNonCurrentAndInvalidEvidence(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*manifest.Manifest)
|
||||||
|
config ConfiguredArtifactDefinition
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{name: "absent manifest", mutate: func(m *manifest.Manifest) { *m = manifest.Manifest{} }, reason: "absent"},
|
||||||
|
{name: "legacy", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeStateVersion = 0
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts = nil
|
||||||
|
}, reason: "legacy"},
|
||||||
|
{name: "unsupported version", mutate: func(m *manifest.Manifest) { m.Stages["analyze"].AnalyzeStateVersion++ }, reason: "legacy or unsupported"},
|
||||||
|
{name: "missing record", mutate: func(m *manifest.Manifest) { delete(m.Stages["analyze"].AnalyzeArtifacts, "session_recap") }, reason: "no analyze manifest record"},
|
||||||
|
{name: "stale", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactStale), reason: `status is "stale"`},
|
||||||
|
{name: "missing", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactMissing), reason: `status is "missing"`},
|
||||||
|
{name: "failed", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactFailed), reason: `status is "failed"`},
|
||||||
|
{name: "unselected", mutate: analyzeEvidenceStatus(manifest.AnalyzeArtifactUnselected), reason: `status is "unselected"`},
|
||||||
|
{name: "fingerprint version", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.FingerprintVersion++ })
|
||||||
|
}, reason: "malformed"},
|
||||||
|
{name: "key mismatch", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Key = "other" })
|
||||||
|
}, reason: "malformed"},
|
||||||
|
{name: "source mismatch", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.SourceID = ConfiguredArtifactSourceID("other") })
|
||||||
|
}, reason: "malformed"},
|
||||||
|
{name: "missing contract", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Contract = nil })
|
||||||
|
}, reason: "malformed"},
|
||||||
|
{name: "configured path mismatch", config: ConfiguredArtifactDefinition{OutputPath: "artifacts/renamed.md"}, reason: "differs from current configuration"},
|
||||||
|
{name: "record path mismatch", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.LocalPath = "artifacts/other.md" })
|
||||||
|
}, reason: "differs from current configuration"},
|
||||||
|
{name: "size mismatch", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.OutputSize++ })
|
||||||
|
}, reason: "size differs"},
|
||||||
|
{name: "checksum mismatch", mutate: func(m *manifest.Manifest) {
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = mutateAnalyzeEvidenceRecord(m, func(r *manifest.AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("0", 64) })
|
||||||
|
}, reason: "checksum differs"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
|
||||||
|
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
|
||||||
|
if test.mutate != nil {
|
||||||
|
test.mutate(m)
|
||||||
|
}
|
||||||
|
definition := test.config
|
||||||
|
if definition.OutputPath == "" {
|
||||||
|
definition.OutputPath = "artifacts/session_recap.md"
|
||||||
|
}
|
||||||
|
got := InspectAnalyzeEvidence(paths, m, "session_recap", definition)
|
||||||
|
if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, test.reason) {
|
||||||
|
t.Fatalf("evidence = %#v, want non-current reason containing %q", got, test.reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectAnalyzeEvidenceRejectsMissingAndUnsafeFiles(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
alter func(t *testing.T, paths SessionPaths, outputPath string)
|
||||||
|
}{
|
||||||
|
{name: "missing", alter: func(t *testing.T, _ SessionPaths, outputPath string) {
|
||||||
|
if err := os.Remove(outputPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "directory", alter: func(t *testing.T, _ SessionPaths, outputPath string) {
|
||||||
|
if err := os.Remove(outputPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(outputPath, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "symlink leaf", alter: func(t *testing.T, paths SessionPaths, outputPath string) {
|
||||||
|
if err := os.Remove(outputPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(paths.Root, "target.md")
|
||||||
|
if err := os.WriteFile(target, []byte("recap\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, outputPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
{name: "symlink ancestor", alter: func(t *testing.T, paths SessionPaths, outputPath string) {
|
||||||
|
if err := os.RemoveAll(paths.ArtifactsDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outside := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(outside, "session_recap.md"), []byte("recap\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outside, paths.ArtifactsDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
|
||||||
|
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
|
||||||
|
outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md")
|
||||||
|
test.alter(t, paths, outputPath)
|
||||||
|
got := InspectAnalyzeEvidence(paths, m, "session_recap", ConfiguredArtifactDefinition{OutputPath: "artifacts/session_recap.md"})
|
||||||
|
if got.State != AnalyzeEvidenceNonCurrent || !strings.Contains(got.Reason, "missing or unsafe") {
|
||||||
|
t.Fatalf("evidence = %#v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHydrateAnalyzeArtifactsUsesOnlyCurrentConfiguredKeys(t *testing.T) {
|
||||||
|
paths := buildSessionPaths(t.TempDir(), "campaign", "session")
|
||||||
|
m := analyzeEvidenceFixture(t, paths, "session_recap", "artifacts/session_recap.md", []byte("recap\n"))
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["removed"] = analyzeEvidenceRecord(t, paths, "removed", "artifacts/removed.md", []byte("old\n"))
|
||||||
|
configured := map[string]ConfiguredArtifactDefinition{"session_recap": {OutputPath: "artifacts/session_recap.md"}}
|
||||||
|
catalog := NewArtifactCatalog()
|
||||||
|
if err := catalog.RegisterConfiguredArtifacts(configured, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
|
entry, _ := catalog.Lookup(ConfiguredArtifactSourceID("session_recap"))
|
||||||
|
if !entry.Available || entry.Provenance != ArtifactProvenanceCurrentAnalyzeManifest || entry.ProducerRunID != "run-1" {
|
||||||
|
t.Fatalf("entry = %#v", entry)
|
||||||
|
}
|
||||||
|
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
|
||||||
|
if entry.Checksum != record.Output.Checksum || entry.Size != record.OutputSize {
|
||||||
|
t.Fatalf("entry content identity = (%q, %d), want (%q, %d)", entry.Checksum, entry.Size, record.Output.Checksum, record.OutputSize)
|
||||||
|
}
|
||||||
|
if entry.Contract == nil || *entry.Contract != *record.Output.Contract {
|
||||||
|
t.Fatalf("entry contract = %#v, want %#v", entry.Contract, record.Output.Contract)
|
||||||
|
}
|
||||||
|
entry.Contract.SchemaVersion = "mutated"
|
||||||
|
again, _ := catalog.Lookup(ConfiguredArtifactSourceID("session_recap"))
|
||||||
|
if again.Contract == nil || again.Contract.SchemaVersion != "1" {
|
||||||
|
t.Fatalf("catalog contract was mutated through lookup: %#v", again.Contract)
|
||||||
|
}
|
||||||
|
if _, ok := catalog.Lookup(ConfiguredArtifactSourceID("removed")); ok {
|
||||||
|
t.Fatal("removed manifest record was advertised in current catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeEvidenceFixture(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) *manifest.Manifest {
|
||||||
|
t.Helper()
|
||||||
|
record := analyzeEvidenceRecord(t, paths, key, relativePath, body)
|
||||||
|
now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
m := manifest.New(paths.SessionID, now)
|
||||||
|
m.Stages["analyze"] = &manifest.StageRecord{
|
||||||
|
Name: "analyze", Status: manifest.StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||||
|
AnalyzeStateVersion: manifest.AnalyzeStateContractVersion,
|
||||||
|
AnalyzeArtifacts: map[string]manifest.AnalyzeArtifactRecord{key: record},
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeEvidenceRecord(t *testing.T, paths SessionPaths, key, relativePath string, body []byte) manifest.AnalyzeArtifactRecord {
|
||||||
|
t.Helper()
|
||||||
|
outputPath := filepath.Join(paths.Root, filepath.FromSlash(relativePath))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(outputPath, body, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
checksum, err := SHA256File(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
now := time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC)
|
||||||
|
return manifest.AnalyzeArtifactRecord{
|
||||||
|
Key: key, Status: manifest.AnalyzeArtifactCurrent,
|
||||||
|
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
|
||||||
|
Fingerprint: strings.Repeat("1", 64),
|
||||||
|
Output: &manifest.ArtifactRecord{
|
||||||
|
Kind: "scriptorium_artifact", SourceID: ConfiguredArtifactSourceID(key), LocalPath: relativePath,
|
||||||
|
Contract: &artifactmodel.ContractMetadata{MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1"},
|
||||||
|
ProducerRunID: "run-1", Checksum: checksum,
|
||||||
|
},
|
||||||
|
OutputSize: int64(len(body)), ProducerRunID: "run-1", UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeEvidenceStatus(status manifest.AnalyzeArtifactStatus) func(*manifest.Manifest) {
|
||||||
|
return func(m *manifest.Manifest) {
|
||||||
|
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
|
||||||
|
record.Status = status
|
||||||
|
record.Output = nil
|
||||||
|
record.OutputSize = 0
|
||||||
|
if status == manifest.AnalyzeArtifactFailed {
|
||||||
|
record.Error = "generation failed"
|
||||||
|
}
|
||||||
|
m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mutateAnalyzeEvidenceRecord(m *manifest.Manifest, mutate func(*manifest.AnalyzeArtifactRecord)) manifest.AnalyzeArtifactRecord {
|
||||||
|
record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"]
|
||||||
|
mutate(&record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
@@ -107,6 +107,9 @@ type ResolvedSessionArtifact struct {
|
|||||||
OutputKind string
|
OutputKind string
|
||||||
ProducerRunID string
|
ProducerRunID string
|
||||||
Provenance string
|
Provenance string
|
||||||
|
Contract *artifactmodel.ContractMetadata
|
||||||
|
Checksum string
|
||||||
|
Size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionArtifactNotFoundError includes context when a known artifact cannot be read.
|
// SessionArtifactNotFoundError includes context when a known artifact cannot be read.
|
||||||
@@ -259,6 +262,9 @@ func ResolveSessionArtifactWithCatalog(paths SessionPaths, m *manifest.Manifest,
|
|||||||
OutputKind: entry.OutputKind,
|
OutputKind: entry.OutputKind,
|
||||||
ProducerRunID: entry.ProducerRunID,
|
ProducerRunID: entry.ProducerRunID,
|
||||||
Provenance: entry.Provenance,
|
Provenance: entry.Provenance,
|
||||||
|
Contract: cloneArtifactContract(entry.Contract),
|
||||||
|
Checksum: entry.Checksum,
|
||||||
|
Size: entry.Size,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableGenerated(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing.T) {
|
func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromManifest(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
paths := buildSessionPaths(workspace, "campaign", "session")
|
paths := buildSessionPaths(workspace, "campaign", "session")
|
||||||
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
outputPath := filepath.Join(paths.ArtifactsDir, "player_handout.md")
|
||||||
@@ -385,16 +385,17 @@ func TestResolveSessionArtifactWithCatalogConfiguredAvailableFromDisk(t *testing
|
|||||||
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
||||||
}
|
}
|
||||||
sourceID := ConfiguredArtifactSourceID("player_handout")
|
sourceID := ConfiguredArtifactSourceID("player_handout")
|
||||||
if err := catalog.MarkAvailableFromDisk(sourceID, outputPath); err != nil {
|
m := analyzeEvidenceFixture(t, paths, "player_handout", "artifacts/player_handout.md", []byte("handout\n"))
|
||||||
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
|
catalog.HydrateAnalyzeArtifacts(paths, m, map[string]ConfiguredArtifactDefinition{
|
||||||
}
|
"player_handout": {Enabled: false, OutputPath: "artifacts/player_handout.md"},
|
||||||
|
})
|
||||||
|
|
||||||
resolved, err := ResolveSessionArtifactWithCatalog(paths, nil, sourceID, catalog)
|
resolved, err := ResolveSessionArtifactWithCatalog(paths, m, sourceID, catalog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||||
}
|
}
|
||||||
if resolved.Provenance != ArtifactProvenanceDisabledFromDisk {
|
if resolved.Provenance != ArtifactProvenanceCurrentAnalyzeManifest {
|
||||||
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceDisabledFromDisk)
|
t.Fatalf("provenance = %q, want %q", resolved.Provenance, ArtifactProvenanceCurrentAnalyzeManifest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
|
ArtifactProvenanceGeneratedCurrentAnalyzeRun = "generated.current_analyze_run"
|
||||||
ArtifactProvenanceDisabledFromDisk = "filesystem.disabled_artifact_output"
|
ArtifactProvenanceCurrentAnalyzeManifest = "manifest.current_analyze_artifact"
|
||||||
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
|
ArtifactProvenanceCurrentExtractManifest = "manifest.current_extract_run"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +65,9 @@ type CatalogEntry struct {
|
|||||||
Path string
|
Path string
|
||||||
Provenance string
|
Provenance string
|
||||||
ProducerRunID string
|
ProducerRunID string
|
||||||
|
Contract *artifactmodel.ContractMetadata
|
||||||
|
Checksum string
|
||||||
|
Size int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactCatalog tracks built-in, configured, and extraction artifact definitions and runtime state.
|
// ArtifactCatalog tracks built-in, configured, and extraction artifact definitions and runtime state.
|
||||||
@@ -98,6 +102,7 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
|
|||||||
if _, exists := c.extractionIndex[trimmed]; exists {
|
if _, exists := c.extractionIndex[trimmed]; exists {
|
||||||
return fmt.Errorf("duplicate extraction artifact key %q", trimmed)
|
return fmt.Errorf("duplicate extraction artifact key %q", trimmed)
|
||||||
}
|
}
|
||||||
|
def := configured[key]
|
||||||
sourceID := ExtractionArtifactSourceID(trimmed)
|
sourceID := ExtractionArtifactSourceID(trimmed)
|
||||||
if err := c.addEntry(CatalogEntry{
|
if err := c.addEntry(CatalogEntry{
|
||||||
SourceID: sourceID,
|
SourceID: sourceID,
|
||||||
@@ -105,6 +110,10 @@ func (c *ArtifactCatalog) RegisterExtractionArtifacts(configured map[string]Extr
|
|||||||
ProducerStage: "extract",
|
ProducerStage: "extract",
|
||||||
OutputKind: "notarius_lane",
|
OutputKind: "notarius_lane",
|
||||||
Planned: true,
|
Planned: true,
|
||||||
|
Contract: &artifactmodel.ContractMetadata{
|
||||||
|
MediaType: def.MediaType, SchemaID: def.SchemaID,
|
||||||
|
SchemaVersion: def.SchemaVersion, ModuleKey: def.ModuleKey,
|
||||||
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("register extraction artifact %q: %w", trimmed, err)
|
return fmt.Errorf("register extraction artifact %q: %w", trimmed, err)
|
||||||
}
|
}
|
||||||
@@ -221,7 +230,7 @@ func (c *ArtifactCatalog) Lookup(sourceID string) (CatalogEntry, bool) {
|
|||||||
return CatalogEntry{}, false
|
return CatalogEntry{}, false
|
||||||
}
|
}
|
||||||
entry, ok := c.entries[strings.TrimSpace(sourceID)]
|
entry, ok := c.entries[strings.TrimSpace(sourceID)]
|
||||||
return entry, ok
|
return cloneCatalogEntry(entry), ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceIDForConfiguredKey returns canonical source ID for one configured key.
|
// SourceIDForConfiguredKey returns canonical source ID for one configured key.
|
||||||
@@ -255,7 +264,7 @@ func (c *ArtifactCatalog) ListConfigured() []CatalogEntry {
|
|||||||
out := make([]CatalogEntry, 0, len(keys))
|
out := make([]CatalogEntry, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
sourceID := c.configuredIndex[key]
|
sourceID := c.configuredIndex[key]
|
||||||
out = append(out, c.entries[sourceID])
|
out = append(out, cloneCatalogEntry(c.entries[sourceID]))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -272,7 +281,7 @@ func (c *ArtifactCatalog) ListExtraction() []CatalogEntry {
|
|||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
out := make([]CatalogEntry, 0, len(keys))
|
out := make([]CatalogEntry, 0, len(keys))
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
out = append(out, c.entries[c.extractionIndex[key]])
|
out = append(out, cloneCatalogEntry(c.entries[c.extractionIndex[key]]))
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -282,17 +291,71 @@ func (c *ArtifactCatalog) MarkAvailableGenerated(sourceID, path string) error {
|
|||||||
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
|
return c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkAvailableFromDisk marks one source as available from disabled artifact on disk.
|
// MarkAvailableGeneratedEvidence marks one source as available from the
|
||||||
func (c *ArtifactCatalog) MarkAvailableFromDisk(sourceID, path string) error {
|
// current analyze invocation and retains the semantic output identity needed
|
||||||
return c.markAvailable(sourceID, path, ArtifactProvenanceDisabledFromDisk)
|
// by later scheduled dependents.
|
||||||
|
func (c *ArtifactCatalog) MarkAvailableGeneratedEvidence(
|
||||||
|
sourceID, path, producerRunID, checksum string,
|
||||||
|
size int64,
|
||||||
|
contract *artifactmodel.ContractMetadata,
|
||||||
|
) error {
|
||||||
|
producerRunID = strings.TrimSpace(producerRunID)
|
||||||
|
checksum = strings.TrimSpace(checksum)
|
||||||
|
if err := ValidateRunIdentity(producerRunID); err != nil {
|
||||||
|
return fmt.Errorf("generated artifact producer run id: %w", err)
|
||||||
|
}
|
||||||
|
if err := validateSHA256(checksum); err != nil {
|
||||||
|
return fmt.Errorf("generated artifact checksum: %w", err)
|
||||||
|
}
|
||||||
|
if size <= 0 {
|
||||||
|
return fmt.Errorf("generated artifact size must be positive")
|
||||||
|
}
|
||||||
|
if contract == nil || strings.TrimSpace(contract.MediaType) == "" ||
|
||||||
|
strings.TrimSpace(contract.SchemaID) == "" || strings.TrimSpace(contract.SchemaVersion) == "" {
|
||||||
|
return fmt.Errorf("generated artifact contract is incomplete")
|
||||||
|
}
|
||||||
|
if err := c.markAvailable(sourceID, path, ArtifactProvenanceGeneratedCurrentAnalyzeRun); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||||
|
entry.ProducerRunID = producerRunID
|
||||||
|
entry.Checksum = checksum
|
||||||
|
entry.Size = size
|
||||||
|
entry.Contract = cloneArtifactContract(contract)
|
||||||
|
c.entries[entry.SourceID] = entry
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ArtifactCatalog) markAvailableFromExtractManifest(sourceID, path, producerRunID string) error {
|
func (c *ArtifactCatalog) markAvailableFromExtractManifest(
|
||||||
|
sourceID, path, producerRunID, checksum string,
|
||||||
|
size int64,
|
||||||
|
contract *artifactmodel.ContractMetadata,
|
||||||
|
) error {
|
||||||
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
|
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentExtractManifest); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
entry := c.entries[strings.TrimSpace(sourceID)]
|
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||||
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
||||||
|
entry.Checksum = strings.TrimSpace(checksum)
|
||||||
|
entry.Size = size
|
||||||
|
entry.Contract = cloneArtifactContract(contract)
|
||||||
|
c.entries[entry.SourceID] = entry
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ArtifactCatalog) markAvailableFromAnalyzeManifest(
|
||||||
|
sourceID, path, producerRunID, checksum string,
|
||||||
|
size int64,
|
||||||
|
contract *artifactmodel.ContractMetadata,
|
||||||
|
) error {
|
||||||
|
if err := c.markAvailable(sourceID, path, ArtifactProvenanceCurrentAnalyzeManifest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry := c.entries[strings.TrimSpace(sourceID)]
|
||||||
|
entry.ProducerRunID = strings.TrimSpace(producerRunID)
|
||||||
|
entry.Checksum = strings.TrimSpace(checksum)
|
||||||
|
entry.Size = size
|
||||||
|
entry.Contract = cloneArtifactContract(contract)
|
||||||
c.entries[entry.SourceID] = entry
|
c.entries[entry.SourceID] = entry
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -313,10 +376,29 @@ func (c *ArtifactCatalog) markAvailable(sourceID, path, provenance string) error
|
|||||||
entry.Available = true
|
entry.Available = true
|
||||||
entry.Path = trimmedPath
|
entry.Path = trimmedPath
|
||||||
entry.Provenance = provenance
|
entry.Provenance = provenance
|
||||||
|
entry.ProducerRunID = ""
|
||||||
|
entry.Checksum = ""
|
||||||
|
entry.Size = 0
|
||||||
|
if provenance == ArtifactProvenanceGeneratedCurrentAnalyzeRun {
|
||||||
|
entry.Contract = nil
|
||||||
|
}
|
||||||
c.entries[normalizedID] = entry
|
c.entries[normalizedID] = entry
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneCatalogEntry(entry CatalogEntry) CatalogEntry {
|
||||||
|
entry.Contract = cloneArtifactContract(entry.Contract)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneArtifactContract(contract *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
|
||||||
|
if contract == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone := *contract
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
func (c *ArtifactCatalog) addEntry(entry CatalogEntry) error {
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return fmt.Errorf("artifact catalog is nil")
|
return fmt.Errorf("artifact catalog is nil")
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package artifacts
|
package artifacts
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
)
|
||||||
|
|
||||||
func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
|
func TestArtifactCatalogRegisterBuiltInsAndLookup(t *testing.T) {
|
||||||
catalog := NewArtifactCatalog()
|
catalog := NewArtifactCatalog()
|
||||||
@@ -179,27 +184,27 @@ func TestArtifactCatalogMarkAvailableGenerated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestArtifactCatalogMarkAvailableFromDisk(t *testing.T) {
|
func TestArtifactCatalogMarkAvailableGeneratedEvidence(t *testing.T) {
|
||||||
catalog := NewArtifactCatalog()
|
catalog := NewArtifactCatalog()
|
||||||
if err := catalog.RegisterConfiguredArtifacts(
|
if err := catalog.RegisterConfiguredArtifacts(map[string]ConfiguredArtifactDefinition{
|
||||||
map[string]ConfiguredArtifactDefinition{
|
"session_recap": {Enabled: true, OutputPath: "artifacts/session_recap.md"},
|
||||||
"session_recap": {Enabled: false, OutputPath: "artifacts/session_recap.md"},
|
}); err != nil {
|
||||||
},
|
t.Fatal(err)
|
||||||
nil,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("RegisterConfiguredArtifacts() error = %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceID, _ := catalog.SourceIDForConfiguredKey("session_recap")
|
sourceID, _ := catalog.SourceIDForConfiguredKey("session_recap")
|
||||||
if err := catalog.MarkAvailableFromDisk(sourceID, "/tmp/session_recap.md"); err != nil {
|
contract := &artifactmodel.ContractMetadata{
|
||||||
t.Fatalf("MarkAvailableFromDisk() error = %v", err)
|
MediaType: "text/markdown", SchemaID: "narratio.session_recap", SchemaVersion: "1",
|
||||||
|
}
|
||||||
|
checksum := strings.Repeat("a", 64)
|
||||||
|
if err := catalog.MarkAvailableGeneratedEvidence(
|
||||||
|
sourceID, "/tmp/session_recap.md", "run-1", checksum, 42, contract,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
entry, _ := catalog.Lookup(sourceID)
|
entry, _ := catalog.Lookup(sourceID)
|
||||||
if !entry.Available {
|
if !entry.Available || entry.ProducerRunID != "run-1" || entry.Checksum != checksum || entry.Size != 42 ||
|
||||||
t.Fatalf("entry.Available = false, want true")
|
entry.Contract == nil || *entry.Contract != *contract {
|
||||||
}
|
t.Fatalf("generated evidence entry = %#v", entry)
|
||||||
if entry.Provenance != ArtifactProvenanceDisabledFromDisk {
|
|
||||||
t.Fatalf("entry.Provenance = %q, want %q", entry.Provenance, ArtifactProvenanceDisabledFromDisk)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,11 @@ func (c *ArtifactCatalog) HydrateExtractionArtifacts(
|
|||||||
if proof.State != ExtractionEvidenceValid {
|
if proof.State != ExtractionEvidenceValid {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for sourceID, path := range proof.Outputs {
|
for sourceID, output := range proof.Outputs {
|
||||||
_ = c.markAvailableFromExtractManifest(sourceID, path, proof.ProducerRunID)
|
_ = c.markAvailableFromExtractManifest(
|
||||||
|
sourceID, output.Path, proof.ProducerRunID,
|
||||||
|
output.Checksum, output.Size, output.Contract,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ func TestHydrateExtractionArtifactsAcceptsOnlyCompleteCurrentBundle(t *testing.T
|
|||||||
if entry.Provenance != ArtifactProvenanceCurrentExtractManifest || entry.ProducerRunID != "extract-run-1" {
|
if entry.Provenance != ArtifactProvenanceCurrentExtractManifest || entry.ProducerRunID != "extract-run-1" {
|
||||||
t.Fatalf("hydrated provenance = %#v", entry)
|
t.Fatalf("hydrated provenance = %#v", entry)
|
||||||
}
|
}
|
||||||
|
wantOutput := currentManifest.Stages["extract"].Outputs[0]
|
||||||
|
info, err := os.Stat(wantOutput.LocalPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if entry.Checksum != wantOutput.Checksum || entry.Size != info.Size() {
|
||||||
|
t.Fatalf("hydrated content identity = (%q, %d), want (%q, %d)", entry.Checksum, entry.Size, wantOutput.Checksum, info.Size())
|
||||||
|
}
|
||||||
|
if entry.Contract == nil || *entry.Contract != *wantOutput.Contract {
|
||||||
|
t.Fatalf("hydrated contract = %#v, want %#v", entry.Contract, wantOutput.Contract)
|
||||||
|
}
|
||||||
resolved, err := ResolveSessionArtifactWithCatalog(paths, currentManifest, entry.SourceID, catalog)
|
resolved, err := ResolveSessionArtifactWithCatalog(paths, currentManifest, entry.SourceID, catalog)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
t.Fatalf("ResolveSessionArtifactWithCatalog() error = %v", err)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
)
|
)
|
||||||
@@ -23,7 +24,15 @@ const (
|
|||||||
type ExtractionEvidence struct {
|
type ExtractionEvidence struct {
|
||||||
State ExtractionEvidenceState
|
State ExtractionEvidenceState
|
||||||
Reason, ProducerRunID string
|
Reason, ProducerRunID string
|
||||||
Outputs map[string]string
|
Outputs map[string]ExtractionEvidenceOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractionEvidenceOutput is the verified semantic identity of one lane.
|
||||||
|
type ExtractionEvidenceOutput struct {
|
||||||
|
Path string
|
||||||
|
Checksum string
|
||||||
|
Size int64
|
||||||
|
Contract *artifactmodel.ContractMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// InspectExtractionEvidence verifies structure, confinement, identities, contracts, and payload bytes.
|
// InspectExtractionEvidence verifies structure, confinement, identities, contracts, and payload bytes.
|
||||||
@@ -72,7 +81,7 @@ func InspectExtractionEvidence(
|
|||||||
for key, d := range configured {
|
for key, d := range configured {
|
||||||
expected[ExtractionArtifactSourceID(key)] = d
|
expected[ExtractionArtifactSourceID(key)] = d
|
||||||
}
|
}
|
||||||
seen, outputs := map[string]struct{}{}, map[string]string{}
|
seen, outputs := map[string]struct{}{}, map[string]ExtractionEvidenceOutput{}
|
||||||
indexSeen := false
|
indexSeen := false
|
||||||
for _, out := range r.Outputs {
|
for _, out := range r.Outputs {
|
||||||
if strings.TrimSpace(out.ProducerRunID) != runID {
|
if strings.TrimSpace(out.ProducerRunID) != runID {
|
||||||
@@ -82,7 +91,7 @@ func InspectExtractionEvidence(
|
|||||||
if indexSeen || out.Kind != extractionIndexKind || filepath.Clean(out.LocalPath) != filepath.Join(root, "index.json") {
|
if indexSeen || out.Kind != extractionIndexKind || filepath.Clean(out.LocalPath) != filepath.Join(root, "index.json") {
|
||||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract index path is not canonical"}
|
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract index path is not canonical"}
|
||||||
}
|
}
|
||||||
if state, reason := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
|
if state, reason, _ := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
|
||||||
return ExtractionEvidence{State: state, Reason: reason}
|
return ExtractionEvidence{State: state, Reason: reason}
|
||||||
}
|
}
|
||||||
indexSeen = true
|
indexSeen = true
|
||||||
@@ -98,11 +107,15 @@ func InspectExtractionEvidence(
|
|||||||
if !compatibleCatalogExtractionContract(out.Contract, d) || !compatibleCatalogExtractionProvenance(out.ExternalProvenance, receiptRunID, receiptPipelineID, d) {
|
if !compatibleCatalogExtractionContract(out.Contract, d) || !compatibleCatalogExtractionProvenance(out.ExternalProvenance, receiptRunID, receiptPipelineID, d) {
|
||||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract output contract or provenance is incompatible"}
|
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract output contract or provenance is incompatible"}
|
||||||
}
|
}
|
||||||
if state, reason := inspectExtractionPayload(root, out.LocalPath, out.Checksum); state != ExtractionEvidenceValid {
|
state, reason, size := inspectExtractionPayload(root, out.LocalPath, out.Checksum)
|
||||||
|
if state != ExtractionEvidenceValid {
|
||||||
return ExtractionEvidence{State: state, Reason: reason}
|
return ExtractionEvidence{State: state, Reason: reason}
|
||||||
}
|
}
|
||||||
seen[out.SourceID] = struct{}{}
|
seen[out.SourceID] = struct{}{}
|
||||||
outputs[out.SourceID] = out.LocalPath
|
outputs[out.SourceID] = ExtractionEvidenceOutput{
|
||||||
|
Path: out.LocalPath, Checksum: out.Checksum, Size: size,
|
||||||
|
Contract: cloneArtifactContract(out.Contract),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !indexSeen || len(seen) != len(expected) || len(r.Outputs) != len(expected)+1 {
|
if !indexSeen || len(seen) != len(expected) || len(r.Outputs) != len(expected)+1 {
|
||||||
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result is incomplete"}
|
return ExtractionEvidence{State: ExtractionEvidenceObsolete, Reason: "extract result is incomplete"}
|
||||||
@@ -110,30 +123,30 @@ func InspectExtractionEvidence(
|
|||||||
return ExtractionEvidence{State: ExtractionEvidenceValid, ProducerRunID: runID, Outputs: outputs}
|
return ExtractionEvidence{State: ExtractionEvidenceValid, ProducerRunID: runID, Outputs: outputs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspectExtractionPayload(root, path, checksum string) (ExtractionEvidenceState, string) {
|
func inspectExtractionPayload(root, path, checksum string) (ExtractionEvidenceState, string, int64) {
|
||||||
if !filepath.IsAbs(path) || !pathWithinExtractionRoot(root, path) || strings.TrimSpace(checksum) == "" {
|
if !filepath.IsAbs(path) || !pathWithinExtractionRoot(root, path) || strings.TrimSpace(checksum) == "" {
|
||||||
return ExtractionEvidenceUnsafe, "extract output path or checksum is unsafe"
|
return ExtractionEvidenceUnsafe, "extract output path or checksum is unsafe", 0
|
||||||
}
|
}
|
||||||
info, err := os.Lstat(path)
|
info, err := os.Lstat(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return ExtractionEvidenceObsolete, "extract output is missing"
|
return ExtractionEvidenceObsolete, "extract output is missing", 0
|
||||||
}
|
}
|
||||||
if !safeExtractionComponents(root, path) {
|
if !safeExtractionComponents(root, path) {
|
||||||
return ExtractionEvidenceUnsafe, "extract output path contains unsafe components"
|
return ExtractionEvidenceUnsafe, "extract output path contains unsafe components", 0
|
||||||
}
|
}
|
||||||
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||||
return ExtractionEvidenceUnsafe, "extract output is not a regular file"
|
return ExtractionEvidenceUnsafe, "extract output is not a regular file", 0
|
||||||
}
|
}
|
||||||
actual, err := SHA256File(path)
|
actual, err := SHA256File(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read"
|
return ExtractionEvidenceUnsafe, "extract output checksum cannot be read", 0
|
||||||
}
|
}
|
||||||
if actual != checksum {
|
if actual != checksum {
|
||||||
return ExtractionEvidenceObsolete, "extract output checksum does not match durable bytes"
|
return ExtractionEvidenceObsolete, "extract output checksum does not match durable bytes", 0
|
||||||
}
|
}
|
||||||
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
|
body, err := fileops.ReadRegularFile(path, MaxExtractionPayloadBytes)
|
||||||
if err != nil || !json.Valid(body) {
|
if err != nil || !json.Valid(body) {
|
||||||
return ExtractionEvidenceObsolete, "extract output is not valid JSON"
|
return ExtractionEvidenceObsolete, "extract output is not valid JSON", 0
|
||||||
}
|
}
|
||||||
return ExtractionEvidenceValid, ""
|
return ExtractionEvidenceValid, "", info.Size()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
6
internal/buildinfo/buildinfo.go
Normal file
6
internal/buildinfo/buildinfo.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Package buildinfo exposes metadata supplied by the release build.
|
||||||
|
package buildinfo
|
||||||
|
|
||||||
|
// Version is the Narratio release identifier. Source builds report "dev";
|
||||||
|
// release automation replaces it with the exact Git tag through -ldflags -X.
|
||||||
|
var Version = "dev"
|
||||||
@@ -138,6 +138,74 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) {
|
|||||||
assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config")
|
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",
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ type CampaignConfig struct {
|
|||||||
|
|
||||||
// CampaignInputsConfig contains stable campaign-level input file references.
|
// CampaignInputsConfig contains stable campaign-level input file references.
|
||||||
type CampaignInputsConfig struct {
|
type CampaignInputsConfig struct {
|
||||||
SpeakersFile string `yaml:"speakers_file"`
|
SpeakersFile string `yaml:"speakers_file"`
|
||||||
AutocorrectFile string `yaml:"autocorrect_file"`
|
AutocorrectFile string `yaml:"autocorrect_file"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,14 +287,15 @@ type NotificationConfig struct {
|
|||||||
|
|
||||||
// SessionInputsConfig contains per-session input references.
|
// SessionInputsConfig contains per-session input references.
|
||||||
type SessionInputsConfig struct {
|
type SessionInputsConfig struct {
|
||||||
AudioDir string `yaml:"audio_dir"`
|
AudioDir string `yaml:"audio_dir"`
|
||||||
AudioFiles []string `yaml:"audio_files"`
|
AudioFiles []string `yaml:"audio_files"`
|
||||||
AudioS3 *SessionAudioS3Input `yaml:"audio_s3"`
|
AudioS3 *SessionAudioS3Input `yaml:"audio_s3"`
|
||||||
SpeakersFile string `yaml:"speakers_file"`
|
SpeakersFile string `yaml:"speakers_file"`
|
||||||
AutocorrectFile string `yaml:"autocorrect_file"`
|
AutocorrectFile string `yaml:"autocorrect_file"`
|
||||||
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.
|
||||||
@@ -303,11 +306,12 @@ type SessionAudioS3Input struct {
|
|||||||
// ResolvedStableInputs records where stable input file paths came from after
|
// ResolvedStableInputs records where stable input file paths came from after
|
||||||
// campaign/session merge.
|
// campaign/session merge.
|
||||||
type ResolvedStableInputs struct {
|
type ResolvedStableInputs struct {
|
||||||
SpeakersFile ResolvedInputFile
|
SpeakersFile ResolvedInputFile
|
||||||
AutocorrectFile ResolvedInputFile
|
AutocorrectFile ResolvedInputFile
|
||||||
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
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.depends_on must not include itself",
|
wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.depends_on must not include itself",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "enabled dependency cycle fails validation",
|
name: "configured dependency cycle fails validation",
|
||||||
scriptoriumYAML: `scriptorium:
|
scriptoriumYAML: `scriptorium:
|
||||||
binary: scriptorium
|
binary: scriptorium
|
||||||
artifacts:
|
artifacts:
|
||||||
@@ -476,7 +476,25 @@ func TestScriptoriumLoadAndValidate(t *testing.T) {
|
|||||||
source: narratio.artifact.artifact_a
|
source: narratio.artifact.artifact_a
|
||||||
required: true
|
required: true
|
||||||
`,
|
`,
|
||||||
wantValidateErr: "pipeline.scriptorium.artifacts enabled dependencies must not contain cycles",
|
wantValidateErr: "pipeline.scriptorium.artifacts dependencies must not contain cycles",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "disabled dependency cycle fails validation",
|
||||||
|
scriptoriumYAML: `scriptorium:
|
||||||
|
binary: scriptorium
|
||||||
|
artifacts:
|
||||||
|
artifact_a:
|
||||||
|
enabled: false
|
||||||
|
depends_on:
|
||||||
|
- artifact_b
|
||||||
|
output_path: artifacts/a.md
|
||||||
|
artifact_b:
|
||||||
|
enabled: false
|
||||||
|
depends_on:
|
||||||
|
- artifact_a
|
||||||
|
output_path: artifacts/b.md
|
||||||
|
`,
|
||||||
|
wantValidateErr: "pipeline.scriptorium.artifacts dependencies must not contain cycles",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "artifact source typo fails validation",
|
name: "artifact source typo fails validation",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -723,7 +761,7 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateEnabledArtifactDependencyCycles(cfg.Artifacts); err != nil {
|
if err := ValidateScriptoriumArtifactDependencies(cfg.Artifacts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -935,38 +983,47 @@ func validatePathWithinRoot(fieldName, value, root string) error {
|
|||||||
return fmt.Errorf("%s must be under %s/", fieldName, normalizedRoot)
|
return fmt.Errorf("%s must be under %s/", fieldName, normalizedRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArtifactConfig) error {
|
// ValidateScriptoriumArtifactDependencies validates the configured dependency
|
||||||
|
// graph independently of execution selection. Disabled artifacts remain valid
|
||||||
|
// prerequisites for explicit selections and therefore participate in cycles.
|
||||||
|
func ValidateScriptoriumArtifactDependencies(artifacts map[string]ScriptoriumArtifactConfig) error {
|
||||||
if len(artifacts) == 0 {
|
if len(artifacts) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
enabled := make(map[string]struct{}, len(artifacts))
|
|
||||||
graph := make(map[string][]string, len(artifacts))
|
graph := make(map[string][]string, len(artifacts))
|
||||||
for name, cfg := range artifacts {
|
for name, cfg := range artifacts {
|
||||||
if !cfg.Enabled {
|
if !artifactpolicy.IsConfiguredKey(name) {
|
||||||
continue
|
return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$")
|
||||||
}
|
|
||||||
enabled[name] = struct{}{}
|
|
||||||
}
|
|
||||||
for name, cfg := range artifacts {
|
|
||||||
if !cfg.Enabled {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
seen := make(map[string]struct{}, len(cfg.DependsOn))
|
||||||
for _, dep := range cfg.DependsOn {
|
for _, dep := range cfg.DependsOn {
|
||||||
trimmedDep := strings.TrimSpace(dep)
|
trimmedDep := strings.TrimSpace(dep)
|
||||||
if _, ok := enabled[trimmedDep]; ok {
|
if trimmedDep == "" {
|
||||||
graph[name] = append(graph[name], trimmedDep)
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on entries must be non-empty", name)
|
||||||
}
|
}
|
||||||
|
if _, ok := artifacts[trimmedDep]; !ok {
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s dependency %q is not configured", name, dep)
|
||||||
|
}
|
||||||
|
if trimmedDep == name {
|
||||||
|
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on must not include itself", name)
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[trimmedDep]; duplicate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmedDep] = struct{}{}
|
||||||
|
graph[name] = append(graph[name], trimmedDep)
|
||||||
}
|
}
|
||||||
|
sort.Strings(graph[name])
|
||||||
}
|
}
|
||||||
|
|
||||||
visiting := make(map[string]bool, len(enabled))
|
visiting := make(map[string]bool, len(artifacts))
|
||||||
visited := make(map[string]bool, len(enabled))
|
visited := make(map[string]bool, len(artifacts))
|
||||||
|
|
||||||
var visit func(node string) error
|
var visit func(node string) error
|
||||||
visit = func(node string) error {
|
visit = func(node string) error {
|
||||||
if visiting[node] {
|
if visiting[node] {
|
||||||
return fmt.Errorf("pipeline.scriptorium.artifacts enabled dependencies must not contain cycles")
|
return fmt.Errorf("pipeline.scriptorium.artifacts dependencies must not contain cycles")
|
||||||
}
|
}
|
||||||
if visited[node] {
|
if visited[node] {
|
||||||
return nil
|
return nil
|
||||||
@@ -982,7 +1039,12 @@ func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArt
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for node := range enabled {
|
nodes := make([]string, 0, len(artifacts))
|
||||||
|
for node := range artifacts {
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
sort.Strings(nodes)
|
||||||
|
for _, node := range nodes {
|
||||||
if err := visit(node); err != nil {
|
if err := visit(node); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
374
internal/manifest/analyze_state.go
Normal file
374
internal/manifest/analyze_state.go
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AnalyzeStateContractVersion identifies the supported per-artifact state
|
||||||
|
// representation owned by the analyze stage.
|
||||||
|
AnalyzeStateContractVersion = 1
|
||||||
|
// AnalyzeFingerprintContractVersion identifies the fingerprint representation
|
||||||
|
// stored by the supported analyze state contract.
|
||||||
|
AnalyzeFingerprintContractVersion = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxAnalyzeArtifactErrorLength = 512
|
||||||
|
maxAnalyzeArtifactTextLength = 4096
|
||||||
|
maxAnalyzeArtifactListEntries = 128
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnalyzeArtifactStatus describes whether one configured analysis artifact is
|
||||||
|
// currently available or why it is not.
|
||||||
|
type AnalyzeArtifactStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AnalyzeArtifactCurrent AnalyzeArtifactStatus = "current"
|
||||||
|
AnalyzeArtifactStale AnalyzeArtifactStatus = "stale"
|
||||||
|
AnalyzeArtifactMissing AnalyzeArtifactStatus = "missing"
|
||||||
|
AnalyzeArtifactFailed AnalyzeArtifactStatus = "failed"
|
||||||
|
AnalyzeArtifactUnselected AnalyzeArtifactStatus = "unselected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AnalyzeArtifactProvenance records useful non-secret Scriptorium invocation
|
||||||
|
// identity without making adapter diagnostics part of the generic artifact schema.
|
||||||
|
type AnalyzeArtifactProvenance struct {
|
||||||
|
PromptID string `json:"prompt_id,omitempty"`
|
||||||
|
ProfileID string `json:"profile_id,omitempty"`
|
||||||
|
CommandMode string `json:"command_mode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyzeArtifactRecord is analyze-owned state for one configured artifact.
|
||||||
|
// Output is present only while the record is current.
|
||||||
|
type AnalyzeArtifactRecord struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Status AnalyzeArtifactStatus `json:"status"`
|
||||||
|
FingerprintVersion int `json:"fingerprint_version,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Dependencies []string `json:"dependencies,omitempty"`
|
||||||
|
Output *ArtifactRecord `json:"output,omitempty"`
|
||||||
|
OutputSize int64 `json:"output_size,omitempty"`
|
||||||
|
ProducerRunID string `json:"producer_run_id"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Scriptorium *AnalyzeArtifactProvenance `json:"scriptorium,omitempty"`
|
||||||
|
Logs []string `json:"logs,omitempty"`
|
||||||
|
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasVersionedAnalyzeState reports whether an analyze stage record carries the
|
||||||
|
// supported per-artifact authority. A legacy aggregate-only record returns false.
|
||||||
|
func (s *StageRecord) HasVersionedAnalyzeState() bool {
|
||||||
|
return s != nil && s.Name == "analyze" && s.AnalyzeStateVersion == AnalyzeStateContractVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAnalyzeArtifactCollection validates one complete session or
|
||||||
|
// invocation collection independently of its containing manifest.
|
||||||
|
func ValidateAnalyzeArtifactCollection(version int, records map[string]AnalyzeArtifactRecord) error {
|
||||||
|
if version != AnalyzeStateContractVersion {
|
||||||
|
return fmt.Errorf("unsupported analyze state version %d", version)
|
||||||
|
}
|
||||||
|
for key, record := range records {
|
||||||
|
if err := validateAnalyzeArtifactRecord(key, record); err != nil {
|
||||||
|
return fmt.Errorf("analyze artifact %q: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneAnalyzeArtifactCollection returns a deep copy in canonical dependency
|
||||||
|
// order so projections cannot be mutated after application.
|
||||||
|
func CloneAnalyzeArtifactCollection(records map[string]AnalyzeArtifactRecord) map[string]AnalyzeArtifactRecord {
|
||||||
|
if records == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make(map[string]AnalyzeArtifactRecord, len(records))
|
||||||
|
for key, record := range records {
|
||||||
|
record.Dependencies = cloneStrings(record.Dependencies)
|
||||||
|
record.Logs = cloneStrings(record.Logs)
|
||||||
|
record.GeneratedConfigs = cloneStrings(record.GeneratedConfigs)
|
||||||
|
record.Output = cloneArtifactRecord(record.Output)
|
||||||
|
record.Scriptorium = cloneAnalyzeProvenance(record.Scriptorium)
|
||||||
|
cloned[key] = record
|
||||||
|
}
|
||||||
|
normalizeAnalyzeArtifactCollection(cloned)
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAnalyzeStageState(stageName string, version int, records map[string]AnalyzeArtifactRecord) error {
|
||||||
|
if stageName != "analyze" {
|
||||||
|
if version != 0 || records != nil {
|
||||||
|
return fmt.Errorf("stage %q cannot contain analyze-owned state", stageName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if version == 0 {
|
||||||
|
if records != nil {
|
||||||
|
return fmt.Errorf("legacy analyze stage without a state version cannot contain analyze_artifacts")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ValidateAnalyzeArtifactCollection(version, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAnalyzeArtifactRecord(mapKey string, record AnalyzeArtifactRecord) error {
|
||||||
|
if !isNormalizedAnalyzeArtifactKey(mapKey) {
|
||||||
|
return fmt.Errorf("map key must match ^[a-z][a-z0-9_]*$ without normalization")
|
||||||
|
}
|
||||||
|
if record.Key != mapKey {
|
||||||
|
return fmt.Errorf("record key %q does not match map key", record.Key)
|
||||||
|
}
|
||||||
|
switch record.Status {
|
||||||
|
case AnalyzeArtifactCurrent, AnalyzeArtifactStale, AnalyzeArtifactMissing, AnalyzeArtifactFailed, AnalyzeArtifactUnselected:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported status %q", record.Status)
|
||||||
|
}
|
||||||
|
if err := validateAnalyzeDependencies(record.Dependencies); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateAnalyzeFingerprint(record.FingerprintVersion, record.Fingerprint, record.Status == AnalyzeArtifactCurrent); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := pathsafe.ValidateOpaqueSegment(record.ProducerRunID); err != nil {
|
||||||
|
return fmt.Errorf("producer_run_id is invalid: %w", err)
|
||||||
|
}
|
||||||
|
if record.UpdatedAt.IsZero() {
|
||||||
|
return fmt.Errorf("updated_at is required")
|
||||||
|
}
|
||||||
|
if len(record.Error) > maxAnalyzeArtifactErrorLength {
|
||||||
|
return fmt.Errorf("error exceeds %d bytes", maxAnalyzeArtifactErrorLength)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(record.Error) != record.Error {
|
||||||
|
return fmt.Errorf("error must be trimmed")
|
||||||
|
}
|
||||||
|
if record.Status == AnalyzeArtifactFailed {
|
||||||
|
if record.Error == "" {
|
||||||
|
return fmt.Errorf("failed status requires error")
|
||||||
|
}
|
||||||
|
} else if record.Error != "" {
|
||||||
|
return fmt.Errorf("status %q forbids error", record.Status)
|
||||||
|
}
|
||||||
|
if record.Status == AnalyzeArtifactCurrent {
|
||||||
|
if err := validateCurrentAnalyzeOutput(record); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else if record.Output != nil || record.OutputSize != 0 {
|
||||||
|
return fmt.Errorf("status %q forbids output and output_size", record.Status)
|
||||||
|
}
|
||||||
|
if err := validateAnalyzeProvenance(record.Scriptorium); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateAnalyzeTextList("logs", record.Logs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateAnalyzeTextList("generated_configs", record.GeneratedConfigs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCurrentAnalyzeOutput(record AnalyzeArtifactRecord) error {
|
||||||
|
if record.Output == nil {
|
||||||
|
return fmt.Errorf("current status requires output")
|
||||||
|
}
|
||||||
|
if record.OutputSize <= 0 {
|
||||||
|
return fmt.Errorf("current status requires positive output_size")
|
||||||
|
}
|
||||||
|
output := record.Output
|
||||||
|
if strings.TrimSpace(output.Kind) == "" {
|
||||||
|
return fmt.Errorf("current output kind is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(output.Kind) != output.Kind {
|
||||||
|
return fmt.Errorf("current output kind must be trimmed")
|
||||||
|
}
|
||||||
|
wantSource := artifactpolicy.ConfiguredSourceID(record.Key)
|
||||||
|
if output.SourceID != wantSource {
|
||||||
|
return fmt.Errorf("current output source_id %q must equal %q", output.SourceID, wantSource)
|
||||||
|
}
|
||||||
|
normalizedPath, err := pathsafe.NormalizeRelativeDestination(output.LocalPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("current output local_path is unsafe: %w", err)
|
||||||
|
}
|
||||||
|
if normalizedPath != output.LocalPath {
|
||||||
|
return fmt.Errorf("current output local_path %q is not canonical %q", output.LocalPath, normalizedPath)
|
||||||
|
}
|
||||||
|
if output.Contract == nil || strings.TrimSpace(output.Contract.MediaType) == "" || strings.TrimSpace(output.Contract.SchemaID) == "" || strings.TrimSpace(output.Contract.SchemaVersion) == "" {
|
||||||
|
return fmt.Errorf("current output contract media_type, schema_id, and schema_version are required")
|
||||||
|
}
|
||||||
|
for field, value := range map[string]string{
|
||||||
|
"media_type": output.Contract.MediaType, "schema_id": output.Contract.SchemaID,
|
||||||
|
"schema_version": output.Contract.SchemaVersion, "module_key": output.Contract.ModuleKey,
|
||||||
|
} {
|
||||||
|
if strings.TrimSpace(value) != value {
|
||||||
|
return fmt.Errorf("current output contract %s must be trimmed", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := validateSHA256("current output checksum", output.Checksum); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if output.ProducerRunID != "" && output.ProducerRunID != record.ProducerRunID {
|
||||||
|
return fmt.Errorf("current output producer_run_id %q does not match record", output.ProducerRunID)
|
||||||
|
}
|
||||||
|
for field, value := range map[string]string{
|
||||||
|
"output kind": output.Kind,
|
||||||
|
"output source_id": output.SourceID,
|
||||||
|
"output local_path": output.LocalPath,
|
||||||
|
"output contract media_type": output.Contract.MediaType,
|
||||||
|
"output contract schema_id": output.Contract.SchemaID,
|
||||||
|
"output contract schema_version": output.Contract.SchemaVersion,
|
||||||
|
"output contract module_key": output.Contract.ModuleKey,
|
||||||
|
} {
|
||||||
|
if err := validateAnalyzeText(field, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if output.ExternalProvenance != nil {
|
||||||
|
if strings.TrimSpace(output.ExternalProvenance.System) == "" {
|
||||||
|
return fmt.Errorf("output provenance system is required when provenance is present")
|
||||||
|
}
|
||||||
|
for field, value := range map[string]string{
|
||||||
|
"output provenance system": output.ExternalProvenance.System,
|
||||||
|
"output provenance run_id": output.ExternalProvenance.RunID,
|
||||||
|
"output provenance pipeline_id": output.ExternalProvenance.PipelineID,
|
||||||
|
"output provenance artifact_id": output.ExternalProvenance.ArtifactID,
|
||||||
|
} {
|
||||||
|
if err := validateAnalyzeText(field, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAnalyzeDependencies(dependencies []string) error {
|
||||||
|
seen := make(map[string]struct{}, len(dependencies))
|
||||||
|
for index, dependency := range dependencies {
|
||||||
|
if !isNormalizedAnalyzeArtifactKey(dependency) {
|
||||||
|
return fmt.Errorf("dependencies[%d] must match ^[a-z][a-z0-9_]*$ without normalization", index)
|
||||||
|
}
|
||||||
|
if _, duplicate := seen[dependency]; duplicate {
|
||||||
|
return fmt.Errorf("duplicate dependency %q", dependency)
|
||||||
|
}
|
||||||
|
seen[dependency] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAnalyzeFingerprint(version int, fingerprint string, required bool) error {
|
||||||
|
if version == 0 && fingerprint == "" {
|
||||||
|
if required {
|
||||||
|
return fmt.Errorf("current status requires fingerprint version and fingerprint")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if version != AnalyzeFingerprintContractVersion {
|
||||||
|
return fmt.Errorf("unsupported fingerprint version %d", version)
|
||||||
|
}
|
||||||
|
return validateSHA256("fingerprint", fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSHA256(field, value string) error {
|
||||||
|
if len(value) != 64 || strings.ToLower(value) != value {
|
||||||
|
return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field)
|
||||||
|
}
|
||||||
|
decoded, err := hex.DecodeString(value)
|
||||||
|
if err != nil || len(decoded) != 32 {
|
||||||
|
return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) error {
|
||||||
|
if provenance == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if provenance.PromptID == "" && provenance.ProfileID == "" && provenance.CommandMode == "" {
|
||||||
|
return fmt.Errorf("scriptorium provenance must contain at least one identifier")
|
||||||
|
}
|
||||||
|
for field, value := range map[string]string{
|
||||||
|
"scriptorium prompt_id": provenance.PromptID,
|
||||||
|
"scriptorium profile_id": provenance.ProfileID,
|
||||||
|
"scriptorium command_mode": provenance.CommandMode,
|
||||||
|
} {
|
||||||
|
if err := validateAnalyzeText(field, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAnalyzeTextList(field string, values []string) error {
|
||||||
|
if len(values) > maxAnalyzeArtifactListEntries {
|
||||||
|
return fmt.Errorf("%s exceeds %d entries", field, maxAnalyzeArtifactListEntries)
|
||||||
|
}
|
||||||
|
for index, value := range values {
|
||||||
|
if err := validateAnalyzeText(fmt.Sprintf("%s[%d]", field, index), value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAnalyzeText(field, value string) error {
|
||||||
|
if len(value) > maxAnalyzeArtifactTextLength {
|
||||||
|
return fmt.Errorf("%s exceeds %d bytes", field, maxAnalyzeArtifactTextLength)
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(value, '\x00') {
|
||||||
|
return fmt.Errorf("%s contains a NUL byte", field)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNormalizedAnalyzeArtifactKey(key string) bool {
|
||||||
|
return strings.TrimSpace(key) == key && artifactpolicy.IsConfiguredKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAnalyzeArtifactCollection(records map[string]AnalyzeArtifactRecord) {
|
||||||
|
for key, record := range records {
|
||||||
|
if len(record.Dependencies) > 1 {
|
||||||
|
record.Dependencies = append([]string(nil), record.Dependencies...)
|
||||||
|
sort.Strings(record.Dependencies)
|
||||||
|
}
|
||||||
|
record.Logs = cloneStrings(record.Logs)
|
||||||
|
record.GeneratedConfigs = cloneStrings(record.GeneratedConfigs)
|
||||||
|
record.Output = cloneArtifactRecord(record.Output)
|
||||||
|
record.Scriptorium = cloneAnalyzeProvenance(record.Scriptorium)
|
||||||
|
records[key] = record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStrings(values []string) []string {
|
||||||
|
return append([]string(nil), values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneArtifactRecord(record *ArtifactRecord) *ArtifactRecord {
|
||||||
|
if record == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone := *record
|
||||||
|
if record.Contract != nil {
|
||||||
|
contract := *record.Contract
|
||||||
|
clone.Contract = &contract
|
||||||
|
}
|
||||||
|
if record.ExternalProvenance != nil {
|
||||||
|
provenance := *record.ExternalProvenance
|
||||||
|
clone.ExternalProvenance = &provenance
|
||||||
|
}
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) *AnalyzeArtifactProvenance {
|
||||||
|
if provenance == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clone := *provenance
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
309
internal/manifest/analyze_state_test.go
Normal file
309
internal/manifest/analyze_state_test.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package manifest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||||
|
records := map[string]AnalyzeArtifactRecord{
|
||||||
|
"session_recap": currentAnalyzeArtifactRecord("session_recap", now),
|
||||||
|
"quest_log": {
|
||||||
|
Key: "quest_log", Status: AnalyzeArtifactStale,
|
||||||
|
FingerprintVersion: AnalyzeFingerprintContractVersion,
|
||||||
|
Fingerprint: strings.Repeat("2", 64),
|
||||||
|
Dependencies: []string{"session_recap"}, ProducerRunID: "run-stale", UpdatedAt: now,
|
||||||
|
},
|
||||||
|
"player_handout": {
|
||||||
|
Key: "player_handout", Status: AnalyzeArtifactMissing,
|
||||||
|
Dependencies: []string{"session_recap"}, ProducerRunID: "run-missing", UpdatedAt: now,
|
||||||
|
},
|
||||||
|
"npc_digest": {
|
||||||
|
Key: "npc_digest", Status: AnalyzeArtifactFailed,
|
||||||
|
ProducerRunID: "run-failed", UpdatedAt: now, Error: "scriptorium validation failed",
|
||||||
|
Logs: []string{"runs/run-failed/logs/npc-digest.stderr.log"},
|
||||||
|
},
|
||||||
|
"gm_notes": {
|
||||||
|
Key: "gm_notes", Status: AnalyzeArtifactUnselected,
|
||||||
|
ProducerRunID: "run-unselected", UpdatedAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
records["session_recap"] = func() AnalyzeArtifactRecord {
|
||||||
|
record := records["session_recap"]
|
||||||
|
record.Dependencies = []string{"quest_log", "gm_notes"}
|
||||||
|
return record
|
||||||
|
}()
|
||||||
|
|
||||||
|
m := New("session", now)
|
||||||
|
m.Stages["analyze"] = &StageRecord{
|
||||||
|
Name: "analyze",
|
||||||
|
Status: StatusSucceeded,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
AnalyzeStateVersion: AnalyzeStateContractVersion,
|
||||||
|
AnalyzeArtifacts: records,
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||||
|
store := &LocalStore{}
|
||||||
|
if err := store.Save(context.Background(), path, m); err != nil {
|
||||||
|
t.Fatalf("Save() error = %v", err)
|
||||||
|
}
|
||||||
|
loaded, err := store.Load(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
analyze := loaded.Stages["analyze"]
|
||||||
|
if !analyze.HasVersionedAnalyzeState() {
|
||||||
|
t.Fatal("round-tripped analyze record lacks versioned state")
|
||||||
|
}
|
||||||
|
for key, want := range records {
|
||||||
|
got, ok := analyze.AnalyzeArtifacts[key]
|
||||||
|
if !ok || got.Status != want.Status {
|
||||||
|
t.Fatalf("artifact %q = %#v, want status %q", key, got, want.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := analyze.AnalyzeArtifacts["session_recap"].Dependencies; !reflect.DeepEqual(got, []string{"gm_notes", "quest_log"}) {
|
||||||
|
t.Fatalf("canonical dependencies = %#v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
orderedKeys := []string{"gm_notes", "npc_digest", "player_handout", "quest_log", "session_recap"}
|
||||||
|
previous := -1
|
||||||
|
for _, key := range orderedKeys {
|
||||||
|
position := bytes.Index(data, []byte(`"`+key+`": {`))
|
||||||
|
if position <= previous {
|
||||||
|
t.Fatalf("map key %q position = %d after %d; JSON is not canonical:\n%s", key, position, previous, data)
|
||||||
|
}
|
||||||
|
previous = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunManifestAnalyzeArtifactStateRoundTrip(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||||
|
run := NewRun("session", "campaign", "run-123", true, []string{"analyze"}, now)
|
||||||
|
run.Stages["analyze"] = &RunStageRecord{
|
||||||
|
Name: "analyze",
|
||||||
|
Action: RunStageActionRun,
|
||||||
|
Status: StatusSucceeded,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
AnalyzeStateVersion: AnalyzeStateContractVersion,
|
||||||
|
AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{
|
||||||
|
"session_recap": currentAnalyzeArtifactRecord("session_recap", now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "run.json")
|
||||||
|
store := &LocalStore{}
|
||||||
|
if err := store.SaveRun(context.Background(), path, run); err != nil {
|
||||||
|
t.Fatalf("SaveRun() error = %v", err)
|
||||||
|
}
|
||||||
|
loaded, err := store.LoadRun(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadRun() error = %v", err)
|
||||||
|
}
|
||||||
|
got := loaded.Stages["analyze"]
|
||||||
|
if got.AnalyzeStateVersion != AnalyzeStateContractVersion || got.AnalyzeArtifacts["session_recap"].Status != AnalyzeArtifactCurrent {
|
||||||
|
t.Fatalf("run analyze state = %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateAnalyzeArtifactCollectionRejectsMalformedState(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||||
|
valid := currentAnalyzeArtifactRecord("session_recap", now)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
mutate func(*AnalyzeArtifactRecord)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "malformed map key", key: "Session Recap", want: "map key must match"},
|
||||||
|
{name: "record key mismatch", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Key = "quest_log" }, want: "does not match map key"},
|
||||||
|
{name: "unsupported status", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = "ready" }, want: "unsupported status"},
|
||||||
|
{name: "unsupported fingerprint version", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.FingerprintVersion = 99 }, want: "unsupported fingerprint version"},
|
||||||
|
{name: "bad fingerprint", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Fingerprint = "not-a-digest" }, want: "fingerprint must be"},
|
||||||
|
{name: "duplicate dependency", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Dependencies = []string{"quest_log", "quest_log"} }, want: "duplicate dependency"},
|
||||||
|
{name: "malformed dependency", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Dependencies = []string{"Quest Log"} }, want: "dependencies[0]"},
|
||||||
|
{name: "missing current output", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output = nil }, want: "requires output"},
|
||||||
|
{name: "bad current size", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.OutputSize = 0 }, want: "positive output_size"},
|
||||||
|
{name: "bad current source", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.SourceID = "narratio.artifact.other" }, want: "source_id"},
|
||||||
|
{name: "unsafe current path", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.LocalPath = "../recap.md" }, want: "local_path is unsafe"},
|
||||||
|
{name: "missing current contract", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.Contract = nil }, want: "output contract"},
|
||||||
|
{name: "bad current checksum", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.Checksum = strings.Repeat("G", 64) }, want: "checksum must be"},
|
||||||
|
{name: "producer mismatch", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Output.ProducerRunID = "other-run" }, want: "does not match record"},
|
||||||
|
{name: "non-current output", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactStale }, want: "forbids output"},
|
||||||
|
{name: "failed without error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Status = AnalyzeArtifactFailed; r.Output = nil; r.OutputSize = 0 }, want: "requires error"},
|
||||||
|
{name: "current with error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Error = "unexpected" }, want: "forbids error"},
|
||||||
|
{name: "oversized error", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) {
|
||||||
|
r.Status = AnalyzeArtifactFailed
|
||||||
|
r.Output = nil
|
||||||
|
r.OutputSize = 0
|
||||||
|
r.Error = strings.Repeat("x", maxAnalyzeArtifactErrorLength+1)
|
||||||
|
}, want: "error exceeds"},
|
||||||
|
{name: "bad producer run", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.ProducerRunID = "bad/run" }, want: "producer_run_id"},
|
||||||
|
{name: "missing update time", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.UpdatedAt = time.Time{} }, want: "updated_at"},
|
||||||
|
{name: "oversized log collection", key: "session_recap", mutate: func(r *AnalyzeArtifactRecord) { r.Logs = make([]string, maxAnalyzeArtifactListEntries+1) }, want: "logs exceeds"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
record := valid
|
||||||
|
record.Output = cloneArtifactRecord(valid.Output)
|
||||||
|
if test.mutate != nil {
|
||||||
|
test.mutate(&record)
|
||||||
|
}
|
||||||
|
err := ValidateAnalyzeArtifactCollection(AnalyzeStateContractVersion, map[string]AnalyzeArtifactRecord{test.key: record})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := ValidateAnalyzeArtifactCollection(99, nil); err == nil || !strings.Contains(err.Error(), "unsupported analyze state version") {
|
||||||
|
t.Fatalf("unsupported version error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeStateOwnershipAndLegacyCompatibility(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||||
|
store := &LocalStore{}
|
||||||
|
legacy := `{
|
||||||
|
"session_id": "session",
|
||||||
|
"created_at": "2026-05-03T10:00:00Z",
|
||||||
|
"updated_at": "2026-05-03T10:01:00Z",
|
||||||
|
"stages": {
|
||||||
|
"analyze": {
|
||||||
|
"name": "analyze",
|
||||||
|
"status": "succeeded",
|
||||||
|
"created_at": "2026-05-03T10:00:00Z",
|
||||||
|
"updated_at": "2026-05-03T10:01:00Z",
|
||||||
|
"outputs": [{"kind":"session_recap","local_path":"artifacts/session_recap.md"}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
loaded, err := store.LoadReader(context.Background(), strings.NewReader(legacy))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadReader(legacy) error = %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Stages["analyze"].HasVersionedAnalyzeState() {
|
||||||
|
t.Fatal("legacy aggregate outputs became current per-artifact evidence")
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "legacy.json")
|
||||||
|
if err := store.Save(context.Background(), path, loaded); err != nil {
|
||||||
|
t.Fatalf("Save(legacy) error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(legacy) error = %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Contains(data, []byte("analyze_state_version")) || bytes.Contains(data, []byte("analyze_artifacts")) || bytes.Contains(data, []byte("fingerprint")) {
|
||||||
|
t.Fatalf("legacy state gained fabricated evidence:\n%s", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := New("session", now)
|
||||||
|
empty.Stages["analyze"] = &StageRecord{
|
||||||
|
Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||||
|
AnalyzeStateVersion: AnalyzeStateContractVersion,
|
||||||
|
}
|
||||||
|
emptyPath := filepath.Join(t.TempDir(), "empty.json")
|
||||||
|
if err := store.Save(context.Background(), emptyPath, empty); err != nil {
|
||||||
|
t.Fatalf("Save(versioned empty state) error = %v", err)
|
||||||
|
}
|
||||||
|
emptyLoaded, err := store.Load(context.Background(), emptyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load(versioned empty state) error = %v", err)
|
||||||
|
}
|
||||||
|
if !emptyLoaded.Stages["analyze"].HasVersionedAnalyzeState() || len(emptyLoaded.Stages["analyze"].AnalyzeArtifacts) != 0 {
|
||||||
|
t.Fatalf("versioned empty state = %#v", emptyLoaded.Stages["analyze"])
|
||||||
|
}
|
||||||
|
|
||||||
|
m := New("session", now)
|
||||||
|
m.Stages["render"] = &StageRecord{
|
||||||
|
Name: "render", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||||
|
AnalyzeStateVersion: AnalyzeStateContractVersion,
|
||||||
|
}
|
||||||
|
if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "bad-owner.json"), m); err == nil || !strings.Contains(err.Error(), "cannot contain analyze-owned state") {
|
||||||
|
t.Fatalf("non-analyze ownership error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Stages = map[string]*StageRecord{"analyze": {
|
||||||
|
Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||||
|
AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{},
|
||||||
|
}}
|
||||||
|
if err := store.Save(context.Background(), filepath.Join(t.TempDir(), "missing-version.json"), m); err == nil || !strings.Contains(err.Error(), "without a state version") {
|
||||||
|
t.Fatalf("missing version error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnalyzeArtifactStateSurvivesAggregateLifecycleClearing(t *testing.T) {
|
||||||
|
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||||
|
for _, transition := range []struct {
|
||||||
|
name string
|
||||||
|
apply func(*Manifest)
|
||||||
|
}{
|
||||||
|
{name: "running", apply: func(m *Manifest) { m.MarkStageRunning("analyze", now.Add(time.Minute)) }},
|
||||||
|
{name: "failed", apply: func(m *Manifest) { m.MarkStageFailed("analyze", now.Add(time.Minute), "aggregate failed") }},
|
||||||
|
{name: "skipped", apply: func(m *Manifest) { m.MarkStageSkipped("analyze", now.Add(time.Minute), "disabled") }},
|
||||||
|
} {
|
||||||
|
t.Run(transition.name, func(t *testing.T) {
|
||||||
|
m := New("session", now)
|
||||||
|
m.Stages["analyze"] = &StageRecord{
|
||||||
|
Name: "analyze", Status: StatusSucceeded, CreatedAt: now, UpdatedAt: now,
|
||||||
|
Outputs: []ArtifactRecord{{Kind: "legacy", LocalPath: "artifacts/legacy.md"}},
|
||||||
|
Logs: []string{"aggregate.log"}, GeneratedConfigs: []string{"aggregate.yml"}, Metadata: map[string]any{"aggregate": true},
|
||||||
|
AnalyzeStateVersion: AnalyzeStateContractVersion,
|
||||||
|
AnalyzeArtifacts: map[string]AnalyzeArtifactRecord{
|
||||||
|
"session_recap": currentAnalyzeArtifactRecord("session_recap", now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
transition.apply(m)
|
||||||
|
stage := m.Stages["analyze"]
|
||||||
|
if len(stage.Outputs) != 0 || len(stage.Logs) != 0 || len(stage.GeneratedConfigs) != 0 || len(stage.Metadata) != 0 {
|
||||||
|
t.Fatalf("aggregate details survived %s: %#v", transition.name, stage)
|
||||||
|
}
|
||||||
|
if !stage.HasVersionedAnalyzeState() || stage.AnalyzeArtifacts["session_recap"].Status != AnalyzeArtifactCurrent {
|
||||||
|
t.Fatalf("per-artifact state was cleared by %s: %#v", transition.name, stage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentAnalyzeArtifactRecord(key string, now time.Time) AnalyzeArtifactRecord {
|
||||||
|
runID := "run-" + strings.ReplaceAll(key, "_", "-")
|
||||||
|
return AnalyzeArtifactRecord{
|
||||||
|
Key: key,
|
||||||
|
Status: AnalyzeArtifactCurrent,
|
||||||
|
FingerprintVersion: AnalyzeFingerprintContractVersion,
|
||||||
|
Fingerprint: strings.Repeat("1", 64),
|
||||||
|
Output: &ArtifactRecord{
|
||||||
|
Kind: key,
|
||||||
|
SourceID: "narratio.artifact." + key,
|
||||||
|
LocalPath: "artifacts/" + strings.ReplaceAll(key, "_", "-") + ".md",
|
||||||
|
ProducerRunID: runID,
|
||||||
|
Checksum: strings.Repeat("a", 64),
|
||||||
|
Contract: &artifactmodel.ContractMetadata{
|
||||||
|
MediaType: "text/markdown", SchemaID: "narratio." + key, SchemaVersion: "1",
|
||||||
|
},
|
||||||
|
ExternalProvenance: &artifactmodel.ExternalProvenance{
|
||||||
|
System: "scriptorium", PipelineID: "campaign", ArtifactID: key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OutputSize: 42,
|
||||||
|
ProducerRunID: runID,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Scriptorium: &AnalyzeArtifactProvenance{
|
||||||
|
PromptID: key, ProfileID: "default", CommandMode: "artifact",
|
||||||
|
},
|
||||||
|
Logs: []string{"runs/" + runID + "/logs/" + key + ".log"},
|
||||||
|
GeneratedConfigs: []string{"runs/" + runID + "/config/" + key + ".yml"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,17 +44,19 @@ type ArtifactRecord struct {
|
|||||||
|
|
||||||
// StageRecord tracks lifecycle and provenance for one pipeline stage.
|
// StageRecord tracks lifecycle and provenance for one pipeline stage.
|
||||||
type StageRecord struct {
|
type StageRecord struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status StageStatus `json:"status"`
|
Status StageStatus `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
Outputs []ArtifactRecord `json:"outputs,omitempty"`
|
Outputs []ArtifactRecord `json:"outputs,omitempty"`
|
||||||
Logs []string `json:"logs,omitempty"`
|
Logs []string `json:"logs,omitempty"`
|
||||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||||
Error *ErrorRecord `json:"error,omitempty"`
|
Error *ErrorRecord `json:"error,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||||
|
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupTarget records one root-confined local deletion requested by a
|
// CleanupTarget records one root-confined local deletion requested by a
|
||||||
|
|||||||
@@ -22,18 +22,20 @@ const (
|
|||||||
|
|
||||||
// RunStageRecord tracks lifecycle and provenance for one stage within a single invocation.
|
// RunStageRecord tracks lifecycle and provenance for one stage within a single invocation.
|
||||||
type RunStageRecord struct {
|
type RunStageRecord struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Action RunStageAction `json:"action"`
|
Action RunStageAction `json:"action"`
|
||||||
Status StageStatus `json:"status"`
|
Status StageStatus `json:"status"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
Outputs []ArtifactRecord `json:"outputs,omitempty"`
|
Outputs []ArtifactRecord `json:"outputs,omitempty"`
|
||||||
Logs []string `json:"logs,omitempty"`
|
Logs []string `json:"logs,omitempty"`
|
||||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||||
Error *ErrorRecord `json:"error,omitempty"`
|
Error *ErrorRecord `json:"error,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||||
|
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunManifest is the invocation-scoped execution record under runs/{run_id}/manifest.json.
|
// RunManifest is the invocation-scoped execution record under runs/{run_id}/manifest.json.
|
||||||
|
|||||||
@@ -110,6 +110,15 @@ func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
|
|||||||
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
|
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
|
||||||
return fmt.Errorf("save manifest: %w", err)
|
return fmt.Errorf("save manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
for name, stage := range m.Stages {
|
||||||
|
if stage == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
|
||||||
|
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||||
|
return fmt.Errorf("save manifest: stages.%s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m.UpdatedAt = time.Now().UTC()
|
m.UpdatedAt = time.Now().UTC()
|
||||||
if m.Stages == nil {
|
if m.Stages == nil {
|
||||||
@@ -216,6 +225,15 @@ func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) e
|
|||||||
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
||||||
return fmt.Errorf("save run manifest: %w", err)
|
return fmt.Errorf("save run manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
for name, stage := range m.Stages {
|
||||||
|
if stage == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
|
||||||
|
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||||
|
return fmt.Errorf("save run manifest: stages.%s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m.UpdatedAt = time.Now().UTC()
|
m.UpdatedAt = time.Now().UTC()
|
||||||
if m.Stages == nil {
|
if m.Stages == nil {
|
||||||
@@ -250,6 +268,14 @@ func validateLoadedManifest(m *Manifest) error {
|
|||||||
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
|
if err := validatePostPublishCleanup(m.PostPublishCleanup); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for name, stage := range m.Stages {
|
||||||
|
if stage == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||||
|
return fmt.Errorf("stages.%s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -266,6 +292,7 @@ func normalizeManifest(m *Manifest) {
|
|||||||
if stage.Name == "" {
|
if stage.Name == "" {
|
||||||
stage.Name = name
|
stage.Name = name
|
||||||
}
|
}
|
||||||
|
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,6 +336,14 @@ func validateLoadedRunManifest(m *RunManifest) error {
|
|||||||
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
if err := validateManifestIdentities(m.SessionID, m.Campaign, m.RunID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for name, stage := range m.Stages {
|
||||||
|
if stage == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||||
|
return fmt.Errorf("stages.%s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -358,6 +393,7 @@ func normalizeRunManifest(m *RunManifest) {
|
|||||||
if stage.Name == "" {
|
if stage.Name == "" {
|
||||||
stage.Name = name
|
stage.Name = name
|
||||||
}
|
}
|
||||||
|
normalizeAnalyzeArtifactCollection(stage.AnalyzeArtifacts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
internal/notariusref/selector.go
Normal file
43
internal/notariusref/selector.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Package notariusref owns Narratio's Notarius CLI reference-selector
|
||||||
|
// vocabulary without depending on Notarius implementation packages.
|
||||||
|
package notariusref
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeSelector validates and normalizes a Notarius v0.6 reference
|
||||||
|
// selector. It intentionally validates selector structure only; Notarius owns
|
||||||
|
// target, slot, and media compatibility.
|
||||||
|
func NormalizeSelector(value string) (string, error) {
|
||||||
|
selector := strings.TrimSpace(value)
|
||||||
|
if selector == "" {
|
||||||
|
return "", fmt.Errorf("reference selector is required")
|
||||||
|
}
|
||||||
|
if strings.Contains(selector, "=") {
|
||||||
|
return "", fmt.Errorf("reference selector must not contain '='")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(selector, ".")
|
||||||
|
for index := range parts {
|
||||||
|
parts[index] = strings.TrimSpace(parts[index])
|
||||||
|
if parts[index] == "" {
|
||||||
|
return "", fmt.Errorf("reference selector components must not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(parts) {
|
||||||
|
case 1, 2:
|
||||||
|
return strings.Join(parts, "."), nil
|
||||||
|
case 3:
|
||||||
|
switch parts[1] {
|
||||||
|
case "extract", "merge", "normalize":
|
||||||
|
return strings.Join(parts, "."), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("three-component reference selector must use extract, merge, or normalize as its middle component")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("reference selector must use slot, chunk.slot, lane.slot, or lane.stage.slot")
|
||||||
|
}
|
||||||
|
}
|
||||||
58
internal/notariusref/selector_test.go
Normal file
58
internal/notariusref/selector_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package notariusref
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeSelectorAcceptsDocumentedForms(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "pipeline", value: "party", want: "party"},
|
||||||
|
{name: "chunk", value: "chunk.party", want: "chunk.party"},
|
||||||
|
{name: "lane", value: "npc-registry.party", want: "npc-registry.party"},
|
||||||
|
{name: "extract", value: "npc-registry.extract.party", want: "npc-registry.extract.party"},
|
||||||
|
{name: "merge", value: "npc-registry.merge.party", want: "npc-registry.merge.party"},
|
||||||
|
{name: "normalize", value: "npc-registry.normalize.party", want: "npc-registry.normalize.party"},
|
||||||
|
{name: "whitespace", value: " npc-registry . extract . party ", want: "npc-registry.extract.party"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := NormalizeSelector(test.value)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NormalizeSelector(%q) error = %v", test.value, err)
|
||||||
|
}
|
||||||
|
if got != test.want {
|
||||||
|
t.Fatalf("NormalizeSelector(%q) = %q, want %q", test.value, got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSelectorRejectsInvalidForms(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "empty", value: "", wantErr: "required"},
|
||||||
|
{name: "whitespace", value: " ", wantErr: "required"},
|
||||||
|
{name: "empty first", value: ".party", wantErr: "components"},
|
||||||
|
{name: "empty middle", value: "lane..party", wantErr: "components"},
|
||||||
|
{name: "empty final", value: "lane.", wantErr: "components"},
|
||||||
|
{name: "equals", value: "party=/tmp/party.yml", wantErr: "must not contain"},
|
||||||
|
{name: "invalid stage", value: "lane.chunk.party", wantErr: "extract, merge, or normalize"},
|
||||||
|
{name: "too many components", value: "lane.extract.party.extra", wantErr: "must use"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
_, err := NormalizeSelector(test.value)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||||
|
t.Fatalf("NormalizeSelector(%q) error = %v, want containing %q", test.value, err, test.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,9 @@ package stage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
@@ -28,6 +30,8 @@ type analyzeArtifactExecutionPlan struct {
|
|||||||
|
|
||||||
type analyzeArtifactExecutionResult struct {
|
type analyzeArtifactExecutionResult struct {
|
||||||
Output artifacts.Ref
|
Output artifacts.Ref
|
||||||
|
OutputSize int64
|
||||||
|
Scriptorium manifest.AnalyzeArtifactProvenance
|
||||||
Logs []string
|
Logs []string
|
||||||
GeneratedConfigs []string
|
GeneratedConfigs []string
|
||||||
Metadata map[string]any
|
Metadata map[string]any
|
||||||
@@ -44,21 +48,6 @@ type analyzeExecutionContext struct {
|
|||||||
Catalog *artifacts.ArtifactCatalog
|
Catalog *artifacts.ArtifactCatalog
|
||||||
}
|
}
|
||||||
|
|
||||||
type analyzeInputResolutionState uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
analyzeInputPresent analyzeInputResolutionState = iota
|
|
||||||
analyzeInputAbsent
|
|
||||||
analyzeInputError
|
|
||||||
)
|
|
||||||
|
|
||||||
type analyzeInputResolution struct {
|
|
||||||
State analyzeInputResolutionState
|
|
||||||
Path string
|
|
||||||
Artifact *artifacts.ResolvedSessionArtifact
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
|
func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
|
||||||
if env == nil || env.Config == nil {
|
if env == nil || env.Config == nil {
|
||||||
return nil, fmt.Errorf("analyze: stage environment config is required")
|
return nil, fmt.Errorf("analyze: stage environment config is required")
|
||||||
@@ -69,10 +58,6 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
if env.Config.Pipeline == nil || env.Config.Session == nil {
|
if env.Config.Pipeline == nil || env.Config.Session == nil {
|
||||||
return nil, fmt.Errorf("analyze: resolved config must include pipeline and session")
|
return nil, fmt.Errorf("analyze: resolved config must include pipeline and session")
|
||||||
}
|
}
|
||||||
if env.Scriptorium == nil {
|
|
||||||
return nil, fmt.Errorf("analyze: scriptorium adapter is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionID string
|
var sessionID string
|
||||||
if m != nil {
|
if m != nil {
|
||||||
sessionID = strings.TrimSpace(m.SessionID)
|
sessionID = strings.TrimSpace(m.SessionID)
|
||||||
@@ -96,6 +81,13 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
"reason": "pipeline.scriptorium is not configured",
|
"reason": "pipeline.scriptorium is not configured",
|
||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
if len(env.Config.Pipeline.Scriptorium.Artifacts) == 0 {
|
||||||
|
return &StageResult{Metadata: map[string]any{
|
||||||
|
"stage": "analyze",
|
||||||
|
"skipped": true,
|
||||||
|
"reason": "no scriptorium artifacts configured",
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
effective := env.EffectiveArtifacts
|
effective := env.EffectiveArtifacts
|
||||||
if !effective.Resolved() {
|
if !effective.Resolved() {
|
||||||
@@ -119,15 +111,11 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err)
|
return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
plans, skipReason, err := buildAnalyzeExecutionPlans(env.Config.Pipeline.Scriptorium, effective, runtimeCatalog)
|
if len(effective.Keys()) == 0 {
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("analyze: %w", err)
|
|
||||||
}
|
|
||||||
if skipReason != "" {
|
|
||||||
return &StageResult{Metadata: map[string]any{
|
return &StageResult{Metadata: map[string]any{
|
||||||
"stage": "analyze",
|
"stage": "analyze",
|
||||||
"skipped": true,
|
"skipped": true,
|
||||||
"reason": skipReason,
|
"reason": "no selected scriptorium artifacts to execute",
|
||||||
}}, nil
|
}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,21 +128,81 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths),
|
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths),
|
||||||
Catalog: runtimeCatalog,
|
Catalog: runtimeCatalog,
|
||||||
}
|
}
|
||||||
|
reconciliation, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, execution)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("analyze: reconcile configured artifacts: %w", err)
|
||||||
|
}
|
||||||
|
workPlan, err := planAnalyzeWork(
|
||||||
|
env.Config.Pipeline.Scriptorium,
|
||||||
|
env.SelectedArtifactKeys,
|
||||||
|
env.Force,
|
||||||
|
reconciliation,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("analyze: plan configured artifacts: %w", err)
|
||||||
|
}
|
||||||
|
if len(workPlan.ExecutionOrder) > 0 && env.Scriptorium == nil {
|
||||||
|
return nil, fmt.Errorf("analyze: scriptorium adapter is required")
|
||||||
|
}
|
||||||
|
|
||||||
outputs := make([]artifacts.Ref, 0, len(plans))
|
|
||||||
logs := []string{}
|
logs := []string{}
|
||||||
generatedConfigs := []string{}
|
generatedConfigs := []string{}
|
||||||
artifactMetadata := make([]map[string]any, 0, len(plans))
|
artifactMetadata := make([]map[string]any, 0, len(workPlan.ExecutionOrder))
|
||||||
reusedArtifacts := []map[string]any{}
|
reusedArtifacts := []map[string]any{}
|
||||||
reusedSeen := map[string]struct{}{}
|
reusedSeen := map[string]struct{}{}
|
||||||
|
sessionRecords := manifest.CloneAnalyzeArtifactCollection(workPlan.ProjectedRecords)
|
||||||
|
invocationRecords := make(map[string]manifest.AnalyzeArtifactRecord)
|
||||||
|
priorCurrentRecords := make(map[string]manifest.AnalyzeArtifactRecord)
|
||||||
|
for _, item := range reconciliation.Ordered {
|
||||||
|
if item.Stored != nil && item.Stored.Status == manifest.AnalyzeArtifactCurrent {
|
||||||
|
priorCurrentRecords[item.Key] = *item.Stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
invocationKeys := make(map[string]struct{}, len(workPlan.ExecutionOrder)+len(workPlan.ReusedCurrent))
|
||||||
|
for _, item := range workPlan.ExecutionOrder {
|
||||||
|
invocationKeys[item.Key] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, item := range workPlan.ReusedCurrent {
|
||||||
|
invocationKeys[item.Key] = struct{}{}
|
||||||
|
if record, ok := sessionRecords[item.Key]; ok {
|
||||||
|
invocationRecords[item.Key] = record
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, plan := range plans {
|
for _, item := range workPlan.ExecutionOrder {
|
||||||
|
artifactCfg := env.Config.Pipeline.Scriptorium.Artifacts[item.Key]
|
||||||
|
fingerprint, _, _, err := computeAnalyzeArtifactFingerprint(
|
||||||
|
item.Key,
|
||||||
|
env.Config.Pipeline.Scriptorium,
|
||||||
|
execution,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
executionErr := fmt.Errorf("analyze: compute execution fingerprint for artifact %q: %w", item.Key, err)
|
||||||
|
return failedAnalyzeResult(
|
||||||
|
execution,
|
||||||
|
item,
|
||||||
|
artifactCfg,
|
||||||
|
"",
|
||||||
|
executionErr,
|
||||||
|
sessionRecords,
|
||||||
|
invocationRecords,
|
||||||
|
), executionErr
|
||||||
|
}
|
||||||
|
priorRecord, hadPriorRecord := priorCurrentRecords[item.Key]
|
||||||
|
plan := analyzeArtifactExecutionPlan{Name: item.Key, Cfg: artifactCfg}
|
||||||
artifactResult, err := executeAnalyzeArtifact(ctx, execution, plan)
|
artifactResult, err := executeAnalyzeArtifact(ctx, execution, plan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return failedAnalyzeResult(
|
||||||
|
execution,
|
||||||
|
item,
|
||||||
|
artifactCfg,
|
||||||
|
fingerprint,
|
||||||
|
err,
|
||||||
|
sessionRecords,
|
||||||
|
invocationRecords,
|
||||||
|
), err
|
||||||
}
|
}
|
||||||
|
|
||||||
outputs = append(outputs, artifactResult.Output)
|
|
||||||
logs = append(logs, artifactResult.Logs...)
|
logs = append(logs, artifactResult.Logs...)
|
||||||
generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...)
|
generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...)
|
||||||
artifactMetadata = append(artifactMetadata, artifactResult.Metadata)
|
artifactMetadata = append(artifactMetadata, artifactResult.Metadata)
|
||||||
@@ -169,18 +217,64 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
reusedArtifacts = append(reusedArtifacts, reused)
|
reusedArtifacts = append(reusedArtifacts, reused)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record, err := currentAnalyzeArtifactRecord(
|
||||||
|
execution,
|
||||||
|
plan,
|
||||||
|
fingerprint,
|
||||||
|
artifactResult,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
recordErr := fmt.Errorf("analyze: record artifact %q: %w", plan.Name, err)
|
||||||
|
return failedAnalyzeResult(
|
||||||
|
execution,
|
||||||
|
item,
|
||||||
|
artifactCfg,
|
||||||
|
fingerprint,
|
||||||
|
recordErr,
|
||||||
|
sessionRecords,
|
||||||
|
invocationRecords,
|
||||||
|
), recordErr
|
||||||
|
}
|
||||||
|
sessionRecords[plan.Name] = record
|
||||||
|
invocationRecords[plan.Name] = record
|
||||||
|
|
||||||
sourceID, ok := runtimeCatalog.SourceIDForConfiguredKey(plan.Name)
|
sourceID, ok := runtimeCatalog.SourceIDForConfiguredKey(plan.Name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("analyze: source id not found for artifact %q", plan.Name)
|
catalogErr := fmt.Errorf("analyze: source id not found for artifact %q", plan.Name)
|
||||||
|
return failedAnalyzeResult(
|
||||||
|
execution, item, artifactCfg, fingerprint, catalogErr,
|
||||||
|
sessionRecords, invocationRecords,
|
||||||
|
), catalogErr
|
||||||
}
|
}
|
||||||
if err := runtimeCatalog.MarkAvailableGenerated(sourceID, artifactResult.Output.AbsolutePath); err != nil {
|
if err := runtimeCatalog.MarkAvailableGeneratedEvidence(
|
||||||
return nil, fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err)
|
sourceID,
|
||||||
|
artifactResult.Output.AbsolutePath,
|
||||||
|
record.ProducerRunID,
|
||||||
|
record.Output.Checksum,
|
||||||
|
record.OutputSize,
|
||||||
|
record.Output.Contract,
|
||||||
|
); err != nil {
|
||||||
|
catalogErr := fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err)
|
||||||
|
return failedAnalyzeResult(
|
||||||
|
execution, item, artifactCfg, fingerprint, catalogErr,
|
||||||
|
sessionRecords, invocationRecords,
|
||||||
|
), catalogErr
|
||||||
|
}
|
||||||
|
if !hadPriorRecord || !sameAnalyzeOutputIdentity(priorRecord, record) {
|
||||||
|
staleUnscheduledAnalyzeDependents(
|
||||||
|
env.Config.Pipeline.Scriptorium.Artifacts,
|
||||||
|
plan.Name,
|
||||||
|
invocationKeys,
|
||||||
|
sessionRecords,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := map[string]any{
|
metadata := map[string]any{
|
||||||
"stage": "analyze",
|
"stage": "analyze",
|
||||||
"selected_artifacts": extractPlanNames(plans),
|
"selected_artifacts": append([]string(nil), workPlan.ExplicitTargets...),
|
||||||
|
"executed_artifacts": analyzePlanKeysForMetadata(workPlan.ExecutionOrder),
|
||||||
|
"reused_current": analyzePlanKeysForMetadata(workPlan.ReusedCurrent),
|
||||||
"generated_artifacts": artifactMetadata,
|
"generated_artifacts": artifactMetadata,
|
||||||
"reused_artifacts": reusedArtifacts,
|
"reused_artifacts": reusedArtifacts,
|
||||||
"artifact_count": len(artifactMetadata),
|
"artifact_count": len(artifactMetadata),
|
||||||
@@ -193,133 +287,200 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &StageResult{
|
return &StageResult{
|
||||||
Outputs: outputs,
|
|
||||||
Logs: dedupeAndSortPaths(logs),
|
Logs: dedupeAndSortPaths(logs),
|
||||||
GeneratedConfigs: dedupeAndSortPaths(generatedConfigs),
|
GeneratedConfigs: dedupeAndSortPaths(generatedConfigs),
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
|
AnalyzeState: &AnalyzeStateProjection{
|
||||||
|
Session: sessionRecords,
|
||||||
|
Invocation: manifest.CloneAnalyzeArtifactCollection(invocationRecords),
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAnalyzeExecutionPlans(
|
func failedAnalyzeResult(
|
||||||
scriptoriumCfg *config.ScriptoriumConfig,
|
execution analyzeExecutionContext,
|
||||||
effective artifacts.EffectiveArtifactSet,
|
item analyzePlanItem,
|
||||||
catalog *artifacts.ArtifactCatalog,
|
artifactCfg config.ScriptoriumArtifactConfig,
|
||||||
) ([]analyzeArtifactExecutionPlan, string, error) {
|
fingerprint string,
|
||||||
if scriptoriumCfg == nil {
|
cause error,
|
||||||
return nil, "pipeline.scriptorium is not configured", nil
|
sessionRecords map[string]manifest.AnalyzeArtifactRecord,
|
||||||
|
invocationRecords map[string]manifest.AnalyzeArtifactRecord,
|
||||||
|
) *StageResult {
|
||||||
|
record := manifest.AnalyzeArtifactRecord{
|
||||||
|
Key: item.Key,
|
||||||
|
Status: manifest.AnalyzeArtifactFailed,
|
||||||
|
Dependencies: normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn),
|
||||||
|
ProducerRunID: analyzeProducerRunID(execution),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
Error: NonResumable(cause.Error()).Reason,
|
||||||
}
|
}
|
||||||
if len(scriptoriumCfg.Artifacts) == 0 {
|
if fingerprint != "" {
|
||||||
return nil, "no scriptorium artifacts configured", nil
|
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
|
||||||
|
record.Fingerprint = fingerprint
|
||||||
}
|
}
|
||||||
|
if artifactCfg.PromptID != "" || artifactCfg.ProfileID != "" {
|
||||||
if len(effective.Keys()) == 0 {
|
record.Scriptorium = &manifest.AnalyzeArtifactProvenance{
|
||||||
return nil, "no selected scriptorium artifacts to execute", nil
|
PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID,
|
||||||
}
|
|
||||||
|
|
||||||
ordered, err := orderSelectedScriptoriumArtifacts(scriptoriumCfg.Artifacts, effective, catalog)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
plans := make([]analyzeArtifactExecutionPlan, 0, len(ordered))
|
|
||||||
for _, name := range ordered {
|
|
||||||
artifactCfg, ok := scriptoriumCfg.Artifacts[name]
|
|
||||||
if !ok {
|
|
||||||
return nil, "", fmt.Errorf("selected artifact %q is not configured", name)
|
|
||||||
}
|
}
|
||||||
plans = append(plans, analyzeArtifactExecutionPlan{Name: name, Cfg: artifactCfg})
|
|
||||||
}
|
}
|
||||||
return plans, "", nil
|
sessionRecords[item.Key] = record
|
||||||
|
invocationRecords[item.Key] = record
|
||||||
|
staleAnalyzeDependents(artifactCfgMap(execution), item.Key, sessionRecords)
|
||||||
|
return &StageResult{AnalyzeState: &AnalyzeStateProjection{
|
||||||
|
Session: manifest.CloneAnalyzeArtifactCollection(sessionRecords),
|
||||||
|
Invocation: manifest.CloneAnalyzeArtifactCollection(invocationRecords),
|
||||||
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func orderSelectedScriptoriumArtifacts(
|
func artifactCfgMap(execution analyzeExecutionContext) map[string]config.ScriptoriumArtifactConfig {
|
||||||
artifactsCfg map[string]config.ScriptoriumArtifactConfig,
|
if execution.Env == nil || execution.Env.Config == nil || execution.Env.Config.Pipeline == nil ||
|
||||||
effective artifacts.EffectiveArtifactSet,
|
execution.Env.Config.Pipeline.Scriptorium == nil {
|
||||||
catalog *artifacts.ArtifactCatalog,
|
return nil
|
||||||
) ([]string, error) {
|
|
||||||
selectedSet := map[string]struct{}{}
|
|
||||||
selected := effective.Keys()
|
|
||||||
for _, key := range selected {
|
|
||||||
selectedSet[key] = struct{}{}
|
|
||||||
}
|
}
|
||||||
|
return execution.Env.Config.Pipeline.Scriptorium.Artifacts
|
||||||
|
}
|
||||||
|
|
||||||
dependencyErrors := []string{}
|
func staleAnalyzeDependents(
|
||||||
for _, selectedKey := range selected {
|
configured map[string]config.ScriptoriumArtifactConfig,
|
||||||
cfg, ok := artifactsCfg[selectedKey]
|
changed string,
|
||||||
if !ok {
|
records map[string]manifest.AnalyzeArtifactRecord,
|
||||||
dependencyErrors = append(dependencyErrors, fmt.Sprintf("selected artifact %q is not configured", selectedKey))
|
) {
|
||||||
|
staleAnalyzeDependentClosure(configured, changed, records, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentAnalyzeArtifactRecord(
|
||||||
|
execution analyzeExecutionContext,
|
||||||
|
plan analyzeArtifactExecutionPlan,
|
||||||
|
fingerprint string,
|
||||||
|
result *analyzeArtifactExecutionResult,
|
||||||
|
) (manifest.AnalyzeArtifactRecord, error) {
|
||||||
|
if result == nil {
|
||||||
|
return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("execution result is required")
|
||||||
|
}
|
||||||
|
producerRunID := analyzeProducerRunID(execution)
|
||||||
|
relativePath, err := normalizedAnalyzeOutputIdentity(plan.Cfg.OutputPath)
|
||||||
|
if err != nil {
|
||||||
|
return manifest.AnalyzeArtifactRecord{}, err
|
||||||
|
}
|
||||||
|
if relativePath == "" {
|
||||||
|
return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("configured output path is required")
|
||||||
|
}
|
||||||
|
contract := result.Output.Contract
|
||||||
|
if contract == nil {
|
||||||
|
return manifest.AnalyzeArtifactRecord{}, fmt.Errorf("validated output contract is required")
|
||||||
|
}
|
||||||
|
record := manifest.AnalyzeArtifactRecord{
|
||||||
|
Key: plan.Name,
|
||||||
|
Status: manifest.AnalyzeArtifactCurrent,
|
||||||
|
FingerprintVersion: manifest.AnalyzeFingerprintContractVersion,
|
||||||
|
Fingerprint: fingerprint,
|
||||||
|
Dependencies: normalizedAnalyzeDependencyKeys(plan.Cfg.DependsOn),
|
||||||
|
Output: &manifest.ArtifactRecord{
|
||||||
|
Kind: "scriptorium_artifact",
|
||||||
|
SourceID: artifacts.ConfiguredArtifactSourceID(plan.Name),
|
||||||
|
LocalPath: relativePath,
|
||||||
|
Contract: cloneAnalyzeOutputContract(contract),
|
||||||
|
ProducerRunID: producerRunID,
|
||||||
|
Checksum: result.Output.Checksum,
|
||||||
|
},
|
||||||
|
OutputSize: result.OutputSize,
|
||||||
|
ProducerRunID: producerRunID,
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
Scriptorium: &result.Scriptorium,
|
||||||
|
Logs: dedupeAndSortPaths(result.Logs),
|
||||||
|
GeneratedConfigs: dedupeAndSortPaths(result.GeneratedConfigs),
|
||||||
|
}
|
||||||
|
if err := manifest.ValidateAnalyzeArtifactCollection(
|
||||||
|
manifest.AnalyzeStateContractVersion,
|
||||||
|
map[string]manifest.AnalyzeArtifactRecord{plan.Name: record},
|
||||||
|
); err != nil {
|
||||||
|
return manifest.AnalyzeArtifactRecord{}, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func analyzeProducerRunID(execution analyzeExecutionContext) string {
|
||||||
|
if execution.Manifest != nil {
|
||||||
|
if runID := strings.TrimSpace(execution.Manifest.RunID); runID != "" {
|
||||||
|
return runID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Direct stage callers predate invocation manifests. Application-owned
|
||||||
|
// execution always supplies the actual run identity.
|
||||||
|
return "direct-analyze"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameAnalyzeOutputIdentity(left, right manifest.AnalyzeArtifactRecord) bool {
|
||||||
|
if left.Status != manifest.AnalyzeArtifactCurrent || right.Status != manifest.AnalyzeArtifactCurrent ||
|
||||||
|
left.Output == nil || right.Output == nil || left.Output.Contract == nil || right.Output.Contract == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return left.OutputSize == right.OutputSize &&
|
||||||
|
left.Output.Checksum == right.Output.Checksum &&
|
||||||
|
*left.Output.Contract == *right.Output.Contract
|
||||||
|
}
|
||||||
|
|
||||||
|
func staleUnscheduledAnalyzeDependents(
|
||||||
|
configured map[string]config.ScriptoriumArtifactConfig,
|
||||||
|
changed string,
|
||||||
|
invocationKeys map[string]struct{},
|
||||||
|
records map[string]manifest.AnalyzeArtifactRecord,
|
||||||
|
) {
|
||||||
|
staleAnalyzeDependentClosure(configured, changed, records, func(key string) bool {
|
||||||
|
_, evaluated := invocationKeys[key]
|
||||||
|
return !evaluated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func staleAnalyzeDependentClosure(
|
||||||
|
configured map[string]config.ScriptoriumArtifactConfig,
|
||||||
|
changed string,
|
||||||
|
records map[string]manifest.AnalyzeArtifactRecord,
|
||||||
|
eligible func(string) bool,
|
||||||
|
) {
|
||||||
|
reverse := make(map[string][]string, len(configured))
|
||||||
|
for key, artifactCfg := range configured {
|
||||||
|
for _, dependency := range normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn) {
|
||||||
|
reverse[dependency] = append(reverse[dependency], key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range reverse {
|
||||||
|
sort.Strings(reverse[key])
|
||||||
|
}
|
||||||
|
queue := append([]string(nil), reverse[changed]...)
|
||||||
|
seen := make(map[string]struct{}, len(queue))
|
||||||
|
for len(queue) > 0 {
|
||||||
|
key := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
if _, visited := seen[key]; visited {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, dep := range cfg.DependsOn {
|
seen[key] = struct{}{}
|
||||||
trimmedDep := strings.TrimSpace(dep)
|
if eligible != nil && !eligible(key) {
|
||||||
if trimmedDep == "" {
|
continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := selectedSet[trimmedDep]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sourceID, ok := catalog.SourceIDForConfiguredKey(trimmedDep)
|
|
||||||
if !ok {
|
|
||||||
dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on unknown configured artifact %q", selectedKey, trimmedDep))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry, ok := catalog.Lookup(sourceID)
|
|
||||||
if !ok || !entry.Available {
|
|
||||||
dependencyErrors = append(dependencyErrors, fmt.Sprintf("artifact %q depends on %q, but %q is unavailable", selectedKey, trimmedDep, sourceID))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
staleProjectedAnalyzeRecord(records, key)
|
||||||
|
queue = append(queue, reverse[key]...)
|
||||||
}
|
}
|
||||||
if len(dependencyErrors) > 0 {
|
}
|
||||||
return nil, errors.New(strings.Join(dependencyErrors, "; "))
|
|
||||||
}
|
|
||||||
|
|
||||||
indegree := map[string]int{}
|
func analyzePlanKeysForMetadata(items []analyzePlanItem) []string {
|
||||||
edges := map[string][]string{}
|
if len(items) == 0 {
|
||||||
for _, key := range selected {
|
return nil
|
||||||
indegree[key] = 0
|
|
||||||
}
|
}
|
||||||
for _, key := range selected {
|
keys := make([]string, 0, len(items))
|
||||||
cfg := artifactsCfg[key]
|
for _, item := range items {
|
||||||
for _, dep := range cfg.DependsOn {
|
keys = append(keys, item.Key)
|
||||||
trimmedDep := strings.TrimSpace(dep)
|
|
||||||
if _, ok := selectedSet[trimmedDep]; !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
edges[trimmedDep] = append(edges[trimmedDep], key)
|
|
||||||
indegree[key]++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
for key := range edges {
|
func cloneAnalyzeOutputContract(value *artifactmodel.ContractMetadata) *artifactmodel.ContractMetadata {
|
||||||
sort.Strings(edges[key])
|
if value == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
cloned := *value
|
||||||
ready := make([]string, 0, len(indegree))
|
return &cloned
|
||||||
for key, degree := range indegree {
|
|
||||||
if degree == 0 {
|
|
||||||
ready = append(ready, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(ready)
|
|
||||||
|
|
||||||
order := make([]string, 0, len(selectedSet))
|
|
||||||
for len(ready) > 0 {
|
|
||||||
node := ready[0]
|
|
||||||
ready = ready[1:]
|
|
||||||
order = append(order, node)
|
|
||||||
for _, dep := range edges[node] {
|
|
||||||
indegree[dep]--
|
|
||||||
if indegree[dep] == 0 {
|
|
||||||
ready = append(ready, dep)
|
|
||||||
sort.Strings(ready)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(order) != len(selectedSet) {
|
|
||||||
return nil, fmt.Errorf("selected scriptorium artifacts contain a dependency cycle")
|
|
||||||
}
|
|
||||||
return order, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeAnalyzeArtifact(
|
func executeAnalyzeArtifact(
|
||||||
@@ -335,34 +496,25 @@ func executeAnalyzeArtifact(
|
|||||||
artifactName := plan.Name
|
artifactName := plan.Name
|
||||||
artifactCfg := plan.Cfg
|
artifactCfg := plan.Cfg
|
||||||
|
|
||||||
inputPaths := map[string]string{}
|
resolvedInputs, err := resolveAnalyzeInputIdentities(artifactCfg.Inputs, execution)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("analyze: resolve inputs for artifact %q: %w", artifactName, err)
|
||||||
|
}
|
||||||
|
inputPaths := resolvedInputs.Paths()
|
||||||
omittedOptionalInputs := []string{}
|
omittedOptionalInputs := []string{}
|
||||||
reusedArtifacts := []map[string]any{}
|
reusedArtifacts := []map[string]any{}
|
||||||
|
for _, identity := range resolvedInputs.Ordered {
|
||||||
inputNames := sortedScriptoriumInputNames(artifactCfg.Inputs)
|
if !identity.Present {
|
||||||
for _, inputName := range inputNames {
|
omittedOptionalInputs = append(omittedOptionalInputs, identity.Name)
|
||||||
inputCfg := artifactCfg.Inputs[inputName]
|
|
||||||
resolution := resolveScriptoriumInput(inputCfg, execution)
|
|
||||||
switch resolution.State {
|
|
||||||
case analyzeInputError:
|
|
||||||
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: %w", inputName, artifactName, resolution.Err)
|
|
||||||
case analyzeInputAbsent:
|
|
||||||
if inputCfg.Required {
|
|
||||||
return nil, fmt.Errorf("analyze: required input %q for artifact %q could not be resolved", inputName, artifactName)
|
|
||||||
}
|
|
||||||
omittedOptionalInputs = append(omittedOptionalInputs, inputName)
|
|
||||||
continue
|
continue
|
||||||
case analyzeInputPresent:
|
|
||||||
inputPaths[inputName] = resolution.Path
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("analyze: resolve input %q for artifact %q: invalid resolution state", inputName, artifactName)
|
|
||||||
}
|
}
|
||||||
if resolution.Artifact != nil && resolution.Artifact.Provenance == artifacts.ArtifactProvenanceDisabledFromDisk {
|
resolvedArtifact := resolvedInputs.Artifact(identity.Name)
|
||||||
|
if resolvedArtifact != nil && resolvedArtifact.Provenance == artifacts.ArtifactProvenanceCurrentAnalyzeManifest {
|
||||||
reusedArtifacts = append(reusedArtifacts, map[string]any{
|
reusedArtifacts = append(reusedArtifacts, map[string]any{
|
||||||
"name": configuredArtifactNameFromSourceID(resolution.Artifact.ID),
|
"name": configuredArtifactNameFromSourceID(resolvedArtifact.ID),
|
||||||
"source_id": resolution.Artifact.ID,
|
"source_id": resolvedArtifact.ID,
|
||||||
"path": resolution.Artifact.Path,
|
"path": resolvedArtifact.Path,
|
||||||
"provenance": resolution.Artifact.Provenance,
|
"provenance": resolvedArtifact.Provenance,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,14 +680,32 @@ func executeAnalyzeArtifact(
|
|||||||
if err := requireNonEmptyFile(finalOutputPath, artifactName+" output"); err != nil {
|
if err := requireNonEmptyFile(finalOutputPath, artifactName+" output"); err != nil {
|
||||||
return nil, fmt.Errorf("analyze: %w", err)
|
return nil, fmt.Errorf("analyze: %w", err)
|
||||||
}
|
}
|
||||||
|
validatedOutput, err := readExternalResult(finalOutputPath, artifactName+" output")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("analyze: %w", err)
|
||||||
|
}
|
||||||
|
contract := &artifactmodel.ContractMetadata{
|
||||||
|
MediaType: "text/markdown", SchemaID: "narratio." + artifactName, SchemaVersion: "1",
|
||||||
|
}
|
||||||
|
relativeOutputPath, err := normalizedAnalyzeOutputIdentity(artifactCfg.OutputPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("analyze: normalize output identity for artifact %q: %w", artifactName, err)
|
||||||
|
}
|
||||||
materializedArtifact, err := materializeRunLocalOutput(env.ArtifactStore, finalOutputPath, canonicalOutputPath, artifacts.Ref{
|
materializedArtifact, err := materializeRunLocalOutput(env.ArtifactStore, finalOutputPath, canonicalOutputPath, artifacts.Ref{
|
||||||
Kind: artifactName,
|
Kind: artifactName,
|
||||||
Category: "artifacts",
|
SourceID: artifacts.ConfiguredArtifactSourceID(artifactName),
|
||||||
SessionID: sessionID,
|
Category: "artifacts",
|
||||||
|
SessionID: sessionID,
|
||||||
|
RelativePath: relativeOutputPath,
|
||||||
|
Contract: contract,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("analyze: materialize artifact output for %q: %w", artifactName, err)
|
return nil, fmt.Errorf("analyze: materialize artifact output for %q: %w", artifactName, err)
|
||||||
}
|
}
|
||||||
|
expectedDigest := sha256.Sum256(validatedOutput)
|
||||||
|
if materializedArtifact.Checksum != hex.EncodeToString(expectedDigest[:]) {
|
||||||
|
return nil, fmt.Errorf("analyze: materialized artifact output for %q differs from validated run-local bytes", artifactName)
|
||||||
|
}
|
||||||
|
|
||||||
logPaths = append(logPaths, stdoutLogPath, stderrLogPath)
|
logPaths = append(logPaths, stdoutLogPath, stderrLogPath)
|
||||||
generatedConfigs = append(generatedConfigs, generatedConfigPath)
|
generatedConfigs = append(generatedConfigs, generatedConfigPath)
|
||||||
@@ -561,7 +731,11 @@ func executeAnalyzeArtifact(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &analyzeArtifactExecutionResult{
|
return &analyzeArtifactExecutionResult{
|
||||||
Output: materializedArtifact,
|
Output: materializedArtifact,
|
||||||
|
OutputSize: int64(len(validatedOutput)),
|
||||||
|
Scriptorium: manifest.AnalyzeArtifactProvenance{
|
||||||
|
PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, CommandMode: res.CommandMode,
|
||||||
|
},
|
||||||
Logs: logPaths,
|
Logs: logPaths,
|
||||||
GeneratedConfigs: generatedConfigs,
|
GeneratedConfigs: generatedConfigs,
|
||||||
Metadata: meta,
|
Metadata: meta,
|
||||||
@@ -569,17 +743,6 @@ func executeAnalyzeArtifact(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractPlanNames(plans []analyzeArtifactExecutionPlan) []string {
|
|
||||||
if len(plans) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(plans))
|
|
||||||
for _, plan := range plans {
|
|
||||||
out = append(out, plan.Name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func configuredArtifactNameFromSourceID(sourceID string) string {
|
func configuredArtifactNameFromSourceID(sourceID string) string {
|
||||||
name, _ := artifactpolicy.ParseConfiguredSource(sourceID)
|
name, _ := artifactpolicy.ParseConfiguredSource(sourceID)
|
||||||
return name
|
return name
|
||||||
@@ -620,83 +783,6 @@ func discoverAnalyzeArtifactRef(m *manifest.Manifest, paths artifacts.SessionPat
|
|||||||
return resolved.Path, resolved.Provenance
|
return resolved.Path, resolved.Provenance
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveScriptoriumInput(inputCfg config.ScriptoriumInputConfig, execution analyzeExecutionContext) analyzeInputResolution {
|
|
||||||
source := strings.TrimSpace(inputCfg.Source)
|
|
||||||
descriptor, describeErr := artifactpolicy.DescribeScriptoriumInputSource(source)
|
|
||||||
if describeErr != nil {
|
|
||||||
return analyzeInputFailure(describeErr)
|
|
||||||
}
|
|
||||||
if descriptor.Source.Kind == artifactpolicy.SourceKindStableInput {
|
|
||||||
resolvedPath, ok, err := resolvePreparedStableInput(descriptor.Source.ID, execution.Paths)
|
|
||||||
if err != nil {
|
|
||||||
if inputCfg.Required {
|
|
||||||
return analyzeInputFailure(err)
|
|
||||||
}
|
|
||||||
return analyzeInputMissing()
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return analyzeInputMissing()
|
|
||||||
}
|
|
||||||
return analyzeInputFound(resolvedPath, nil)
|
|
||||||
}
|
|
||||||
if descriptor.Source.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
|
||||||
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
|
|
||||||
if err == nil {
|
|
||||||
copy := resolved
|
|
||||||
return analyzeInputFound(resolved.Path, ©)
|
|
||||||
}
|
|
||||||
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
|
|
||||||
if inputCfg.Required {
|
|
||||||
return analyzeInputFailure(fmt.Errorf(
|
|
||||||
"required previous-session input source %q is unavailable; run narratio run-stage prepare %s --force",
|
|
||||||
source,
|
|
||||||
execution.SessionID,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
return analyzeInputMissing()
|
|
||||||
}
|
|
||||||
return analyzeInputFailure(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resolved, err := artifacts.ResolveSessionArtifactWithCatalog(execution.Paths, execution.Manifest, source, execution.Catalog)
|
|
||||||
if err == nil {
|
|
||||||
copy := resolved
|
|
||||||
return analyzeInputFound(resolved.Path, ©)
|
|
||||||
}
|
|
||||||
if !errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
|
|
||||||
return analyzeInputFailure(err)
|
|
||||||
}
|
|
||||||
if !inputCfg.Required {
|
|
||||||
return analyzeInputMissing()
|
|
||||||
}
|
|
||||||
|
|
||||||
switch descriptor.Source.Kind {
|
|
||||||
case artifactpolicy.SourceKindExtraction:
|
|
||||||
return analyzeInputFailure(fmt.Errorf(
|
|
||||||
"required extraction source %q is unavailable; enable and configure pipeline.notarius output %q, then run narratio run-stage extract %s --force",
|
|
||||||
source,
|
|
||||||
descriptor.Source.ConfiguredKey,
|
|
||||||
execution.SessionID,
|
|
||||||
))
|
|
||||||
case artifactpolicy.SourceKindConfiguredArtifact:
|
|
||||||
return analyzeInputFailure(fmt.Errorf("configured artifact source %q is unavailable", source))
|
|
||||||
default:
|
|
||||||
return analyzeInputFailure(requiredBuiltInInputError(descriptor.Source.ID, execution))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func analyzeInputFound(path string, artifact *artifacts.ResolvedSessionArtifact) analyzeInputResolution {
|
|
||||||
return analyzeInputResolution{State: analyzeInputPresent, Path: path, Artifact: artifact}
|
|
||||||
}
|
|
||||||
|
|
||||||
func analyzeInputMissing() analyzeInputResolution {
|
|
||||||
return analyzeInputResolution{State: analyzeInputAbsent}
|
|
||||||
}
|
|
||||||
|
|
||||||
func analyzeInputFailure(err error) analyzeInputResolution {
|
|
||||||
return analyzeInputResolution{State: analyzeInputError, Err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
func requiredBuiltInInputError(source string, execution analyzeExecutionContext) error {
|
func requiredBuiltInInputError(source string, execution analyzeExecutionContext) error {
|
||||||
entry, ok := execution.Catalog.Lookup(source)
|
entry, ok := execution.Catalog.Lookup(source)
|
||||||
if !ok || strings.TrimSpace(entry.ProducerStage) == "" {
|
if !ok || strings.TrimSpace(entry.ProducerStage) == "" {
|
||||||
@@ -710,36 +796,6 @@ func requiredBuiltInInputError(source string, execution analyzeExecutionContext)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) (string, bool, error) {
|
|
||||||
filename, ok := preparedStableInputFilename(sourceID)
|
|
||||||
if !ok {
|
|
||||||
return "", false, fmt.Errorf("unsupported prepared input source %q", sourceID)
|
|
||||||
}
|
|
||||||
path := filepath.Join(paths.InputsDir, filename)
|
|
||||||
if err := requireNonEmptyFile(path, "prepared input "+sourceID); err != nil {
|
|
||||||
return "", false, fmt.Errorf(
|
|
||||||
"prepared input source %q is unavailable; run narratio run-stage prepare %s --force: %w",
|
|
||||||
sourceID,
|
|
||||||
paths.SessionID,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return path, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preparedStableInputFilename(sourceID string) (string, bool) {
|
|
||||||
switch strings.TrimSpace(sourceID) {
|
|
||||||
case artifactpolicy.SourceInputPlayers:
|
|
||||||
return "players.yml", true
|
|
||||||
case artifactpolicy.SourceInputParty:
|
|
||||||
return "party.yml", true
|
|
||||||
case artifactpolicy.SourceInputGlossary:
|
|
||||||
return "glossary.yml", true
|
|
||||||
default:
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildAnalyzeRuntimeArtifactCatalog(
|
func buildAnalyzeRuntimeArtifactCatalog(
|
||||||
paths artifacts.SessionPaths,
|
paths artifacts.SessionPaths,
|
||||||
m *manifest.Manifest,
|
m *manifest.Manifest,
|
||||||
@@ -759,25 +815,7 @@ func buildAnalyzeRuntimeArtifactCatalog(
|
|||||||
if notariusCfg != nil && notariusCfg.Enabled {
|
if notariusCfg != nil && notariusCfg.Enabled {
|
||||||
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
catalog.HydrateExtractionArtifacts(paths, m, extractionDefinitions)
|
||||||
}
|
}
|
||||||
|
catalog.HydrateAnalyzeArtifacts(paths, m, configured)
|
||||||
for _, entry := range catalog.ListConfigured() {
|
|
||||||
if entry.Executable {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(entry.CanonicalRelPath) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resolvedPath, err := resolveScriptoriumOutputPath(paths, entry.CanonicalRelPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := requireNonEmptyFile(resolvedPath, "configured artifact "+entry.SourceID); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := catalog.MarkAvailableFromDisk(entry.SourceID, resolvedPath); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return catalog, nil
|
return catalog, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user