Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 545aa6893b | |||
| 98139f7e8b | |||
| f8fa0a2623 | |||
| 9af773491b | |||
| 2a656f0f11 | |||
| aec35a2d9b | |||
| 23dc4e2078 | |||
| c812fe3655 | |||
| 3da97ca50c | |||
| 4c203d8588 | |||
| fcb5f825e1 | |||
| 4c57ace2f6 | |||
| dde7f76ecb | |||
| a102db36af | |||
| b5b1d22011 | |||
| c91599ef36 | |||
| 257f10c9fb | |||
| fb9a4d14f4 | |||
| c4435b76c4 | |||
| 61000a9466 | |||
| 7e4ceb3d48 | |||
| 4e991fa21d | |||
| f86b17045d | |||
| f302488075 | |||
| 8c1171478d | |||
| 7ee637803d | |||
| 4d6086fefb | |||
| 82cb53e107 | |||
| b97b12da7f | |||
| 49ea747b17 | |||
| 3b5e9db41f |
@@ -2,74 +2,26 @@ when:
|
||||
- event: tag
|
||||
|
||||
steps:
|
||||
validate:
|
||||
image: golang:1.25
|
||||
validate-release:
|
||||
image: golang:1.25.5
|
||||
commands:
|
||||
- go test ./...
|
||||
- go test -race ./...
|
||||
- go vet ./...
|
||||
- go build ./...
|
||||
- go test ./internal/doccheck
|
||||
- go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||
|
||||
cross-build:
|
||||
image: golang:1.25
|
||||
depends_on: validate
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
output_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$output_dir"' EXIT
|
||||
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
|
||||
goos="${target%/*}"
|
||||
goarch="${target#*/}"
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -o "$output_dir/narratio-$goos-$goarch" ./cmd/narratio
|
||||
done
|
||||
- ./scripts/check-release-candidate.sh "$CI_COMMIT_TAG"
|
||||
|
||||
build-release-assets:
|
||||
image: golang:1.25
|
||||
depends_on: [validate, cross-build]
|
||||
image: golang:1.25.5
|
||||
depends_on:
|
||||
- validate-release
|
||||
commands:
|
||||
- |
|
||||
set -eu
|
||||
|
||||
version="$CI_COMMIT_TAG"
|
||||
dist="dist"
|
||||
pkg="gitea.maximumdirect.net/eric/narratio/cmd/narratio"
|
||||
|
||||
rm -rf "$dist"
|
||||
mkdir -p "$dist"
|
||||
|
||||
build_binary() {
|
||||
goos="$1"
|
||||
goarch="$2"
|
||||
suffix="$3"
|
||||
output="$dist/narratio-$version-$goos-$goarch$suffix"
|
||||
|
||||
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
|
||||
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=$version" \
|
||||
-o "$output" "$pkg"
|
||||
}
|
||||
|
||||
build_binary linux amd64 ""
|
||||
build_binary linux arm64 ""
|
||||
build_binary darwin amd64 ""
|
||||
build_binary darwin arm64 ""
|
||||
build_binary windows amd64 ".exe"
|
||||
build_binary windows arm64 ".exe"
|
||||
|
||||
smoke_binary="$dist/narratio-version-smoke"
|
||||
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/narratio/internal/buildinfo.Version=$version" \
|
||||
-o "$smoke_binary" "$pkg"
|
||||
reported_version="$("$smoke_binary" version)"
|
||||
rm -f "$smoke_binary"
|
||||
if [ "$reported_version" != "narratio $version" ]; then
|
||||
echo "release binary reported unexpected version: $reported_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$PWD" in
|
||||
/*) ;;
|
||||
*) echo "release workspace must have an absolute path" >&2; exit 1 ;;
|
||||
esac
|
||||
./scripts/build-release-assets.sh "$CI_COMMIT_TAG" "$PWD/dist"
|
||||
|
||||
publish-release:
|
||||
image: woodpeckerci/plugin-release
|
||||
image: woodpeckerci/plugin-release:0.3.1
|
||||
depends_on:
|
||||
- build-release-assets
|
||||
settings:
|
||||
@@ -77,6 +29,8 @@ steps:
|
||||
from_secret: GITEA_RELEASE_TOKEN
|
||||
files:
|
||||
- dist/narratio-*
|
||||
title: Narratio ${CI_COMMIT_TAG}
|
||||
note: docs/releases/${CI_COMMIT_TAG}.md
|
||||
checksum: sha256
|
||||
checksum-file: SHA256SUMS
|
||||
checksum-flatten: true
|
||||
|
||||
@@ -27,7 +27,7 @@ This requires resolvable `pipeline.yml`, `campaign.yml`, and concrete
|
||||
- [Integration contracts](docs/integrations/) — external tools, formats, and
|
||||
compatibility expectations.
|
||||
- [Maintained examples](examples/README.md) — complete copyable configuration
|
||||
and input files.
|
||||
and input files, including the production/testing split bundle.
|
||||
|
||||
## Maintainer Documentation
|
||||
|
||||
|
||||
71
docs/cli.md
71
docs/cli.md
@@ -20,6 +20,7 @@ Top-level commands:
|
||||
- `publish <session_id>`: force-run publish.
|
||||
- `clean <session_id>` or `clean --all`: remove local work/spool state.
|
||||
- `session <subcommand>`: session helper commands.
|
||||
- `config <subcommand>`: validate, display, source-trace, or compare resolved pipeline configuration.
|
||||
|
||||
Session subcommands:
|
||||
|
||||
@@ -43,6 +44,7 @@ Most session-aware commands accept:
|
||||
- `--session <session.yml>`
|
||||
- `--session-id <session_id>`
|
||||
- `--previous-session-id <session_id>`
|
||||
- `--profile <name>`
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -51,6 +53,10 @@ Rules:
|
||||
- if both positional `<session_id>` and `--session-id` are provided, values must match.
|
||||
- `--previous-session-id` is a strict expectation: the selected session file
|
||||
must contain the same `previous_session_id`.
|
||||
- `--profile` selects a declared pipeline profile. It may be supplied once;
|
||||
an explicit empty or unknown value fails configuration resolution. When it is
|
||||
omitted, a declared `default_profile` is used. The same selection applies to
|
||||
all common-flag commands, including `regenerate-artifacts`.
|
||||
- `clean --all` cannot be combined with campaign/session selectors.
|
||||
- notification delivery is currently limited to the configured `noop` mode; see
|
||||
the [configuration reference](./config.md#notifications).
|
||||
@@ -72,6 +78,62 @@ Commands with additional positionals keep their command-specific order:
|
||||
|
||||
## Command Reference
|
||||
|
||||
### `config validate`, `config show`, `config sources`, and `config diff`
|
||||
|
||||
```bash
|
||||
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]
|
||||
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]
|
||||
narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]
|
||||
narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>]
|
||||
```
|
||||
|
||||
These commands resolve the selected profile, defaults, ordinary paths, and—if
|
||||
a campaign is selected—the campaign-owned party. They neither discover or load
|
||||
a session nor create a workspace, manifest, run, lock, adapter, remote
|
||||
connection, or credential environment.
|
||||
|
||||
Campaign selection is optional for a pipeline without party-driven artifact
|
||||
families. A pipeline with `scriptorium.artifact_families` needs a selected or
|
||||
configured default campaign so Narratio can expand its concrete artifacts and
|
||||
publish rules. `--campaign` and `--campaign-file` remain mutually exclusive.
|
||||
Session, range, force, and artifact-execution flags are not accepted.
|
||||
|
||||
`config validate` writes a concise root-path, selected-profile (or `none`), and
|
||||
effective-digest summary after successful complete validation. `config show`
|
||||
writes one deterministic, secret-free YAML document containing defaulted and
|
||||
expanded concrete configuration. It omits composition declarations, artifact
|
||||
family declarations, and runtime provenance.
|
||||
|
||||
`config sources` reports the same fully validated resolution without printing
|
||||
effective values. Its header identifies the root, ordered imports, selected
|
||||
profile and overlay, selected campaign, party mode/source, and digest. The
|
||||
remaining tab-separated records are sorted as `path`, `role`, and `source`.
|
||||
Roles distinguish root, import, profile, centralized default, campaign, party,
|
||||
legacy-player, and generated family ownership. A generated party member has
|
||||
one family record and one party record at the same logical path. The output
|
||||
never reads or prints secret values.
|
||||
|
||||
`config diff` resolves both supplied profile names from one parsed root source
|
||||
set and compares their fully resolved, secret-free effective mappings. It does
|
||||
not accept `--profile`; the two positional names must be distinct, declared
|
||||
profiles. When party-driven families are present, both profiles must resolve to
|
||||
the same selected campaign and party. Use `--campaign-file` if profile-specific
|
||||
campaign configuration would otherwise select different files.
|
||||
|
||||
Equal profiles print `no differences`. Otherwise, sorted tab-separated records
|
||||
use one of these forms, with compact JSON values:
|
||||
|
||||
```text
|
||||
added <path> <right-value>
|
||||
removed <path> <left-value>
|
||||
changed <path> <left-value> <right-value>
|
||||
```
|
||||
|
||||
Mappings are flattened to their logical field paths; lists remain one atomic
|
||||
value. The command compares defaulted concrete artifacts and publish rules, not
|
||||
profile names, source-file layout, or formatting. It succeeds when differences
|
||||
are found, making it suitable for review and migration checks.
|
||||
|
||||
### `version`
|
||||
|
||||
```bash
|
||||
@@ -102,6 +164,7 @@ Behavior:
|
||||
`--name=value` spellings;
|
||||
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||
- writes session and run manifests.
|
||||
- reports the resolved profile (or `none`) and effective configuration digest.
|
||||
|
||||
When `--artifacts` is present, the selected range must contain `analyze` or
|
||||
`publish`. Either consumer is sufficient, including a one-stage range.
|
||||
@@ -203,6 +266,8 @@ 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.
|
||||
The output includes the resolved profile (or `none`) and effective configuration
|
||||
digest without writing provenance or any manifest state.
|
||||
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
|
||||
@@ -302,6 +367,12 @@ and precedence.
|
||||
|
||||
## `--artifacts` Selection Rules
|
||||
|
||||
An artifact-family key selects all of its concrete character members. A
|
||||
concrete generated key selects only that member; mixed family and concrete
|
||||
selection is deduplicated and executed as concrete keys. The resulting plan
|
||||
and command output identify both the concrete key and, where applicable, its
|
||||
family and character ID.
|
||||
|
||||
- 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;
|
||||
|
||||
179
docs/config.md
179
docs/config.md
@@ -42,6 +42,50 @@ The downloaded remote session file is command-scoped: Narratio removes it after
|
||||
the command finishes and records only the remote object provenance alongside
|
||||
the durable copied session input.
|
||||
|
||||
### Read-only effective pipeline inspection
|
||||
|
||||
`narratio config validate`, `narratio config show`, and `narratio config
|
||||
sources` use the same `--config`, `--campaign`, `--campaign-file`, and
|
||||
`--profile` selection rules as pipeline commands, but do not select, discover,
|
||||
or load a session. They do not read credential values or create runtime state.
|
||||
`narratio config diff <left-profile> <right-profile>` uses the same pipeline and
|
||||
campaign selectors, resolves each named profile independently from one parsed
|
||||
root source set, and does not accept a separate `--profile` flag.
|
||||
|
||||
Campaign selection is optional only when the resolved pipeline has no
|
||||
`scriptorium.artifact_families`. When families are declared, Narratio selects a
|
||||
campaign through an explicit flag or `pipeline.campaigns.default_campaign_id`,
|
||||
then parses the campaign-owned party and expands concrete artifacts and any
|
||||
family publish rules before validation. `config validate` prints the resulting
|
||||
root, profile, and effective digest. `config show` emits the normalized
|
||||
effective pipeline YAML, with defaults and concrete expansion included but
|
||||
composition and family declarations omitted. `config sources` prints a stable
|
||||
source projection instead of effective values: root/import/profile/default
|
||||
ownership plus campaign/party and generated-family records. Canonical derived
|
||||
players trace to the party; a legacy configured players file is explicitly
|
||||
marked as a legacy player source. The [CLI reference](cli.md#config-validate-config-show-config-sources-and-config-diff)
|
||||
owns command syntax and output conventions.
|
||||
|
||||
`config diff` compares normalized field values rather than YAML text or source
|
||||
ownership. It emits sorted `added`, `removed`, and `changed` records, uses
|
||||
compact deterministic JSON values, treats lists atomically, and reports `no
|
||||
differences` when the complete effective configurations are equal. Concrete
|
||||
family members and generated publish rules participate after expansion; moving
|
||||
an equal value between eligible root/import sources does not create a
|
||||
difference.
|
||||
|
||||
### Migrating to the maintained bundle
|
||||
|
||||
Use the [production/testing bundle](../examples/production-testing/pipeline.yml)
|
||||
as the complete copyable migration reference. Split stable pipeline settings
|
||||
into explicit additive imports, place production/testing differences in one
|
||||
selected overlay, and retain a production `default_profile`. Convert campaign
|
||||
rosters to [canonical party input](integrations/party.md), remove a separate
|
||||
`players_file`, then express character work as families. Inspect the result
|
||||
with `config validate`, `config show`, and `config sources`; use `config diff`
|
||||
to review profiles before running a session. Unversioned parties and their
|
||||
`players_file` remain a clearly bounded legacy compatibility path.
|
||||
|
||||
### Identity segments
|
||||
|
||||
Campaign IDs (`campaign_id` and `default_campaign_id`), session IDs, previous
|
||||
@@ -55,19 +99,100 @@ remote state with an unsafe legacy identity must be migrated before use.
|
||||
|
||||
- YAML decode is strict (`KnownFields(true)`) and accepts exactly one document:
|
||||
unknown fields or trailing documents fail load.
|
||||
- A pipeline file may explicitly import additive YAML fragments through the
|
||||
root-only `composition.imports` list. Imported files contribute fields to one
|
||||
logical pipeline document; they do not override fields supplied by the root
|
||||
or another import.
|
||||
- A root pipeline may declare named profiles. Exactly one profile is selected
|
||||
by an option-aware caller or by `composition.default_profile`; a caller's
|
||||
explicit selection takes precedence. Declaring profiles without either form
|
||||
of selection is an error.
|
||||
- Configured timeout and retry-delay durations must be positive. An omitted
|
||||
artifact timeout continues to inherit its configured Scriptorium timeout.
|
||||
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
|
||||
- Pipeline defaults are applied before validation.
|
||||
- Campaign and session identities must agree.
|
||||
- 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.
|
||||
`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.
|
||||
- `party_file` is classified when pipeline and campaign configuration are
|
||||
combined. A versioned [canonical party](integrations/party.md) is
|
||||
campaign-owned, derives the players input internally, and forbids both a
|
||||
separate `players_file` and a session `party_file` override. An unversioned
|
||||
party remains a bounded legacy input and requires `players_file`; its normal
|
||||
campaign/session overrides continue to apply.
|
||||
- Exactly one audio mode must be configured in session input:
|
||||
- local (`audio_dir` or `audio_files`), or
|
||||
- S3 (`audio_s3.prefix`).
|
||||
|
||||
### Pipeline composition
|
||||
|
||||
Large pipeline configurations may be split into explicitly named fragments and
|
||||
may declare one overlay per selectable profile:
|
||||
|
||||
```yaml
|
||||
composition:
|
||||
imports:
|
||||
- config/storage.yml
|
||||
- config/integrations.yaml
|
||||
default_profile: production
|
||||
profiles:
|
||||
production:
|
||||
overlay: profiles/production.yml
|
||||
testing:
|
||||
overlay: profiles/testing.yml
|
||||
|
||||
campaigns:
|
||||
root: /usr/local/share/narratio/campaigns
|
||||
```
|
||||
|
||||
The maintained [production/testing bundle](../examples/production-testing/pipeline.yml)
|
||||
is a complete copyable example of this structure, including canonical-party
|
||||
artifact families.
|
||||
|
||||
Imports are resolved relative to the directory containing the root pipeline
|
||||
file and are loaded in declaration order. Narratio does not scan directories or
|
||||
infer fragments. Each import must be a confined regular `.yml` or `.yaml` file:
|
||||
absolute paths, traversal, symlinks, directories, duplicate files, and an
|
||||
import of the root pipeline itself are rejected. Only the root pipeline may
|
||||
contain `composition`; nested composition is rejected.
|
||||
|
||||
Composition is additive. A map may be extended by multiple files when every
|
||||
leaf is distinct, but a scalar, list, or map/list/scalar kind cannot be claimed
|
||||
more than once, even when the repeated values are identical. Conflict errors
|
||||
name the full field path and every source that claimed it. The assembled YAML is
|
||||
then decoded against the normal strict pipeline schema and defaults are applied
|
||||
once.
|
||||
|
||||
Profile names are case-sensitive, non-empty, trimmed, and cannot contain
|
||||
control characters. If `profiles` is present, it must contain at least one
|
||||
entry and every entry must contain only an `overlay` path. An explicit profile
|
||||
selection overrides `default_profile`; unknown and explicitly empty selections
|
||||
fail. Narratio never selects the first profile implicitly and does not read a
|
||||
profile selection from the environment.
|
||||
|
||||
Every declared overlay is resolved relative to the root pipeline directory and
|
||||
must satisfy the same confined regular-YAML-file rules as an import. Narratio
|
||||
parses every declared overlay even when it is not selected, then applies only
|
||||
the selected one. Maps merge recursively, overlay scalars replace base scalars,
|
||||
and overlay lists replace base lists completely. Explicit `false`, zero, empty
|
||||
lists, and empty maps remain meaningful. YAML null cannot delete a value, and
|
||||
kind changes are rejected. Profiles cannot inherit from or stack with other
|
||||
profiles, and overlays cannot import files or declare profiles.
|
||||
|
||||
After composition, Narratio strictly decodes the result, applies centralized
|
||||
defaults once, resolves ordinary paths, and computes a deterministic effective
|
||||
configuration digest. The digest represents the normalized, secret-free
|
||||
runtime pipeline mapping; it excludes composition declarations, source
|
||||
provenance, profile identity, and raw environment secret values. Equivalent
|
||||
effective mappings therefore have the same digest regardless of how fields are
|
||||
split among the root and imports.
|
||||
|
||||
An imported field has the same meaning it would have in a monolithic root
|
||||
pipeline. In particular, ordinary relative pipeline paths continue to resolve
|
||||
from the root pipeline directory, not from the importing fragment's directory.
|
||||
|
||||
## Minimal Working Configuration
|
||||
|
||||
`pipeline.yml`
|
||||
@@ -155,6 +280,9 @@ Rules:
|
||||
|
||||
| Field | Type | Required | Default / Rule |
|
||||
| --- | --- | --- | --- |
|
||||
| `composition.imports[]` | list of strings | No | explicit additive pipeline fragments relative to the root pipeline directory; `.yml` or `.yaml` regular files only |
|
||||
| `composition.default_profile` | string | Conditional | selected when profiles exist and no caller explicitly selects one; must name a declared profile |
|
||||
| `composition.profiles.<name>.overlay` | string | Conditional | required for every declared profile; one confined `.yml` or `.yaml` overlay relative to the root pipeline directory |
|
||||
| `pipeline.workspace.root` | string | No | `/var/lib/narratio` |
|
||||
| `pipeline.workspace.cleanup_after_publish` | bool | No | `false` |
|
||||
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
|
||||
@@ -243,6 +371,7 @@ Rules:
|
||||
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
|
||||
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
|
||||
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
||||
| `pipeline.scriptorium.artifact_families` | map | No | empty; expands one ordinary artifact per canonical party character |
|
||||
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
|
||||
|
||||
### Notarius Reference Bindings
|
||||
@@ -342,6 +471,42 @@ For each artifact input `pipeline.scriptorium.artifacts.<name>.inputs.<input_nam
|
||||
loading. Use the canonical `source` identifier to select the input; Narratio
|
||||
does not provide adapter-specific input passthrough fields.
|
||||
|
||||
### Scriptorium Artifact Families
|
||||
|
||||
`pipeline.scriptorium.artifact_families` declares a shared artifact template
|
||||
for every canonical campaign character. Configuration resolution expands each
|
||||
family into ordinary `pipeline.scriptorium.artifacts` entries before analyze
|
||||
planning or Scriptorium invocation. A legacy party cannot be used for a family.
|
||||
|
||||
For each `pipeline.scriptorium.artifact_families.<name>`:
|
||||
|
||||
| Field | Type | Required | Rule |
|
||||
| --- | --- | --- | --- |
|
||||
| `enabled`, `prompt_id`, `profile_id`, `timeout`, `render_debug`, `depends_on`, `inputs`, `vars` | ordinary artifact fields | No | copied to each generated artifact under the corresponding ordinary rules |
|
||||
| `for_each` | string | Yes | exactly `party.characters` |
|
||||
| `output_path_pattern` | string | Yes | safe path beneath `artifacts/` with exactly one `{character_id}` token and no other brace syntax |
|
||||
| `member_vars` | map | No | maps an ordinary Scriptorium variable name to a supported canonical character selector |
|
||||
| `member_dependencies` | list | No | unique family keys; each generated member depends on the corresponding generated member of each listed family |
|
||||
| `publish` | map | No | typed family publish policy (`enabled`, `required`, `dest_pattern`) expanded into concrete publish outputs when enabled |
|
||||
|
||||
Generated keys are `<family>_<character_id>` and generated output paths must
|
||||
not collide with explicit artifacts or another generated artifact. Families
|
||||
expand even when disabled; normal analyze selection still omits disabled
|
||||
artifacts unless they are explicitly selected by their concrete key.
|
||||
|
||||
Supported `member_vars` selectors are `character_id`, `player.name`,
|
||||
`character.name`, `character.class_summary`, and `character.alias_summary`.
|
||||
Their resolved values are strings. A member variable may not reuse a static
|
||||
`vars` name; `session_id` remains owned and overwritten by Narratio as for any
|
||||
other Scriptorium artifact.
|
||||
|
||||
Within a family only, an input source may use
|
||||
`narratio.member_artifact.<family>`. The referenced family must be named in
|
||||
that family's `member_dependencies`; resolution rewrites the source to the
|
||||
corresponding ordinary `narratio.artifact.<family>_<character_id>` source.
|
||||
This syntax is rejected in explicit artifacts and never reaches runtime stages
|
||||
or Scriptorium.
|
||||
|
||||
### Notifications
|
||||
|
||||
Narratio currently supports only `notification.mode: noop`, which is also the
|
||||
@@ -359,8 +524,8 @@ integration.
|
||||
| `inputs.speakers_file` | string | Yes | stable input default |
|
||||
| `inputs.autocorrect_file` | string | Yes | stable input default |
|
||||
| `inputs.glossary_file` | string | Yes | stable input default |
|
||||
| `inputs.players_file` | string | Yes | stable input default |
|
||||
| `inputs.party_file` | string | Yes | stable input default |
|
||||
| `inputs.players_file` | string | Conditional | required only with an unversioned legacy `party_file`; forbidden for a canonical party |
|
||||
| `inputs.party_file` | string | Yes | stable campaign party source; relative paths resolve from `campaign.yml` |
|
||||
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
||||
|
||||
### Session
|
||||
@@ -375,8 +540,8 @@ integration.
|
||||
| `inputs.speakers_file` | string | No | overrides campaign stable input |
|
||||
| `inputs.autocorrect_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.party_file` | string | No | overrides campaign stable input |
|
||||
| `inputs.players_file` | string | No | legacy-party override; forbidden for a canonical party |
|
||||
| `inputs.party_file` | string | No | legacy-party override; forbidden for a canonical campaign party |
|
||||
| `inputs.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
||||
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
||||
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
||||
|
||||
@@ -25,6 +25,7 @@ polished transcripts and generated artifacts. Start with the
|
||||
| Adapters or external tool contracts | [Adapter Internals](internal/adapters.md) and [Integration Contracts](integrations/README.md) | The internal guide owns adapter composition and mechanics; integration documents own external formats and protocols. |
|
||||
| Manifests, artifacts, workspace paths, or publish behavior | [Manifest Internals](internal/manifest.md), [Artifact Internals](internal/artifacts.md), [Workspace Internals](internal/workspace.md), [Publish Internals](internal/stage-publish.md), and [Operations](operations.md) | These separate implementation state and resolution from operator-visible layout and lifecycle. |
|
||||
| Maintained configuration or input examples | [Configuration](config.md) and [Examples](../examples/README.md) | The reference owns field meanings; the examples directory owns complete copyable files. |
|
||||
| Preparing, validating, or publishing a release | [Release Procedure](release.md) | The maintainer procedure owns version selection, candidate validation, guarded tag publication, and optional later CI inspection. |
|
||||
| Proposed or unimplemented behavior | `docs/roadmap/` | Future work belongs only in roadmap documentation until implemented. |
|
||||
|
||||
For an existing subsystem, also inspect its focused tests and package-level
|
||||
@@ -46,9 +47,9 @@ go test ./internal/config -run '^TestExamplesLoadAndValidate$'
|
||||
|
||||
The documentation check verifies local Markdown links and the dependency graph
|
||||
of the Woodpecker workflows. The configuration check loads every maintained
|
||||
pipeline and session example. Release automation repeats these checks and
|
||||
cross-compiles the CLI before it builds release assets; publishing depends on
|
||||
that validation path, so a failure cannot publish a release.
|
||||
pipeline and session example. Tag CI reuses this validation path before its
|
||||
asynchronous asset publication; the maintainer release boundary is documented
|
||||
in the [Release Procedure](release.md).
|
||||
|
||||
Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled
|
||||
job to expose ordering and repeatability defects. Current runners cross-compile
|
||||
|
||||
@@ -21,6 +21,7 @@ focused stage documents.
|
||||
- [Audita](./audita.md): transcript polishing (`audita process`).
|
||||
- [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle
|
||||
discovery (`notarius run`).
|
||||
- [Party](./party.md): canonical campaign roster input.
|
||||
- [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
|
||||
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering
|
||||
(`scriptorium run|render`).
|
||||
|
||||
@@ -41,6 +41,12 @@ 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`.
|
||||
|
||||
For canonical party configuration, the `party` binding is the unchanged,
|
||||
validated authored roster and the `players` binding is its generated
|
||||
projection. Both retain their established `narratio.input.party` and
|
||||
`narratio.input.players` source IDs, and both are resolved from the prepared
|
||||
manifest rather than from campaign configuration at extraction time.
|
||||
|
||||
The maintained D&D boundary binds only the four campaign-owned external slots:
|
||||
|
||||
```text
|
||||
|
||||
73
docs/integrations/party.md
Normal file
73
docs/integrations/party.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Canonical Party Input
|
||||
|
||||
`party.yml` is a campaign-owned roster input. Narratio recognizes the
|
||||
versioned `narratio.party.v1` document below when it resolves a pipeline,
|
||||
campaign, and session together.
|
||||
|
||||
```yaml
|
||||
schema_version: narratio.party.v1
|
||||
|
||||
characters:
|
||||
arannis:
|
||||
player:
|
||||
name: Eric
|
||||
character:
|
||||
name: Arannis
|
||||
alias:
|
||||
- Ari
|
||||
- The Grey Owl
|
||||
classes:
|
||||
- name: wizard
|
||||
level: 8
|
||||
```
|
||||
|
||||
`characters` is a non-empty mapping. Each key is a stable character ID using
|
||||
the configured-artifact key grammar: a lowercase ASCII letter followed by zero
|
||||
or more lowercase ASCII letters, digits, or underscores. Character order is
|
||||
preserved where roster order matters.
|
||||
|
||||
Every entry has `player.name`, `character.name`, and a non-empty
|
||||
`character.classes` list. Class entries require a non-empty `name` and may
|
||||
include a positive integer `level`. The optional, intentionally singular
|
||||
`character.alias` field is a list. Names, aliases, and class names must be
|
||||
non-empty, trimmed display strings without control characters. Character names
|
||||
and aliases must be unique across the full roster under Unicode-aware
|
||||
case-insensitive comparison; player names may repeat.
|
||||
|
||||
The document has exactly one YAML document and accepts no unknown fields. A
|
||||
wrong or malformed `schema_version` is an error.
|
||||
|
||||
## Legacy migration boundary
|
||||
|
||||
An unversioned party input remains supported only as opaque legacy reference
|
||||
material while campaigns migrate. It requires a separate `players_file` and
|
||||
retains the existing session override behavior. It cannot be mixed with a
|
||||
canonical party: canonical campaigns must omit `players_file`, and sessions
|
||||
must not override their party or players inputs.
|
||||
|
||||
Use the canonical document for new campaigns. The configuration rules and
|
||||
source-relative path behavior are defined in the [Configuration Reference](../config.md).
|
||||
|
||||
## Derived players document
|
||||
|
||||
During `prepare`, Narratio copies the canonical party source bytes unchanged
|
||||
to `inputs/party.yml` and writes this deterministic players-only projection to
|
||||
`inputs/players.yml`:
|
||||
|
||||
```yaml
|
||||
schema_version: narratio.players.v1
|
||||
players:
|
||||
- name: Eric
|
||||
character:
|
||||
id: arannis
|
||||
name: Arannis
|
||||
alias:
|
||||
- Ari
|
||||
- The Grey Owl
|
||||
```
|
||||
|
||||
There is one entry per character, sorted by stable character ID. Repeated
|
||||
player names remain separate entries. The optional `alias` list retains its
|
||||
declared order and is omitted when empty. The projection carries no class
|
||||
data. Its prepared manifest record is marked `derived_from_party`; it is not a
|
||||
separate user-provided `players_file`.
|
||||
@@ -39,7 +39,10 @@ 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.
|
||||
kind and prepared filename vocabulary. Canonical party mode preserves the
|
||||
party source bytes in the party record and supplies the players record from the
|
||||
deterministic `derived_from_party` projection; both remain ordinary prepared
|
||||
source IDs for consumers.
|
||||
|
||||
## Runtime Catalog
|
||||
|
||||
@@ -57,6 +60,12 @@ exact named configured definitions become the effective set for that invocation,
|
||||
regardless of their `enabled` value. The effective-set resolver itself does not
|
||||
expand dependencies; the analyze work planner closes those targets over their
|
||||
configured prerequisite graph. Availability is separate from executability.
|
||||
Configuration may normalize a family selection into its concrete generated
|
||||
members before this resolver runs. The effective set retains optional family
|
||||
and character origin metadata, but its keys, catalog sources, and runtime
|
||||
lookups remain concrete configured-artifact identities.
|
||||
Family publish policies are likewise expanded into ordinary configured-source
|
||||
publish rules during configuration resolution.
|
||||
Configured outputs, including non-executable prerequisites, become available
|
||||
only when the versioned analyze state identifies a current result whose source,
|
||||
contract, canonical configured path, size, and checksum match a confined
|
||||
@@ -99,6 +108,10 @@ Configured sources (`narratio.artifact.*`):
|
||||
rewriting manifest state. Catalog construction iterates current
|
||||
configuration, so removed or renamed records are not advertised.
|
||||
|
||||
`narratio.member_artifact.*` is not a runtime source family. Configuration
|
||||
resolution accepts it only in an artifact-family declaration and rewrites it
|
||||
to the corresponding configured source before this catalog is built.
|
||||
|
||||
Prepared stable sources (`narratio.input.*`):
|
||||
|
||||
- resolve only from the current manifest's exact prepared-input record;
|
||||
|
||||
180
docs/internal/configuration.md
Normal file
180
docs/internal/configuration.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Configuration Internals
|
||||
|
||||
User-visible fields, defaults, and selection behavior belong in the
|
||||
[Configuration Reference](../config.md). This document describes the internal
|
||||
pipeline-loading boundary implemented by `internal/config`.
|
||||
|
||||
## Pipeline Loading
|
||||
|
||||
`LoadPipeline` assembles and validates a pipeline in this order:
|
||||
|
||||
1. Parse the root YAML into a presence-aware composition tree. The tree retains
|
||||
source names, full field paths, node kinds, declaration order, and explicit
|
||||
zero, false, empty-map, and empty-list values.
|
||||
2. Remove the root-only `composition` envelope and validate its explicit
|
||||
`imports`, `default_profile`, and named `profiles` declarations. A load
|
||||
option retains the difference between omitted and explicitly empty profile
|
||||
selection.
|
||||
3. Open each import relative to the root pipeline directory through the
|
||||
confined regular-file boundary. Imports must use a `.yml` or `.yaml`
|
||||
extension and cannot traverse, use symlinks, repeat a file, import the root,
|
||||
or contain another composition envelope.
|
||||
4. Resolve and structurally parse every declared profile overlay through the
|
||||
same confined regular-file boundary. Missing or malformed unselected
|
||||
overlays fail the load. Overlays cannot contain a composition envelope.
|
||||
5. Additively merge the root body and imports. Distinct map leaves compose;
|
||||
repeated scalar or list paths and node-kind disagreements are conflicts.
|
||||
6. Select exactly one declared profile from an explicit option or the default,
|
||||
then recursively merge its overlay. Overlay leaves replace base leaves,
|
||||
lists are atomic replacements, and null or kind changes fail.
|
||||
7. Emit deterministic canonical YAML and strictly decode it into
|
||||
`PipelineConfig`.
|
||||
8. Apply pipeline defaults once, resolve ordinary relative pipeline paths from
|
||||
the root pipeline file, and digest the normalized effective mapping.
|
||||
|
||||
This ordering preserves monolithic configuration behavior. Moving a field to
|
||||
an imported fragment changes its source ownership, not its path base, default,
|
||||
or schema semantics.
|
||||
|
||||
## Loaded Context Resolution
|
||||
|
||||
`LoadedPipelineCampaign` carries one already composed pipeline and its selected
|
||||
campaign into session resolution. `LoadSessionWithPipelineCampaignOptions`
|
||||
loads a local session against that context, while
|
||||
`ResolveLoadedPipelineCampaign` also accepts an already loaded remote session
|
||||
or no session while a caller retrieves one. Compatibility loaders route through
|
||||
these functions after their initial pipeline and campaign reads.
|
||||
|
||||
Application commands own pipeline and campaign discovery, campaign-file versus
|
||||
registry selection, and the corresponding mutual-exclusion rules. Once they
|
||||
have a `LoadedPipelineCampaign`, local session discovery and remote-session
|
||||
download retain that exact pipeline object and its private provenance. Removing
|
||||
a temporary downloaded session file therefore cannot invalidate the resolved
|
||||
pipeline or campaign context.
|
||||
|
||||
The application also has a separate read-only inspection resolver for `config
|
||||
validate`, `config show`, and `config sources`. It uses the same production root/profile and
|
||||
campaign selection functions, but never routes through session discovery,
|
||||
remote-session download, secret loading, adapter composition, workspace
|
||||
initialization, manifest access, or cleanup. A pipeline with retained artifact
|
||||
family declarations must resolve its selected campaign before ordinary pipeline
|
||||
validation, which expands its canonical-party members and generated publish
|
||||
rules. A pipeline without those declarations may be validated by itself.
|
||||
|
||||
`MarshalEffectivePipeline` is the configuration-owned projection for `config
|
||||
show`. It serializes the typed, defaulted effective mapping through the
|
||||
deterministic composition renderer, then removes resolution-only artifact
|
||||
family declarations. The result contains no composition envelope or private
|
||||
provenance fields and has one trailing newline; commands do not marshal runtime
|
||||
objects directly.
|
||||
|
||||
`EffectivePipelineSources` and `EffectiveCampaignSources` provide the separate
|
||||
safe provenance projection for `config sources`. Pipeline ownership begins with
|
||||
the complete logical field paths retained during composition and classifies
|
||||
each contributor as root, import, profile, or centralized default. The
|
||||
projection replaces generated concrete member paths with paired family and
|
||||
canonical-party records, and does the same for generated publish rules.
|
||||
Campaign records identify campaign-owned fields and party inputs; canonical
|
||||
derived players point to the party source, while legacy players retain a
|
||||
dedicated legacy-player role. The application command only joins these sorted
|
||||
records with selection metadata and never reparses configuration files.
|
||||
|
||||
`config diff` uses a paired profile loader that parses the root, imports, and
|
||||
declared overlays once, then clones the additive base before independently
|
||||
selecting, decoding, defaulting, and finalizing each profile. When campaign
|
||||
resolution is needed, the command loads one selected campaign and party and
|
||||
expands both effective pipelines from that same party value. The configuration
|
||||
owner projects each normalized effective mapping into sorted logical paths;
|
||||
mapping leaves are compared individually while sequence values remain atomic.
|
||||
Values are compact deterministic JSON representations for command output, not
|
||||
raw YAML fragments, ownership records, or secret material. A differing digest
|
||||
with no projected difference is treated as an internal consistency error.
|
||||
|
||||
Campaign context construction also reads and classifies the campaign-owned
|
||||
party source through `ParseParty`. A canonical party retains its raw bytes and
|
||||
normalized roster in runtime-only `ResolvedParty` provenance, while a legacy
|
||||
party remains opaque. Canonical resolution creates a virtual
|
||||
`derived_from_party` players input and rejects competing campaign or session
|
||||
players files and session party overrides. The compact legacy compatibility
|
||||
path resolves the effective campaign/session party and players files together.
|
||||
|
||||
## Canonical Party Domain
|
||||
|
||||
`ParseParty` is the package-owned boundary for classifying a party source.
|
||||
When a top-level `schema_version` is present, it strictly validates the
|
||||
`narratio.party.v1` contract into ordered character domain values. The
|
||||
canonical value retains a separate exact byte copy of its source so consumers
|
||||
can materialize the authored party document without reserializing it. Its
|
||||
`PlayersYAML` method deterministically derives the versioned players-only
|
||||
projection.
|
||||
|
||||
An unversioned source is classified by the small legacy compatibility boundary
|
||||
in `party_legacy.go`; it deliberately exposes no parsed roster information.
|
||||
That boundary exists solely to isolate removable compatibility behavior from
|
||||
the canonical parser.
|
||||
|
||||
## Diagnostics And Runtime Metadata
|
||||
|
||||
Syntax, duplicate-key, composition, conflict, and schema failures include the
|
||||
relevant source name and full field path. Additive conflicts report every
|
||||
claiming source so operators can repair the split without repeatedly
|
||||
rediscovering additional conflicts.
|
||||
|
||||
The loaded pipeline retains private runtime metadata for the absolute root
|
||||
path, ordered imports, selected profile name and selection source, selected
|
||||
overlay, contributing sources, effective digest, and leaf ownership. Base
|
||||
leaves retain their root/import owners, replaced leaves belong to the selected
|
||||
overlay, and centrally supplied values use the synthetic `default` owner. This
|
||||
metadata does not participate in YAML decoding or alter the public
|
||||
configuration model.
|
||||
|
||||
The effective digest is SHA-256 over deterministic canonical YAML produced from
|
||||
the defaulted `PipelineConfig`. Runtime Notarius paths remain absolute for
|
||||
execution, but the digest substitutes their normalized logical values captured
|
||||
before root-relative resolution, so relocating an equivalent configuration
|
||||
bundle does not change provenance. Because composition and resolution metadata
|
||||
are private, the digest excludes source layout, profile name, and ownership.
|
||||
Configuration stores environment variable names rather than resolving raw
|
||||
credentials, so raw secret values are neither loaded nor hashed.
|
||||
`recomputePipelineEffectiveDigest` is the single package-owned refresh point
|
||||
for later runtime expansion.
|
||||
|
||||
## Test Surfaces
|
||||
|
||||
`composition_test.go` protects the presence and merge algebra independently of
|
||||
the public schema. `pipeline_composition_test.go` exercises explicit imports,
|
||||
confinement, conflicts, strict decoding, metadata, and root-relative path
|
||||
behavior through `LoadPipeline`. `pipeline_profiles_test.go` covers selection,
|
||||
all-overlay validation, overlay behavior, provenance, option propagation, and
|
||||
effective-digest stability. Application configuration-loader tests protect the
|
||||
single-read boundary by changing the pipeline file after its initial load and
|
||||
confirming local session resolution retains the original pipeline. Other
|
||||
configuration tests continue to protect defaults and validation after assembly.
|
||||
`party_test.go` protects the versioned party schema, domain invariants, and
|
||||
deterministic players projection without involving campaign or runtime wiring.
|
||||
`party_resolution_test.go` protects campaign-owned party loading, canonical
|
||||
input restrictions, legacy overrides, source provenance, and virtual players
|
||||
input selection.
|
||||
|
||||
## Artifact Family Resolution
|
||||
|
||||
Pipeline loading retains `scriptorium.artifact_families` as a resolution-only
|
||||
declaration. Once campaign party resolution establishes a canonical roster,
|
||||
configuration expands families in sorted family-key and character-ID order
|
||||
into ordinary `ScriptoriumArtifactConfig` values. The expansion owns the narrow
|
||||
`{character_id}` output substitution, closed member-variable selectors, key and
|
||||
output collision checks, and the runtime-only family-origin catalog. It then
|
||||
removes family declarations from `ScriptoriumConfig`, runs ordinary Scriptorium
|
||||
validation, and refreshes the effective pipeline digest. Stages and adapters
|
||||
therefore receive only concrete artifact maps.
|
||||
|
||||
The catalog retains sorted family member keys plus family/character/source
|
||||
origins and the typed dependency/publish declarations for their later owners.
|
||||
`member_dependencies` add corresponding ordinary concrete dependencies, while
|
||||
the family-only `narratio.member_artifact.<family>` input form is rewritten to
|
||||
the matching ordinary configured-artifact source. The catalog records those
|
||||
resolved dependency and input identities with their declaring family and party
|
||||
member. No member-artifact source is registered as a runtime policy source.
|
||||
An enabled family publish declaration expands to ordinary configured-artifact
|
||||
publish rules before the existing publish and lock validators run. Runtime
|
||||
publication consequently receives no family wildcard or special matcher.
|
||||
@@ -25,6 +25,12 @@ portable opaque segments. Unsafe legacy identities are rejected with migration
|
||||
guidance rather than being normalized into a different workspace or remote
|
||||
namespace.
|
||||
|
||||
Prepare records independent `party` and `players` input checksums. In canonical
|
||||
party mode, the party record retains its campaign source identity while the
|
||||
players record uses `derived_from_party`; raw roster content is never embedded
|
||||
in manifest metadata. Both records remain the durable authority for consumers
|
||||
of their prepared input source IDs.
|
||||
|
||||
The model admits these stage states:
|
||||
|
||||
- `pending`
|
||||
@@ -60,6 +66,11 @@ 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.
|
||||
|
||||
Family-produced records additionally retain optional `family` and
|
||||
`character_id` provenance supplied by configuration resolution. These fields
|
||||
do not replace the concrete configured key or infer family membership from a
|
||||
name, so older records without them remain valid.
|
||||
|
||||
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
|
||||
@@ -113,6 +124,7 @@ does not turn incidental canonical bytes into manifest authority.
|
||||
`manifest.RunManifest` is created for each invocation and records:
|
||||
|
||||
- invocation identity and `force` flag
|
||||
- the selected profile (when any) and secret-free effective configuration digest
|
||||
- requested stages
|
||||
- per-stage action (`run` or `skip`)
|
||||
- per-stage status
|
||||
@@ -156,7 +168,9 @@ durable; callers must reload it before retrying.
|
||||
|
||||
The application runner marks an executing stage running and then succeeded or
|
||||
failed in both manifests, persisting each transition. On success it records
|
||||
outputs, logs, generated configuration references, and metadata. Artifact
|
||||
outputs, logs, generated configuration references, metadata, and—when the
|
||||
stage implements the optional contract—a versioned semantic-configuration
|
||||
fingerprint. Artifact
|
||||
records may include optional contract and external provenance objects; old
|
||||
manifests remain compatible when those fields are absent. A successful forced
|
||||
rerun marks only succeeded transitive dependent session-stage records stale.
|
||||
@@ -165,7 +179,8 @@ dependents are returned in canonical order. Render and extract therefore never
|
||||
stale one another, while either can stale analyze, publish, and notify.
|
||||
|
||||
Starting an execution clears the current session-stage record's prior outputs,
|
||||
logs, generated configuration references, and metadata. Failed and skipped
|
||||
logs, generated configuration references, metadata, and semantic fingerprint.
|
||||
Failed and skipped
|
||||
transitions enforce the same clearing rule directly, while success repopulates
|
||||
only fields returned by the new result. Marking a record stale does not clear
|
||||
those details because resume validation and diagnosis may still require them
|
||||
@@ -184,20 +199,69 @@ session-stage record along with older logs, generated configuration references,
|
||||
and metadata, then applies any bounded details from the current skip and
|
||||
continues. This self-skip is distinct from deciding not to execute an
|
||||
already-succeeded stage and is reconsidered on later runs. Skipped results
|
||||
cannot contain outputs.
|
||||
cannot contain outputs. An intentional self-skip records the current semantic
|
||||
fingerprint because it is a completed, reusable stage result; failed or
|
||||
interrupted work never promotes one.
|
||||
|
||||
When an already-succeeded stage is skipped, the invocation run manifest records
|
||||
the `skip` action and reason. The session manifest deliberately retains its
|
||||
existing succeeded record because it remains the cross-invocation progress
|
||||
authority. Extraction and analyze have resume validators and may reject an
|
||||
authority. If a stage supplies semantic configuration evidence, reuse first
|
||||
requires the persisted positive schema version and lowercase SHA-256 digest to
|
||||
match the current resolved stage semantics. Missing legacy evidence, malformed
|
||||
evidence, or a mismatch makes the stage and its fixed transitive dependents
|
||||
stale. The invocation skip copies the matched fingerprint for provenance but
|
||||
does not rewrite session authority. The existing stage-specific resume
|
||||
validator runs only after this semantic check succeeds; both checks are
|
||||
required. Extraction and analyze have resume validators and may reject an
|
||||
otherwise eligible skip when their selected durable evidence is obsolete; the
|
||||
runner marks the aggregate record stale and executes it. Analyze's validator
|
||||
can still accept a partial selection when only unrelated artifact records are
|
||||
stale.
|
||||
|
||||
Implemented reuse coverage is deliberately split between aggregate semantic
|
||||
evidence and focused durable validators:
|
||||
|
||||
| Work | Reuse authority | Focused owners |
|
||||
| --- | --- | --- |
|
||||
| prepare | aggregate semantic fingerprint | [prepare](stage-prepare.md) |
|
||||
| transcribe | aggregate semantic fingerprint | [transcribe](stage-transcribe.md), [WhisperX](../integrations/whisperx.md) |
|
||||
| merge | aggregate semantic fingerprint | [merge](stage-merge.md), [Seriatim](../integrations/seriatim.md) |
|
||||
| polish | aggregate semantic fingerprint | [polish](stage-polish.md), [Audita](../integrations/audita.md) |
|
||||
| normalize | aggregate semantic fingerprint | [normalize](stage-normalize.md), [Seriatim](../integrations/seriatim.md) |
|
||||
| trim | aggregate semantic fingerprint | [trim](stage-trim.md), [Scriptorium](../integrations/scriptorium.md), [Seriatim](../integrations/seriatim.md) |
|
||||
| render | aggregate semantic fingerprint | [render](stage-render.md), [Seriatim](../integrations/seriatim.md) |
|
||||
| extract | aggregate semantic fingerprint plus reference/output validator | [extract](stage-extract.md), [Notarius](../integrations/notarius.md) |
|
||||
| analyze artifacts | per-artifact fingerprint, reconciliation, and output validator | [analyze](stage-analyze.md), [Scriptorium](../integrations/scriptorium.md) |
|
||||
| publish | aggregate semantic fingerprint plus immediate lock/commit checks | [publish](stage-publish.md), [storage adapter](adapters.md) |
|
||||
| notify | aggregate delivery-mode fingerprint | [pipeline overview](overview.md), [configuration](../config.md#notifications) |
|
||||
|
||||
These contracts record resolved choices Narratio can observe, not operational
|
||||
runner tuning. External model, module, prompt, profile, and configuration-file
|
||||
contents that a tool privately loads remain outside the contract when their
|
||||
configured identifier is unchanged; operators must force the affected work
|
||||
after such a private content change.
|
||||
|
||||
Session manifest is the authoritative stage-progress ledger across invocations.
|
||||
Run manifest is invocation-scoped audit state.
|
||||
|
||||
Both manifests retain the most recently resolved invocation's bounded
|
||||
configuration provenance. It identifies the selected profile name and source
|
||||
(`default` or `cli`) plus the effective configuration digest, but never a raw
|
||||
secret or profile content. This provenance is informational: it does not
|
||||
participate in stage resume or cache decisions. A profile change therefore
|
||||
invalidates only stages whose semantic configuration changed. When a private
|
||||
external-tool model, module, prompt, or profile changes behind an unchanged
|
||||
configured identifier, use `--force` for the affected work.
|
||||
|
||||
`session plan` computes the same current fingerprint and applies the same
|
||||
comparison and invalidation rules to a cloned manifest. It predicts the runner
|
||||
decision without persisting session or invocation state. The shared helper
|
||||
hashes deterministic JSON from stage-owned typed structs; stage providers must
|
||||
exclude secrets, complete effective-configuration dumps, and operational
|
||||
values that cannot affect canonical results. Concrete coverage is owned by the
|
||||
focused stage and integration documents linked above.
|
||||
|
||||
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
|
||||
@@ -228,6 +292,9 @@ where a durable running record can require operator interpretation.
|
||||
## Invariants
|
||||
|
||||
- stage resume/skip decisions are session-manifest driven.
|
||||
- semantic fingerprint comparison precedes stage-specific resume validation.
|
||||
- only successful and intentional-skipped results promote current semantic
|
||||
evidence; invocation reuse copies evidence without replacing session state.
|
||||
- running, failed, and self-skipped stages do not retain result payloads from
|
||||
an earlier success.
|
||||
- stale stages retain prior details until replacement execution starts.
|
||||
|
||||
@@ -28,7 +28,7 @@ progress and artifact services resolve durable inputs and outputs.
|
||||
| --- | --- | --- |
|
||||
| Executable | `cmd/narratio` | Process entry, standard stream wiring, argument handoff, and exit status. |
|
||||
| Application orchestration | `internal/app` | Command dispatch, configuration selection, secret-file environment loading, production composition, session locking, planning, execution, restore, cleanup gates, and user-facing reporting. |
|
||||
| Configuration | `internal/config` | Strict YAML loading, discovery, defaults, normalization, session templating, and validation. |
|
||||
| Configuration | [`internal/config`](configuration.md) | Presence-aware root/import/profile composition, canonical party and family expansion, strict YAML loading, defaults, normalization, session templating, and validation. |
|
||||
| Pipeline stages | `internal/stage` | Canonical stage registry, shared stage contract, execution dependencies, and implemented stage behavior. |
|
||||
| External boundaries | `internal/adapters`, `internal/audio` | WhisperX HTTP, downstream subprocesses, notification, object storage, and S3 audio materialization behind Narratio contracts. |
|
||||
| Manifests | `internal/manifest` | Durable session progress, invocation audit state, stage transitions, validation, and atomic persistence. |
|
||||
@@ -47,9 +47,11 @@ 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.
|
||||
missing session file through configured remote storage. It retains the initially
|
||||
composed pipeline and selected campaign while resolving either a local or
|
||||
downloaded remote session, so one invocation cannot mix pipeline revisions.
|
||||
Analyze planning additionally exposes the artifact closure's targets,
|
||||
prerequisite rebuilds, execution order, and current reuse.
|
||||
|
||||
## Pipeline Stage Set
|
||||
|
||||
@@ -68,8 +70,9 @@ The implemented canonical order is:
|
||||
11. `notify` (no-op)
|
||||
|
||||
`notify` currently has no persisted pipeline outputs and uses the explicit
|
||||
`noop` notification mode. The focused stage documents own implementation
|
||||
mechanics. The
|
||||
`noop` notification mode. Its versioned semantic evidence records that delivery
|
||||
mode and excludes adapter credentials and response data. The focused stage
|
||||
documents own implementation mechanics. The
|
||||
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
|
||||
and execution semantics.
|
||||
|
||||
@@ -82,6 +85,8 @@ trimmed transcript state: neither invalidates the other, while either can stale
|
||||
|
||||
## Focused Documentation
|
||||
|
||||
- [Configuration Internals](configuration.md): pipeline composition, import
|
||||
confinement, field ownership, decoding, and root-relative path semantics.
|
||||
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
|
||||
failure behavior, and test surfaces.
|
||||
- [Artifact Internals](artifacts.md): source identities, runtime catalog,
|
||||
|
||||
@@ -7,7 +7,10 @@ dependency order, and safely materialize validated outputs.
|
||||
|
||||
## Inputs
|
||||
|
||||
- configured artifacts from `pipeline.scriptorium.artifacts`
|
||||
- ordinary configured artifacts from `pipeline.scriptorium.artifacts`; canonical
|
||||
party artifact families have already expanded into this map during
|
||||
configuration resolution, including corresponding member dependencies and
|
||||
rewritten member-artifact input sources
|
||||
- optional selected artifact keys supplied through the stage environment
|
||||
- built-in, configured, extraction, and previous-session source references in
|
||||
artifact inputs
|
||||
@@ -165,7 +168,8 @@ Supported source families:
|
||||
## Related Contracts And Tests
|
||||
|
||||
- [Configuration](../config.md#scriptorium-artifact-entries) owns artifact
|
||||
fields and source-selection rules.
|
||||
fields and source-selection rules, including
|
||||
[artifact families](../config.md#scriptorium-artifact-families).
|
||||
- [CLI](../cli.md) owns user-visible artifact selection.
|
||||
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
|
||||
- Implementation and tests: `internal/stage/analyze.go`,
|
||||
|
||||
@@ -21,8 +21,8 @@ procedures belong in [Operations](../operations.md).
|
||||
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;
|
||||
4. fingerprints the byte- and provenance-bearing Notarius invocation evidence,
|
||||
including sorted reference identities;
|
||||
5. creates a run-local staging directory and invokes the injected
|
||||
`notarius.Runner`;
|
||||
6. revalidates the reference snapshots, then validates the v2 successful
|
||||
@@ -56,14 +56,20 @@ outputs is stable and does not repeatedly invalidate dependent stages.
|
||||
|
||||
## Resume Validation
|
||||
|
||||
`internal/stage/extract_resume.go` permits a skip only when the existing stage
|
||||
record succeeded and still matches the current invocation fingerprint. The
|
||||
fingerprint covers the resolved executable and config paths, pipeline ID,
|
||||
timeout, working directory, sorted configured output contracts, the current
|
||||
direct trimmed-transcript identity, and sorted prepared-reference identities.
|
||||
The same reference helper and transcript identity are resolved again for
|
||||
artifact evidence, so changing the current transcript bytes or producer
|
||||
identity makes the prior extraction obsolete.
|
||||
Before the focused validator runs, the application compares extract's versioned
|
||||
semantic fingerprint. It covers enablement, Notarius pipeline identity, sorted
|
||||
reference selector/source mappings, sorted declared output contracts, and each
|
||||
canonical `narratio.extraction.<key>` output identity. It excludes executable,
|
||||
timeout, working directory, config path, and private Notarius config contents.
|
||||
|
||||
`internal/stage/extract_resume.go` then permits a skip only when the existing
|
||||
stage record still matches the current byte- and provenance-bearing invocation
|
||||
evidence. That evidence covers the current direct trimmed-transcript identity,
|
||||
sorted prepared-reference identities, pipeline identity, and configured output
|
||||
contracts. The same reference helper and transcript identity are resolved again
|
||||
for artifact evidence, so changing current transcript bytes, reference bytes,
|
||||
or producer identity makes the prior extraction obsolete. Operational runner
|
||||
settings do not invalidate otherwise current durable evidence.
|
||||
|
||||
A valid prepared-reference change makes extraction non-resumable. Missing,
|
||||
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
|
||||
@@ -76,9 +82,10 @@ contracts and provenance, regular-file status, and stored checksums. Missing or
|
||||
obsolete results are non-resumable and run again; unsafe filesystem conditions
|
||||
return an error rather than silently accepting or replacing data.
|
||||
|
||||
The fingerprint cannot observe files imported by Notarius configuration,
|
||||
profile contents, prompt/module definitions, or other transitive inputs.
|
||||
Operators must force extraction after changing any such input.
|
||||
Neither contract can observe files imported by Notarius configuration, profile
|
||||
contents, prompt/module definitions, or other transitive inputs. Operators must
|
||||
force extraction after changing any such private input behind a stable
|
||||
identifier.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
@@ -101,7 +108,8 @@ available for audit and recovery.
|
||||
|
||||
- Stage execution, selection, and resume validation: `internal/stage/extract.go`,
|
||||
`internal/stage/extract_resume.go`,
|
||||
`internal/stage/extract_test.go`
|
||||
`internal/stage/extract_test.go`,
|
||||
`internal/stage/semantic_contracts_delivery.go`
|
||||
- Subprocess boundary: `internal/adapters/notarius/subprocess.go`,
|
||||
`internal/adapters/notarius/subprocess_test.go`
|
||||
- Catalog hydration: `internal/artifacts/extraction_catalog.go`,
|
||||
|
||||
@@ -29,9 +29,23 @@ Normalize raw transcript inputs and merge into base transcript via Seriatim.
|
||||
- base transcript must validate before stage success.
|
||||
- report output is config-gated.
|
||||
|
||||
## Resume Evidence
|
||||
|
||||
Merge records a versioned semantic-configuration fingerprint for the Seriatim
|
||||
merge operation, output schema, coalesce gap, and every configured advanced
|
||||
merge transformation. A change reruns merge and stales only its fixed
|
||||
descendants; prepare and transcribe remain reusable. Binary path, timeout,
|
||||
report emission, logs, and diagnostic retention are operational exclusions.
|
||||
|
||||
Configuration or resources loaded privately inside Seriatim are outside
|
||||
Narratio's observable contract and require `--force` when changed. An existing
|
||||
successful merge record without evidence reruns once when selected.
|
||||
|
||||
## Related Contracts And Tests
|
||||
|
||||
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
||||
- [Configuration](../config.md#pipeline) owns operator-selected Seriatim values.
|
||||
- Implementation and tests: `internal/stage/merge.go`,
|
||||
`internal/stage/merge_test.go`
|
||||
`internal/stage/merge_test.go`,
|
||||
`internal/stage/semantic_contracts_initial.go`, and
|
||||
`internal/stage/semantic_contracts_initial_test.go`
|
||||
|
||||
@@ -26,9 +26,17 @@ Normalize polished transcript into final transcript using Seriatim.
|
||||
- final transcript must validate as processed transcript JSON (`segments` array).
|
||||
- normalize defaults are applied when `pipeline.normalize` is unset.
|
||||
|
||||
## Resume Semantics
|
||||
|
||||
The versioned semantic fingerprint covers the Seriatim normalize operation,
|
||||
output schema and canonical output identity, plus the configured transcript
|
||||
transformations. Seriatim's executable and timeout and optional report
|
||||
generation are operational and do not invalidate the normalized transcript.
|
||||
|
||||
## Related Contracts And Tests
|
||||
|
||||
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
||||
- [Configuration](../config.md#pipeline) owns normalize fields and defaults.
|
||||
- Implementation and tests: `internal/stage/normalize.go`,
|
||||
`internal/stage/normalize_test.go`
|
||||
`internal/stage/normalize_test.go`,
|
||||
`internal/stage/semantic_contracts_refinement.go`
|
||||
|
||||
@@ -28,10 +28,26 @@ Run Audita polishing on base transcript and produce polished transcript.
|
||||
- polished transcript schema validation is mandatory.
|
||||
- report output is config-gated.
|
||||
|
||||
## Resume Semantics
|
||||
|
||||
The versioned semantic fingerprint covers the Audita service endpoint, model,
|
||||
validation model, module set, transcript description, output schema, selected
|
||||
external configuration path, and canonical polished-transcript identity. Module
|
||||
ordering is normalized because the configured modules form a set. Audita's
|
||||
executable, timeouts, concurrency, report and debug behavior, work retention,
|
||||
and credential environment name are operational and do not invalidate a
|
||||
successful result.
|
||||
|
||||
Narratio can fingerprint a selected model, module, or configuration identifier,
|
||||
but it cannot inspect content that Audita privately resolves behind that stable
|
||||
identifier. Force `polish` after changing such private content without changing
|
||||
its identifier.
|
||||
|
||||
## Related Contracts And Tests
|
||||
|
||||
- [Audita](../integrations/audita.md) owns subprocess, validation, and failure
|
||||
semantics.
|
||||
- [Configuration](../config.md#pipeline) owns operator-selected Audita values.
|
||||
- Implementation and tests: `internal/stage/polish.go`,
|
||||
`internal/stage/polish_test.go`
|
||||
`internal/stage/polish_test.go`,
|
||||
`internal/stage/semantic_contracts_refinement.go`
|
||||
|
||||
@@ -39,6 +39,9 @@ Materialize canonical current-session inputs before processing stages.
|
||||
- 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.
|
||||
- in canonical party mode, copies the validated raw party bytes unchanged and
|
||||
deterministically generates the prepared players projection; legacy mode
|
||||
continues to copy its opaque party and explicit players sources.
|
||||
- scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
|
||||
- clears managed `previous/` state on every invocation, then, when requirements exist:
|
||||
- resolves the pointer-selected previous source through the shared resolver;
|
||||
@@ -57,6 +60,26 @@ mapping, while the isolated legacy reader rejects ambiguous fallback matches.
|
||||
- managed `previous/` state represents only the current requirement set.
|
||||
- `manifest.inputs` ordering is deterministic (`kind`, `path`).
|
||||
|
||||
## Resume Evidence
|
||||
|
||||
Prepare records a versioned semantic-configuration fingerprint for the
|
||||
resolved campaign/session selection, local-versus-S3 audio mode and canonical
|
||||
audio names, stable-input ownership/presence, previous-session identity, and
|
||||
the party mode plus canonical players projection version, and the effective
|
||||
previous-artifact requirement set. A change reruns prepare and
|
||||
stales its fixed descendants. Existing successful records without this
|
||||
evidence rerun once when selected.
|
||||
|
||||
Workspace, spool, and cache placement and absolute source relocation are not
|
||||
semantic when logical selection, canonical names, and bytes are equivalent.
|
||||
The fingerprint deliberately does not read or rehash large audio. Prepared
|
||||
input checksums remain the content provenance. Before reusing success, prepare
|
||||
validates every durable prepared copy and compares current stable-input bytes,
|
||||
canonical party and derived-player bytes, local audio membership/checksums, or
|
||||
S3 key/size/entity-tag identity with that provenance. Source relocation with
|
||||
equivalent names and bytes remains reusable; changed or unavailable evidence
|
||||
causes a normal prepare rerun.
|
||||
|
||||
## Related Contracts And Tests
|
||||
|
||||
- [Configuration](../config.md) owns audio selection, stable input fields, and
|
||||
@@ -66,5 +89,10 @@ mapping, while the isolated legacy reader rejects ambiguous fallback matches.
|
||||
- [Storage Internals](storage.md) and [Artifact Internals](artifacts.md) explain
|
||||
the internal collaborators.
|
||||
- Implementation and tests: `internal/stage/prepare.go`,
|
||||
`internal/stage/prepare_test.go`, `internal/audio/s3_audio_test.go`,
|
||||
`internal/stage/prepare_test.go`,
|
||||
`internal/stage/prepare_resume.go`,
|
||||
`internal/stage/prepare_resume_test.go`,
|
||||
`internal/stage/semantic_contracts_initial.go`,
|
||||
`internal/stage/semantic_contracts_initial_test.go`,
|
||||
`internal/audio/s3_audio_test.go`,
|
||||
`internal/previouscache/*_test.go`
|
||||
|
||||
@@ -27,8 +27,8 @@ Exact remote placement and the operator workflow belong in
|
||||
|
||||
- when publishing or run upload is disabled, completes successfully with no
|
||||
outputs and records explanatory metadata. This is not an explicit self-skip:
|
||||
both manifests record success, and an ordinary later run reuses that result
|
||||
until publish is forced.
|
||||
both manifests record success. Enablement and upload policy are fingerprinted,
|
||||
so changing either automatically makes the prior result non-resumable.
|
||||
- validates prerequisite stage success and object-store availability.
|
||||
- derives a deterministic run-archive allowlist from the validated run
|
||||
`manifest.json`: declared run-local outputs, logs, generated configs, and the
|
||||
@@ -89,6 +89,16 @@ Includes counts/lists for:
|
||||
- post-commit local cleanup is authorized by the committed publish metadata and
|
||||
is durably recorded by the application lifecycle before any local deletion.
|
||||
|
||||
## Resume Semantics
|
||||
|
||||
The versioned semantic fingerprint covers enabled behavior, run-upload policy,
|
||||
normalized source/destination/required output rules, static lock policy, and
|
||||
the remote backend, bucket, region, endpoint, and root-prefix identity. Rule
|
||||
and lock ordering is canonicalized. Credential environment names,
|
||||
path-addressing transport mode, local workspace placement, and run identifiers
|
||||
are excluded. Remote locks remain mutable state and are still revalidated at
|
||||
the commit boundary; semantic evidence does not replace that safety check.
|
||||
|
||||
The commit boundary and cleanup gate are normative architecture invariants; see
|
||||
[Architecture](../policy/architecture.md#publish-commit-boundary).
|
||||
|
||||
@@ -101,5 +111,7 @@ The commit boundary and cleanup gate are normative architecture invariants; see
|
||||
- [Artifact Internals](artifacts.md) explains source resolution and current-state
|
||||
helpers.
|
||||
- Implementation and tests: `internal/stage/publish.go`,
|
||||
`internal/stage/publish_test.go`, `internal/app/operator_helpers_test.go`,
|
||||
`internal/stage/publish_test.go`,
|
||||
`internal/stage/semantic_contracts_delivery.go`,
|
||||
`internal/app/operator_helpers_test.go`, and
|
||||
`internal/app/post_publish_cleanup_test.go`
|
||||
|
||||
@@ -26,7 +26,8 @@ records that may consume rendered transcripts.
|
||||
- records input provenance, output paths, adapter metadata, logs, and generated config refs.
|
||||
- when `pipeline.render.enabled=false`, completes successfully with no outputs
|
||||
and records explanatory metadata. This is not an explicit self-skip: both
|
||||
manifests record success, and enabling render later requires a forced run.
|
||||
manifests record success. Because enablement is fingerprinted, enabling
|
||||
render later automatically makes the prior result non-resumable.
|
||||
|
||||
## Failure Semantics
|
||||
|
||||
@@ -40,9 +41,20 @@ records that may consume rendered transcripts.
|
||||
- only `format: markdown` is supported.
|
||||
- render stage owns production of built-in Markdown transcript sources.
|
||||
|
||||
## Resume Semantics
|
||||
|
||||
The versioned semantic fingerprint covers enablement, final format, resolved
|
||||
title (including the session-title fallback), timestamp, segment-ID and
|
||||
metadata inclusion, both canonical input identities, and both Markdown output
|
||||
identities. Seriatim's executable, timeout, and report behavior are operational
|
||||
and do not invalidate rendered transcripts. A render-only change leaves the
|
||||
independent `extract` sibling reusable while invalidating their shared
|
||||
downstream consumers.
|
||||
|
||||
## Related Contracts And Tests
|
||||
|
||||
- [Seriatim](../integrations/seriatim.md) owns render subprocess behavior.
|
||||
- [Configuration](../config.md#pipeline) owns render fields and defaults.
|
||||
- Implementation and tests: `internal/stage/render.go`,
|
||||
`internal/stage/render_test.go`
|
||||
`internal/stage/render_test.go`,
|
||||
`internal/stage/semantic_contracts_refinement.go`
|
||||
|
||||
@@ -31,6 +31,19 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
|
||||
- each successful output is validated before stage success, and cancellation or
|
||||
incomplete dispatch cannot be reported as a successful result.
|
||||
|
||||
## Resume Evidence
|
||||
|
||||
Transcribe records a versioned semantic-configuration fingerprint containing
|
||||
the Narratio-visible WhisperX service URL and recognition language. Changes to
|
||||
either rerun transcription and stale its fixed descendants while leaving
|
||||
prepare reusable. Retry count/delay, concurrency, timeout, credentials, and
|
||||
diagnostic locations are operational and do not change this evidence.
|
||||
|
||||
WhisperX models or private service configuration not exposed by Narratio's
|
||||
adapter contract cannot be fingerprinted; use `--force` after changing them.
|
||||
An existing successful transcribe record without evidence reruns once when
|
||||
selected.
|
||||
|
||||
## Related Contracts And Tests
|
||||
|
||||
- [WhisperX](../integrations/whisperx.md) owns HTTP, retry, timeout, and
|
||||
@@ -38,4 +51,6 @@ Generate raw per-speaker transcripts from prepared audio using WhisperX.
|
||||
- [Configuration](../config.md#pipeline) owns concurrency and other
|
||||
operator-selected values.
|
||||
- Implementation and tests: `internal/stage/transcribe.go`,
|
||||
`internal/stage/transcribe_test.go`
|
||||
`internal/stage/transcribe_test.go`,
|
||||
`internal/stage/semantic_contracts_initial.go`, and
|
||||
`internal/stage/semantic_contracts_initial_test.go`
|
||||
|
||||
@@ -33,6 +33,19 @@ When `trim.enabled=false`:
|
||||
- bounds output exists only in enabled trim path.
|
||||
- render-debug output is diagnostic and not a declared stage output.
|
||||
|
||||
## Resume Semantics
|
||||
|
||||
The versioned semantic fingerprint covers enablement, the bounds prompt and
|
||||
profile identifiers, the Scriptorium configuration identity, transcript input
|
||||
name, sticky session variable, bounds and trimmed output identities, and the
|
||||
Seriatim trim operation. Diagnostic bounds rendering, diagnostic output paths,
|
||||
timeouts, executable paths, and optional reports are operational and do not
|
||||
invalidate the canonical trimmed transcript.
|
||||
|
||||
Narratio cannot inspect prompt, profile, or configuration content that
|
||||
Scriptorium or Seriatim privately resolves behind a stable identifier. Force
|
||||
`trim` after changing such private content without changing its identifier.
|
||||
|
||||
## Related Contracts And Tests
|
||||
|
||||
- [Scriptorium](../integrations/scriptorium.md) owns bounds generation and
|
||||
@@ -40,4 +53,5 @@ When `trim.enabled=false`:
|
||||
- [Seriatim](../integrations/seriatim.md) owns transcript trimming behavior.
|
||||
- [Configuration](../config.md#pipeline) owns trim fields and defaults.
|
||||
- Implementation and tests: `internal/stage/trim.go`,
|
||||
`internal/stage/trim_test.go`
|
||||
`internal/stage/trim_test.go`,
|
||||
`internal/stage/semantic_contracts_refinement.go`
|
||||
|
||||
@@ -70,6 +70,19 @@ narratio run 2026-04-04
|
||||
narratio session status 2026-04-04
|
||||
```
|
||||
|
||||
Run, plan, and status output identify the resolved pipeline profile (or `none`)
|
||||
and effective configuration digest. Status distinguishes the current resolved
|
||||
value from the last value persisted in the session manifest, which helps
|
||||
diagnose profile switches without changing resume authority.
|
||||
|
||||
Before switching an operational profile, compare its effective meaning with the
|
||||
current selection through `narratio config diff <left-profile> <right-profile>`.
|
||||
The command is read-only and succeeds whether it finds differences or not. Its
|
||||
sorted records describe defaulted, expanded concrete configuration—not source
|
||||
file layout—so it can be used to review model, artifact, and publish changes
|
||||
without creating a session or run. Select the same campaign explicitly when
|
||||
profiles could resolve different campaign paths; see the [CLI reference](cli.md#config-validate-config-show-config-sources-and-config-diff) for syntax and record format.
|
||||
|
||||
## Stage Execution and Continuation Behavior
|
||||
|
||||
Canonical stage order:
|
||||
@@ -88,7 +101,9 @@ Canonical stage order:
|
||||
|
||||
Execution rules:
|
||||
|
||||
- succeeded stages are skipped unless `--force` is set;
|
||||
- succeeded stages are skipped unless `--force` is set; stages with semantic
|
||||
configuration contracts additionally require matching versioned evidence,
|
||||
and missing legacy evidence causes a safe one-time rerun;
|
||||
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||
- forcing a stage marks succeeded transitive dependents as `stale` before the
|
||||
replacement runs; render and extract are independent siblings; and
|
||||
@@ -97,6 +112,27 @@ Execution rules:
|
||||
repeated self-skip with the same reason and no outputs is stable and does not
|
||||
perpetually rerun dependent work.
|
||||
|
||||
Every aggregate stage except analyze currently provides semantic-configuration
|
||||
evidence; analyze retains its more precise per-artifact fingerprints and
|
||||
validator.
|
||||
Prepare additionally validates current stable/local/S3 source identity and the
|
||||
checksums of its durable prepared copies before reuse. Changed bytes, audio
|
||||
membership, S3 object identity, or missing/tampered copies rerun prepare and
|
||||
its fixed descendants without requiring `--force`. Changing prepare selection
|
||||
semantics likewise reruns all fixed descendants; changing
|
||||
WhisperX language/service identity reuses prepare; changing a Seriatim merge
|
||||
transformation reuses prepare and transcribe; and changing an Audita model
|
||||
reuses prepare, transcribe, and merge while rebuilding transcript refinement.
|
||||
A trim change invalidates both render and extract through the fixed dependency
|
||||
relation, while a render-only change preserves the extract sibling.
|
||||
|
||||
Operational timeouts, retry/concurrency tuning, executable paths,
|
||||
workspace/cache/spool placement, reports, diagnostics, and secret values are
|
||||
excluded. Configuration, models, prompts, modules, or resources loaded
|
||||
privately inside external tools remain unobservable to Narratio. If their
|
||||
contents change behind the same configured identifier, explicitly force the
|
||||
affected stage.
|
||||
|
||||
An explicit self-skip is a durable `skipped` stage outcome that later runs
|
||||
reconsider. It differs from successful no-output execution: disabled `render`
|
||||
and `publish`, and absent or no-executable `analyze`, record `succeeded` with
|
||||
@@ -177,6 +213,11 @@ canonical file into place or editing the manifest. See
|
||||
|
||||
## Artifact Selection
|
||||
|
||||
For a configured artifact family, selecting its family key expands to every
|
||||
concrete character artifact. Select a concrete generated key to operate on one
|
||||
member only. Manifests and plan output retain the concrete key as the durable
|
||||
identity and include the family and character ID as optional provenance.
|
||||
|
||||
`--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`.
|
||||
@@ -213,6 +254,11 @@ prepared inputs. Their canonical locations are `inputs/party.yml`,
|
||||
`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.
|
||||
For a canonical party, preparation retains the validated authored party bytes
|
||||
at `inputs/party.yml` and generates `inputs/players.yml` from that roster.
|
||||
The manifest records their checksums separately, with the players input marked
|
||||
as derived from the party; refresh preparation after changing the roster rather
|
||||
than editing either prepared file.
|
||||
Inspect the effective stable-input inventory and
|
||||
prepared-file readiness with:
|
||||
|
||||
@@ -280,6 +326,10 @@ narratio run-stage extract 2026-04-04 --force
|
||||
|
||||
Narratio automatically reruns extraction when its recorded invocation contract,
|
||||
prepared Narratio reference identities, or durable output validation changes.
|
||||
The semantic portion covers Notarius enablement, pipeline identity, declared
|
||||
reference mapping, and output contracts. Executable, timeout, working directory,
|
||||
and private config-file paths are operational and do not invalidate a current
|
||||
result.
|
||||
It cannot fingerprint configuration files, profiles, prompts, modules, or
|
||||
other references loaded transitively by Notarius itself. Force extraction after
|
||||
changing any of those inputs, even when the top-level Narratio and Notarius
|
||||
@@ -288,6 +338,12 @@ marks successful downstream stages stale. Ordinary extraction failures or
|
||||
outcome changes also stale affected downstream stages, while an identical
|
||||
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
|
||||
|
||||
Publish reuse additionally tracks enabled/run-upload behavior, normalized
|
||||
output rules, static locks, and remote backend/bucket/region/endpoint/root
|
||||
identity. Credential environment names, local workspace placement, and run IDs
|
||||
are excluded. Regardless of semantic reuse evidence, executing publish still
|
||||
revalidates mutable remote locks immediately before commit selection.
|
||||
|
||||
## Publish Workflow
|
||||
|
||||
Run publish only:
|
||||
|
||||
@@ -145,6 +145,14 @@ Configuration is strict, explicit, centralized, and operator-oriented.
|
||||
- Empty configured values do not silently replace meaningful defaults.
|
||||
- Validation rejects invalid composition before stage execution where
|
||||
practical.
|
||||
- Root-owned imports and one selected profile resolve deterministically through
|
||||
the configuration owner; commands do not implement their own merge rules.
|
||||
- Canonical party rosters are campaign-owned. Their derived players projection
|
||||
and concrete character-family artifacts are resolved before runtime stages
|
||||
or adapters receive configuration.
|
||||
- Resume uses stage- or artifact-owned semantic evidence for observable
|
||||
result-affecting configuration; profile identity and an effective digest are
|
||||
provenance, never blanket cache keys.
|
||||
- Session templating remains narrow and deterministic rather than becoming a
|
||||
general configuration language.
|
||||
- Secret values are supplied indirectly and are not persisted in ordinary
|
||||
|
||||
@@ -59,6 +59,8 @@ secret values.
|
||||
| --- | --- | --- | --- |
|
||||
| Product orientation and minimal end-to-end quickstart | `README.md` | What Narratio is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, implementation detail. |
|
||||
| Contributor entry point | `docs/development.md` | Task-oriented reading guide, minimal contributor orientation, baseline validation commands, and links to canonical docs. | Package inventory, architecture rules, subsystem behavior, detailed change recipes. |
|
||||
| Maintainer release procedure | `docs/release.md` | Version selection, candidate preparation and validation, guarded tag publication, release completion boundary, failure recovery, and optional asynchronous inspection. | Script implementation mechanics, current application contracts, and historical release summaries. |
|
||||
| Historical release summary | `docs/releases/<tag>.md` | Immutable summary, compatibility, upgrade, and changes for one released version. | Current maintainer procedure and current application contract details. |
|
||||
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, architectural boundaries, invariants, safety properties, and non-goals. | Concrete package inventory, implementation mechanics, contributor procedures, decision history, future work. |
|
||||
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and ADR/document lifecycle. | Application architecture or product behavior. |
|
||||
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, test boundaries, doubles, coverage guidance, regression-test policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
|
||||
|
||||
97
docs/release.md
Normal file
97
docs/release.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Releasing Narratio
|
||||
|
||||
This document is the maintainer procedure for creating a Narratio source and
|
||||
binary release. The synchronous release boundary is a successful push of one
|
||||
new tag to `origin`; Woodpecker and Gitea publication happen later and do not
|
||||
change that result.
|
||||
|
||||
## Choose a version and write its note
|
||||
|
||||
Narratio is past `v1.0.0`. Use an unused stable tag in the exact form
|
||||
`vMAJOR.MINOR.PATCH`:
|
||||
|
||||
- increment `MINOR` for backward-compatible features;
|
||||
- increment `PATCH` for backward-compatible fixes; and
|
||||
- reserve a new `MAJOR` for an intentional breaking documented contract.
|
||||
|
||||
Before preparing the candidate, create
|
||||
`docs/releases/vMAJOR.MINOR.PATCH.md` with this structure:
|
||||
|
||||
```markdown
|
||||
# Narratio vMAJOR.MINOR.PATCH
|
||||
|
||||
This release ...
|
||||
|
||||
## Summary
|
||||
|
||||
## Compatibility
|
||||
|
||||
## Upgrade
|
||||
|
||||
## Changes
|
||||
```
|
||||
|
||||
The compatibility section identifies relevant CLI, configuration, artifact,
|
||||
integration, or operating-contract changes. The upgrade section states the
|
||||
required operator action, or explicitly says that no special action is
|
||||
required. Release notes are immutable historical summaries; link to the
|
||||
current canonical documentation for detailed behavior.
|
||||
|
||||
Commit the note and all candidate changes, then use the ordinary development
|
||||
workflow to push that commit to `main`. Do not create a release tag before the
|
||||
candidate is committed and `origin/main` contains the exact same commit.
|
||||
|
||||
## Validate the candidate
|
||||
|
||||
Run the shared checker from any directory:
|
||||
|
||||
```sh
|
||||
scripts/check-release-candidate.sh vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
It validates the version and matching note, module hygiene, formatting,
|
||||
whitespace, uncached tests, race tests, static checks, documentation, examples,
|
||||
and six official cross-build assets. It uses `GOWORK=off`, does not contact
|
||||
application services, CI, or Gitea, and does not create tags or modify tracked
|
||||
source. Fix any failure on `main`, commit it, push it normally, and rerun the
|
||||
checker.
|
||||
|
||||
The checker builds Linux, macOS, and Windows assets for `amd64` and `arm64`.
|
||||
Cross-builds prove compilation; they are not native macOS or Windows runtime
|
||||
evidence.
|
||||
|
||||
## Publish the tag
|
||||
|
||||
From a clean checkout on `main` whose `HEAD` equals `origin/main`, run:
|
||||
|
||||
```sh
|
||||
scripts/release.sh vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
The command fetches and checks `origin/main`, re-runs candidate validation,
|
||||
then fetches and checks again before creating an explicitly unsigned lightweight
|
||||
tag for the originally recorded commit. It refuses dirty, divergent, changed,
|
||||
or already-tagged candidates. It pushes only:
|
||||
|
||||
```text
|
||||
refs/tags/vMAJOR.MINOR.PATCH:refs/tags/vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
It never commits changes, pushes `main`, force-pushes, moves a tag, or pushes
|
||||
all tags. A successful push of that exact ref completes the release command;
|
||||
the command prints the tag and commit, then returns without waiting for CI,
|
||||
querying Gitea, downloading assets, or checking checksums.
|
||||
|
||||
If a failure occurs before the tag is created, correct the candidate on `main`
|
||||
and repeat validation. If the push fails after local tag creation, the local tag
|
||||
is intentionally retained for inspection and the command must not be retried
|
||||
blindly. Once the upstream tag has been pushed, it is immutable. Correct any
|
||||
defect or failed asynchronous publication with a new patch version, a new
|
||||
release note, and the complete procedure again.
|
||||
|
||||
## Optional asynchronous inspection
|
||||
|
||||
After a successful tag push, a human may later inspect the tag-triggered
|
||||
Woodpecker run and the corresponding Gitea release for binaries and checksums.
|
||||
This is optional follow-up only. Automated releasers must not wait for, poll,
|
||||
or treat CI/Gitea completion as a condition of the successful tag push.
|
||||
@@ -1,7 +1,26 @@
|
||||
# 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.
|
||||
This directory contains immutable historical release notes for Narratio.
|
||||
Future notes are created with the matching stable version and use this minimum
|
||||
structure:
|
||||
|
||||
```markdown
|
||||
# Narratio vMAJOR.MINOR.PATCH
|
||||
|
||||
This release ...
|
||||
|
||||
## Summary
|
||||
|
||||
## Compatibility
|
||||
|
||||
## Upgrade
|
||||
|
||||
## Changes
|
||||
```
|
||||
|
||||
See the [release procedure](../release.md) for creating a candidate and tag.
|
||||
When asynchronous publication succeeds, the corresponding Gitea release is the
|
||||
canonical source for downloadable binaries and checksums.
|
||||
|
||||
- [v1.6.0](v1.6.0.md)
|
||||
- [v1.5.0](v1.5.0.md)
|
||||
|
||||
74
docs/releases/v1.6.0.md
Normal file
74
docs/releases/v1.6.0.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Narratio v1.6.0
|
||||
|
||||
Narratio v1.6.0 makes large pipeline configurations easier to organize,
|
||||
inspect, and vary while adding character-oriented artifact generation and a
|
||||
guarded, reproducible release procedure.
|
||||
|
||||
## Summary
|
||||
|
||||
Pipeline configuration can now be assembled from explicit additive imports and
|
||||
a selected production or testing profile. Campaigns can own a canonical,
|
||||
versioned party roster, and Scriptorium artifact families can expand one
|
||||
definition into concrete per-character artifacts, dependencies, variables, and
|
||||
publish rules.
|
||||
|
||||
New read-only configuration commands expose the fully resolved pipeline,
|
||||
source provenance, semantic digest, and profile differences before a session is
|
||||
run. Stage reuse now records configuration-sensitive semantic evidence so
|
||||
profile or configuration changes cannot silently reuse incompatible work.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is a backward-compatible feature release. Existing monolithic pipeline
|
||||
files, concrete Scriptorium artifacts, publish rules, and configurations
|
||||
without profiles remain supported. When profiles are declared and no explicit
|
||||
profile is selected, Narratio uses the configured production default.
|
||||
|
||||
An unversioned party file plus a separate players file remains available as an
|
||||
isolated legacy compatibility path, but it cannot drive artifact families. New
|
||||
campaigns and new party-oriented features should use the `narratio.party.v1`
|
||||
schema. Existing manifests remain readable; missing legacy semantic evidence
|
||||
is treated as stale rather than trusted.
|
||||
|
||||
Narratio continues to consume the documented Notarius D&D pipeline contract.
|
||||
The release workflow still cross-compiles Linux, macOS, and Windows binaries
|
||||
for `amd64` and `arm64`; cross-compilation is not native runtime evidence for
|
||||
macOS or Windows.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No special action is required for existing monolithic configurations that do
|
||||
not adopt profiles or artifact families. On the first run after upgrading,
|
||||
stages recorded by older manifests may regenerate once because those records do
|
||||
not contain the new semantic configuration evidence.
|
||||
|
||||
To adopt the new configuration model, use the maintained
|
||||
`examples/production-testing` bundle as a migration reference: split stable
|
||||
settings into explicit imports, define a production default and optional
|
||||
testing profile, convert campaign party data to `narratio.party.v1`, remove the
|
||||
separate players file, and then introduce character artifact families. Review
|
||||
the result with `narratio config validate`, `config show`, `config sources`, and
|
||||
`config diff` before running a session.
|
||||
|
||||
Campaign, session, previous-session, and run identifiers must satisfy the
|
||||
documented portable identity grammar. Existing manifests or remote state with
|
||||
unsafe legacy identifiers must be migrated before use.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added root-owned, non-recursive additive pipeline imports with strict,
|
||||
source-aware conflict detection.
|
||||
- Added named pipeline profiles with an explicit production default and
|
||||
deliberate command-line selection.
|
||||
- Added `config validate`, `config show`, `config sources`, and `config diff`
|
||||
for read-only inspection of resolved configuration and provenance.
|
||||
- Added the strict `narratio.party.v1` campaign roster, including stable
|
||||
character IDs, player and character names, optional aliases, and classes.
|
||||
- Added deterministic players derivation and canonical party delivery to
|
||||
downstream integrations.
|
||||
- Added character-oriented artifact families, corresponding member
|
||||
dependencies, family selection, and generated publish policies.
|
||||
- Added semantic configuration fingerprints and stage-specific resume checks
|
||||
across transcript and artifact stages.
|
||||
- Added shared release candidate, asset build, and guarded tag-publication
|
||||
scripts, with tag-triggered publication remaining asynchronous.
|
||||
@@ -71,6 +71,36 @@ Safe fix:
|
||||
|
||||
Relevant reference: [Configuration](./config.md).
|
||||
|
||||
## Unexpected imported or profile value
|
||||
|
||||
Symptom:
|
||||
|
||||
- an effective configuration value differs from the root file, or a duplicate
|
||||
ownership/configuration error is hard to locate.
|
||||
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio config sources --config /path/pipeline.yml --profile testing
|
||||
```
|
||||
|
||||
Add `--campaign` or `--campaign-file` when the pipeline has party-driven
|
||||
artifact families. The output identifies each effective logical field's root,
|
||||
import, profile, default, campaign, party, or family source without printing
|
||||
the field value or credential contents.
|
||||
|
||||
Safe fix:
|
||||
|
||||
- move a duplicated base field so it has one owner;
|
||||
- correct the selected profile or its overlay; or
|
||||
- correct the campaign party/family declaration that owns generated values.
|
||||
|
||||
To review what would actually change before switching profiles, run `config
|
||||
diff` with the same pipeline and campaign selectors. It compares normalized
|
||||
effective values rather than YAML formatting or source-file layout.
|
||||
|
||||
Relevant reference: [Configuration inspection](./config.md#read-only-effective-pipeline-inspection).
|
||||
|
||||
## Audio mode conflict
|
||||
|
||||
Symptom:
|
||||
|
||||
@@ -11,6 +11,18 @@ in the [configuration reference](../docs/config.md).
|
||||
WhisperX URL.
|
||||
- [Production-shaped pipeline](pipeline.production.yml): S3 storage, publish,
|
||||
external tools, and configured Scriptorium artifacts.
|
||||
- [Production/testing split bundle](production-testing/pipeline.yml): explicit
|
||||
`conf.d` imports, a production default, and selectable production/testing
|
||||
overlays. It also demonstrates canonical-party artifact families and a
|
||||
testing-only disabled artifact. Validate it with:
|
||||
|
||||
```sh
|
||||
narratio config validate --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
|
||||
narratio config diff production testing --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
|
||||
```
|
||||
|
||||
`config show` and `config sources` accept the same selectors and remain
|
||||
read-only.
|
||||
- [Full annotated pipeline](pipeline.full.annotated.yml): every implemented
|
||||
pipeline section with explanatory comments.
|
||||
- [Extraction subset pipeline](pipeline.extraction-subset.yml): a focused
|
||||
@@ -37,11 +49,11 @@ with the sample campaign and a compatible local- or S3-audio session.
|
||||
- The sample campaign references its local
|
||||
[speakers](campaigns/sample-campaign/speakers.yml),
|
||||
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
|
||||
[glossary](campaigns/sample-campaign/glossary.yml),
|
||||
[players](campaigns/sample-campaign/players.yml), and
|
||||
[party](campaigns/sample-campaign/party.yml) fixtures, plus an optional
|
||||
[glossary](campaigns/sample-campaign/glossary.yml), and canonical
|
||||
[party](campaigns/sample-campaign/party.yml) fixture, plus an optional
|
||||
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
|
||||
follows the Notarius v0.6 contract.
|
||||
follows the Notarius v0.6 contract. Narratio derives the players projection
|
||||
from this party source; the campaign deliberately has no `players_file`.
|
||||
- [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
|
||||
reserves the expected filename and directory shape. Replace it with a real
|
||||
FLAC file before running transcription.
|
||||
|
||||
@@ -4,6 +4,5 @@ inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
players_file: ./players.yml
|
||||
party_file: ./party.yml
|
||||
spell_catalog_file: ./spell_catalog.json
|
||||
|
||||
@@ -1,2 +1,26 @@
|
||||
- name: Example Hero
|
||||
type: pc
|
||||
schema_version: narratio.party.v1
|
||||
|
||||
characters:
|
||||
arannis:
|
||||
player:
|
||||
name: Rowan Hale
|
||||
character:
|
||||
name: Arannis
|
||||
alias:
|
||||
- Ari
|
||||
- The Grey Owl
|
||||
classes:
|
||||
- name: wizard
|
||||
level: 8
|
||||
brenna:
|
||||
player:
|
||||
name: Rowan Hale
|
||||
character:
|
||||
name: Brenna
|
||||
alias:
|
||||
- Shield of Dawn
|
||||
classes:
|
||||
- name: paladin
|
||||
level: 6
|
||||
- name: warlock
|
||||
level: 2
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
- name: Example Player
|
||||
role: player
|
||||
32
examples/production-testing/conf.d/artifacts.yml
Normal file
32
examples/production-testing/conf.d/artifacts.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
scriptorium:
|
||||
binary: scriptorium
|
||||
config_path: ./scriptorium/config.yml
|
||||
artifact_families:
|
||||
character_meta:
|
||||
enabled: true
|
||||
for_each: party.characters
|
||||
prompt_id: dnd.character_meta
|
||||
output_path_pattern: artifacts/characters/{character_id}/meta.md
|
||||
member_vars:
|
||||
character_id: character_id
|
||||
character_name: character.name
|
||||
player_name: player.name
|
||||
class_summary: character.class_summary
|
||||
character_items:
|
||||
enabled: true
|
||||
for_each: party.characters
|
||||
prompt_id: dnd.character_items
|
||||
output_path_pattern: artifacts/characters/{character_id}/items.md
|
||||
member_dependencies: [character_meta]
|
||||
inputs:
|
||||
character_meta:
|
||||
source: narratio.member_artifact.character_meta
|
||||
required: true
|
||||
member_vars:
|
||||
character_id: character_id
|
||||
character_name: character.name
|
||||
aliases: character.alias_summary
|
||||
publish:
|
||||
enabled: true
|
||||
required: false
|
||||
dest_pattern: artifacts/characters/{character_id}/items.md
|
||||
12
examples/production-testing/conf.d/platform.yml
Normal file
12
examples/production-testing/conf.d/platform.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
workspace:
|
||||
root: ./workspace
|
||||
|
||||
campaigns:
|
||||
root: ../../campaigns
|
||||
default_campaign_id: sample-campaign
|
||||
|
||||
cache:
|
||||
root: ./cache
|
||||
|
||||
spool:
|
||||
root: ./spool
|
||||
7
examples/production-testing/conf.d/publish.yml
Normal file
7
examples/production-testing/conf.d/publish.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
publish:
|
||||
enabled: true
|
||||
upload_run: false
|
||||
outputs:
|
||||
- source: narratio.transcript.final_markdown
|
||||
dest: transcripts/final.md
|
||||
required: true
|
||||
2
examples/production-testing/conf.d/storage.yml
Normal file
2
examples/production-testing/conf.d/storage.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
storage:
|
||||
backend: local
|
||||
16
examples/production-testing/conf.d/transcript.yml
Normal file
16
examples/production-testing/conf.d/transcript.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
language: en
|
||||
|
||||
seriatim:
|
||||
binary: seriatim
|
||||
output_schema: seriatim-intermediate
|
||||
|
||||
audita:
|
||||
binary: audita
|
||||
modules: [glossary, grammar]
|
||||
output_schema: audita-v1
|
||||
|
||||
normalize:
|
||||
output_path: transcripts/final.json
|
||||
output_schema: seriatim-intermediate
|
||||
15
examples/production-testing/pipeline.yml
Normal file
15
examples/production-testing/pipeline.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# Copyable production/testing pipeline entry point. Every fragment is named
|
||||
# explicitly; Narratio never scans conf.d automatically.
|
||||
composition:
|
||||
imports:
|
||||
- conf.d/platform.yml
|
||||
- conf.d/storage.yml
|
||||
- conf.d/transcript.yml
|
||||
- conf.d/artifacts.yml
|
||||
- conf.d/publish.yml
|
||||
default_profile: production
|
||||
profiles:
|
||||
production:
|
||||
overlay: profiles/production.yml
|
||||
testing:
|
||||
overlay: profiles/testing.yml
|
||||
10
examples/production-testing/profiles/production.yml
Normal file
10
examples/production-testing/profiles/production.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
audita:
|
||||
model: narratio-production-model-placeholder
|
||||
validation_model: narratio-production-validator-placeholder
|
||||
|
||||
scriptorium:
|
||||
artifact_families:
|
||||
character_meta:
|
||||
profile_id: production-placeholder
|
||||
character_items:
|
||||
profile_id: production-placeholder
|
||||
14
examples/production-testing/profiles/testing.yml
Normal file
14
examples/production-testing/profiles/testing.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
audita:
|
||||
model: narratio-testing-model-placeholder
|
||||
validation_model: narratio-testing-validator-placeholder
|
||||
|
||||
scriptorium:
|
||||
artifacts:
|
||||
testing_notes:
|
||||
enabled: false
|
||||
output_path: artifacts/testing-notes.md
|
||||
artifact_families:
|
||||
character_meta:
|
||||
profile_id: testing-placeholder
|
||||
character_items:
|
||||
profile_id: testing-placeholder
|
||||
@@ -60,10 +60,14 @@ func resolveEffectiveArtifacts(cfg *config.Config, selected []string) (artifacts
|
||||
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires pipeline.scriptorium.artifacts to be configured")
|
||||
}
|
||||
configured := artifacts.ConfiguredArtifactDefinitions(cfg.Pipeline.Scriptorium.Artifacts)
|
||||
if len(selected) > 0 && len(configured) == 0 {
|
||||
normalized, err := normalizeArtifactSelection(cfg, selected)
|
||||
if err != nil {
|
||||
return artifacts.EffectiveArtifactSet{}, err
|
||||
}
|
||||
if len(normalized) > 0 && len(configured) == 0 {
|
||||
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires at least one configured artifact in pipeline.scriptorium.artifacts")
|
||||
}
|
||||
effective, err := artifacts.ResolveEffectiveArtifactSet(configured, selected)
|
||||
effective, err := artifacts.ResolveEffectiveArtifactSet(configured, normalized)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "is not configured") {
|
||||
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts includes unknown artifact %q", selectedArtifactName(err))
|
||||
@@ -73,7 +77,47 @@ func resolveEffectiveArtifacts(cfg *config.Config, selected []string) (artifacts
|
||||
if err := validateEffectiveArtifactConfiguration(cfg.Pipeline.Scriptorium.Artifacts, effective); err != nil {
|
||||
return artifacts.EffectiveArtifactSet{}, err
|
||||
}
|
||||
return effective, nil
|
||||
return effective.WithOrigins(effectiveArtifactOrigins(cfg.Pipeline)), nil
|
||||
}
|
||||
|
||||
func normalizeArtifactSelection(cfg *config.Config, selected []string) ([]string, error) {
|
||||
if len(selected) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
configured := cfg.Pipeline.Scriptorium.Artifacts
|
||||
families := config.ArtifactFamilies(cfg.Pipeline).Families
|
||||
set := make(map[string]struct{}, len(selected))
|
||||
for _, raw := range selected {
|
||||
key := strings.TrimSpace(raw)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("artifact names must be non-empty")
|
||||
}
|
||||
if family, ok := families[key]; ok {
|
||||
for _, member := range family.Members {
|
||||
set[member] = struct{}{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, ok := configured[key]; !ok {
|
||||
return nil, fmt.Errorf("--artifacts includes unknown artifact %q", key)
|
||||
}
|
||||
set[key] = struct{}{}
|
||||
}
|
||||
normalized := make([]string, 0, len(set))
|
||||
for key := range set {
|
||||
normalized = append(normalized, key)
|
||||
}
|
||||
sort.Strings(normalized)
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func effectiveArtifactOrigins(pipeline *config.PipelineConfig) map[string]artifacts.EffectiveArtifactOrigin {
|
||||
catalog := config.ArtifactFamilies(pipeline)
|
||||
origins := make(map[string]artifacts.EffectiveArtifactOrigin, len(catalog.Members))
|
||||
for key, member := range catalog.Members {
|
||||
origins[key] = artifacts.EffectiveArtifactOrigin{Family: member.Family, CharacterID: member.CharacterID}
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
func selectedArtifactName(err error) string {
|
||||
|
||||
@@ -125,17 +125,28 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
|
||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
if err := RunStage(
|
||||
context.Background(),
|
||||
[]string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
&bytes.Buffer{},
|
||||
); err != nil {
|
||||
t.Fatalf("seed prepare stage: %v", err)
|
||||
}
|
||||
seed, err := store.Load(context.Background(), manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load prepared manifest: %v", err)
|
||||
}
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||
seed.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
seed.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
|
||||
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge", "polish", "normalize", "trim", "render")
|
||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||
t.Fatalf("save manifest: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Run(
|
||||
err = Run(
|
||||
context.Background(),
|
||||
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||
&out,
|
||||
|
||||
@@ -1,11 +1,71 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestResolveEffectiveArtifactsExpandsFamilySelections(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, body string) string {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
pipeline := write("pipeline.yml", `workspace: {root: /tmp/narratio-work}
|
||||
whisperx: {transcribe_url: https://example.test/transcribe}
|
||||
notification: {mode: noop}
|
||||
scriptorium:
|
||||
artifact_families:
|
||||
character_meta:
|
||||
enabled: false
|
||||
for_each: party.characters
|
||||
prompt_id: dnd.character_meta
|
||||
output_path_pattern: artifacts/characters/{character_id}/meta.md
|
||||
`)
|
||||
campaign := write("campaign.yml", `campaign_id: campaign
|
||||
inputs: {speakers_file: speakers.yml, autocorrect_file: autocorrect.yml, glossary_file: glossary.yml, party_file: party.yml}
|
||||
`)
|
||||
session := write("session.yml", `session_id: session
|
||||
campaign: campaign
|
||||
inputs: {audio_dir: audio}
|
||||
`)
|
||||
write("party.yml", `schema_version: narratio.party.v1
|
||||
characters:
|
||||
zeta: {player: {name: Z}, character: {name: Zeta, classes: [{name: wizard}]}}
|
||||
alpha: {player: {name: A}, character: {name: Alpha, classes: [{name: ranger}]}}
|
||||
`)
|
||||
cfg, err := config.LoadWithSessionOptions(pipeline, campaign, session, config.SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
effective, err := resolveEffectiveArtifacts(cfg, []string{"character_meta", "character_meta_alpha"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := effective.Keys(), []string{"character_meta_alpha", "character_meta_zeta"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("keys = %#v, want %#v", got, want)
|
||||
}
|
||||
if origin, ok := effective.Origin("character_meta_alpha"); !ok || origin.Family != "character_meta" || origin.CharacterID != "alpha" {
|
||||
t.Fatalf("origin = %#v, %t", origin, ok)
|
||||
}
|
||||
if _, err := resolveEffectiveArtifacts(cfg, []string{"unknown"}); err == nil || !strings.Contains(err.Error(), "unknown artifact") {
|
||||
t.Fatalf("unknown selection error = %v", err)
|
||||
}
|
||||
if defaultEffective, err := resolveEffectiveArtifacts(cfg, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(defaultEffective.Keys()) != 0 {
|
||||
t.Fatal("default selection should omit disabled family members")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactSelectionFlagNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -3,8 +3,11 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -271,6 +274,188 @@ func TestAssembledLegacyAnalyzeTransitionPublishesOnlyCurrentRecords(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testing.T) {
|
||||
pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths()
|
||||
workspacePath := filepath.Join(filepath.Dir(pipelinePath), "workspace")
|
||||
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("example workspace stat = %v, want absent", err)
|
||||
}
|
||||
|
||||
var validated bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validated); err != nil {
|
||||
t.Fatalf("validate production default: %v", err)
|
||||
}
|
||||
if !strings.Contains(validated.String(), "profile=production") {
|
||||
t.Fatalf("validate output = %q, want production profile", validated.String())
|
||||
}
|
||||
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigShow, ConfigSources} {
|
||||
if err := command(context.Background(), []string{
|
||||
"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing",
|
||||
}, io.Discard); err != nil {
|
||||
t.Fatalf("testing inspection command %T: %v", command, err)
|
||||
}
|
||||
}
|
||||
var diff bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), []string{
|
||||
"production", "testing", "--config", pipelinePath, "--campaign-file", campaignPath,
|
||||
}, &diff); err != nil {
|
||||
t.Fatalf("compare profiles: %v", err)
|
||||
}
|
||||
if !strings.Contains(diff.String(), "audita.model") || !strings.Contains(diff.String(), "character_items_arannis.profile_id") {
|
||||
t.Fatalf("profile diff = %q, want model and expanded family changes", diff.String())
|
||||
}
|
||||
|
||||
common := []string{
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath,
|
||||
"--session", sessionPath, "--profile", "testing",
|
||||
}
|
||||
var planned bytes.Buffer
|
||||
if err := Plan(context.Background(), common, &planned); err != nil {
|
||||
t.Fatalf("plan testing bundle: %v", err)
|
||||
}
|
||||
if !strings.Contains(planned.String(), "profile=testing") {
|
||||
t.Fatalf("plan output = %q, want testing provenance", planned.String())
|
||||
}
|
||||
|
||||
original := executeStagesFn
|
||||
t.Cleanup(func() { executeStagesFn = original })
|
||||
var capturedCfg *config.Config
|
||||
var capturedPlan BoundedPlan
|
||||
var capturedOptions RunOptions
|
||||
executeStagesFn = func(_ context.Context, cfg *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
||||
capturedCfg = cfg
|
||||
capturedPlan = plan
|
||||
capturedOptions = options
|
||||
return &RunSummary{SessionID: cfg.Session.SessionID, ManifestPath: manifestPathForConfig(cfg.Pipeline.Workspace.Root)}, nil
|
||||
}
|
||||
runArgs := append(append([]string(nil), common...), "--from", "analyze", "--through", "analyze", "--artifacts", "character_items_arannis")
|
||||
var runOut bytes.Buffer
|
||||
if err := Run(context.Background(), runArgs, &runOut); err != nil {
|
||||
t.Fatalf("bounded family run: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(capturedPlan.Names(), []string{"analyze"}) {
|
||||
t.Fatalf("bounded plan = %#v, want analyze only", capturedPlan.Names())
|
||||
}
|
||||
if profile, ok := config.SelectedPipelineProfile(capturedCfg.Pipeline); !ok || profile.Name != "testing" {
|
||||
t.Fatalf("captured profile = %#v, selected=%t", profile, ok)
|
||||
}
|
||||
if digest := config.EffectivePipelineDigest(capturedCfg.Pipeline); digest == "" || !strings.Contains(runOut.String(), "digest="+digest) {
|
||||
t.Fatalf("run output = %q, want effective digest %q", runOut.String(), digest)
|
||||
}
|
||||
if got := capturedOptions.EffectiveArtifacts.Keys(); !reflect.DeepEqual(got, []string{"character_items_arannis"}) {
|
||||
t.Fatalf("effective artifact keys = %#v", got)
|
||||
}
|
||||
if origin, ok := capturedOptions.EffectiveArtifacts.Origin("character_items_arannis"); !ok || origin.Family != "character_items" || origin.CharacterID != "arannis" {
|
||||
t.Fatalf("family origin = %#v, present=%t", origin, ok)
|
||||
}
|
||||
fullFamily, err := resolveEffectiveArtifacts(capturedCfg, []string{"character_items"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve complete family: %v", err)
|
||||
}
|
||||
if got := fullFamily.Keys(); !reflect.DeepEqual(got, []string{"character_items_arannis", "character_items_brenna"}) {
|
||||
t.Fatalf("full family keys = %#v", got)
|
||||
}
|
||||
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("inspection or bounded run created example workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssembledSplitBundleProfileSwitchRecordsProvenanceAndLimitsReuse(t *testing.T) {
|
||||
production := loadAssembledSplitBundleConfig(t, "")
|
||||
testingCfg := loadAssembledSplitBundleConfig(t, "testing")
|
||||
workspace := t.TempDir()
|
||||
production.Pipeline.Workspace.Root = workspace
|
||||
testingCfg.Pipeline.Workspace.Root = workspace
|
||||
|
||||
names := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"}
|
||||
providers := make([]stage.SemanticConfigFingerprinter, len(names))
|
||||
seed := manifest.New(production.Session.SessionID, time.Now().UTC())
|
||||
seed.Campaign = production.Session.Campaign
|
||||
for _, candidate := range stage.All() {
|
||||
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
|
||||
}
|
||||
for index, name := range names {
|
||||
providers[index] = canonicalSemanticProvider(t, name)
|
||||
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: production})
|
||||
if err != nil {
|
||||
t.Fatalf("production %s fingerprint: %v", name, err)
|
||||
}
|
||||
seed.Stages[name].SemanticConfig = &fingerprint
|
||||
}
|
||||
store := &manifest.LocalStore{}
|
||||
if err := store.Save(context.Background(), manifestPathForConfig(workspace), seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
productionRuns := make([]int, len(names))
|
||||
productionStages := assembledSemanticStages(names, providers, productionRuns)
|
||||
productionSummary, err := executeStages(context.Background(), production, productionStages, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("record production provenance: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(productionRuns, make([]int, len(names))) {
|
||||
t.Fatalf("production runs = %v, want complete reuse", productionRuns)
|
||||
}
|
||||
assertAssembledProvenance(t, store, productionSummary, "production", config.EffectivePipelineDigest(production.Pipeline))
|
||||
|
||||
testingRuns := make([]int, len(names))
|
||||
testingStages := assembledSemanticStages(names, providers, testingRuns)
|
||||
testingSummary, err := executeStages(context.Background(), testingCfg, testingStages, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("switch to testing profile: %v", err)
|
||||
}
|
||||
if got, want := testingRuns, []int{0, 0, 0, 1, 1, 1, 1}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("testing profile runs = %v, want %v", got, want)
|
||||
}
|
||||
assertAssembledProvenance(t, store, testingSummary, "testing", config.EffectivePipelineDigest(testingCfg.Pipeline))
|
||||
}
|
||||
|
||||
func assembledSplitBundlePaths() (pipelinePath, campaignPath, sessionPath string) {
|
||||
examplesDir := filepath.Join("..", "..", "examples")
|
||||
return filepath.Join(examplesDir, "production-testing", "pipeline.yml"),
|
||||
filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml"),
|
||||
filepath.Join(examplesDir, "session.local-audio.yml")
|
||||
}
|
||||
|
||||
func loadAssembledSplitBundleConfig(t *testing.T, profile string) *config.Config {
|
||||
t.Helper()
|
||||
pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths()
|
||||
options := config.SessionLoadOptions{}
|
||||
if profile != "" {
|
||||
options.Profile = &profile
|
||||
}
|
||||
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, options)
|
||||
if err != nil {
|
||||
t.Fatalf("load split bundle profile %q: %v", profile, err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func assembledSemanticStages(names []string, providers []stage.SemanticConfigFingerprinter, runs []int) []stage.Stage {
|
||||
stages := make([]stage.Stage, 0, len(names))
|
||||
for index, name := range names {
|
||||
stages = append(stages, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
|
||||
}
|
||||
return stages
|
||||
}
|
||||
|
||||
func assertAssembledProvenance(t *testing.T, store *manifest.LocalStore, summary *RunSummary, profile, digest string) {
|
||||
t.Helper()
|
||||
session, err := store.Load(context.Background(), summary.ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []*manifest.EffectiveConfigProvenance{session.EffectiveConfig, run.EffectiveConfig} {
|
||||
if value == nil || value.SelectedProfile == nil || value.SelectedProfile.Name != profile || value.EffectiveConfigDigest != digest {
|
||||
t.Fatalf("effective configuration provenance = %#v, want profile=%q digest=%q", value, profile, digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedAllStagesSucceeded(t *testing.T, cfg *config.Config) {
|
||||
t.Helper()
|
||||
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
|
||||
@@ -31,6 +31,14 @@ func (f *singletonStringFlag) Set(value string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f singletonStringFlag) pointer() *string {
|
||||
if !f.set {
|
||||
return nil
|
||||
}
|
||||
value := f.value
|
||||
return &value
|
||||
}
|
||||
|
||||
type singletonBoolFlag struct {
|
||||
name string
|
||||
value bool
|
||||
|
||||
@@ -57,6 +57,7 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
|
||||
{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"},
|
||||
{name: "profile", args: []string{"session", "--profile", "production", "--profile=testing"}, want: "--profile may be specified only once"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -68,6 +69,32 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedRunParsingRetainsExplicitProfilePresence(t *testing.T) {
|
||||
withoutProfile, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := withoutProfile.Config.sessionOptions().Profile; got != nil {
|
||||
t.Fatalf("omitted profile = %#v, want nil", got)
|
||||
}
|
||||
|
||||
withProfile, err := parseBoundedRunRequest("run", []string{"session", "--profile", "testing"}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := withProfile.Config.sessionOptions().Profile; got == nil || *got != "testing" {
|
||||
t.Fatalf("explicit profile = %#v, want testing", got)
|
||||
}
|
||||
|
||||
explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--profile", ""}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := explicitEmpty.Config.sessionOptions().Profile; got == nil || *got != "" {
|
||||
t.Fatalf("explicit empty profile = %#v, want non-nil empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) {
|
||||
args := []string{"session", "--from", "publish", "--through", "render"}
|
||||
runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard)
|
||||
|
||||
@@ -88,11 +88,7 @@ func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writ
|
||||
strings.TrimSpace(flags.previousSessionID) != "" {
|
||||
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
|
||||
}
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(flags.pipelinePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clean: %w", err)
|
||||
}
|
||||
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
||||
_, pipelineCfg, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()})
|
||||
if err != nil {
|
||||
return fmt.Errorf("clean: %w", err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session"}
|
||||
var supportedCommands = []string{"version", "run", "regenerate-artifacts", "run-stage", "analyze", "publish", "clean", "session", "config"}
|
||||
|
||||
var runCommandFn = Run
|
||||
|
||||
@@ -40,6 +40,8 @@ func Execute(args []string, stdout, stderr io.Writer) int {
|
||||
err = Session(ctx, cmdArgs, stdout)
|
||||
case "clean":
|
||||
err = Clean(ctx, cmdArgs, stdout)
|
||||
case "config":
|
||||
err = Config(ctx, cmdArgs, stdout)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd)
|
||||
printUsage(stderr)
|
||||
|
||||
@@ -549,6 +549,17 @@ inputs:
|
||||
return pipelinePath, campaignPath, sessionPath
|
||||
}
|
||||
|
||||
func materializePrepareResumeFixture(t *testing.T, pipelinePath, campaignPath, sessionPath string) {
|
||||
t.Helper()
|
||||
if err := RunStage(
|
||||
context.Background(),
|
||||
[]string{"prepare", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
|
||||
&bytes.Buffer{},
|
||||
); err != nil {
|
||||
t.Fatalf("materialize prepare resume fixture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeAppTestCampaignConfig(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
campaignPath := filepath.Join(dir, "campaign.yml")
|
||||
@@ -563,6 +574,7 @@ inputs:
|
||||
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
||||
t.Fatalf("write campaign.yml: %v", err)
|
||||
}
|
||||
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
|
||||
return campaignPath
|
||||
}
|
||||
|
||||
|
||||
298
internal/app/config_commands.go
Normal file
298
internal/app/config_commands.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
type inspectionFlags struct {
|
||||
pipelinePath string
|
||||
campaignPath string
|
||||
campaignFilePath string
|
||||
profile singletonStringFlag
|
||||
}
|
||||
|
||||
type inspectionConfig struct {
|
||||
PipelinePath string
|
||||
Pipeline *config.PipelineConfig
|
||||
CampaignPath string
|
||||
Campaign *config.CampaignConfig
|
||||
Party config.ResolvedParty
|
||||
}
|
||||
|
||||
// Config dispatches read-only pipeline configuration commands.
|
||||
func Config(ctx context.Context, args []string, out io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("config: expected subcommand: validate|show|sources|diff")
|
||||
}
|
||||
if args[0] == "--help" || args[0] == "-h" {
|
||||
writeConfigCommandUsage(out)
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "validate":
|
||||
return ConfigValidate(ctx, args[1:], out)
|
||||
case "show":
|
||||
return ConfigShow(ctx, args[1:], out)
|
||||
case "sources":
|
||||
return ConfigSources(ctx, args[1:], out)
|
||||
case "diff":
|
||||
return ConfigDiff(ctx, args[1:], out)
|
||||
default:
|
||||
return fmt.Errorf("config: unknown subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigValidate validates one fully resolved effective pipeline without
|
||||
// loading session state or constructing runtime collaborators.
|
||||
func ConfigValidate(_ context.Context, args []string, out io.Writer) error {
|
||||
flags, err := parseInspectionFlags("config validate", args, out)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
resolved, err := resolveInspectionConfig(flags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config validate: %w", err)
|
||||
}
|
||||
_, err = fmt.Fprintf(out, "Configuration valid: root=%s; profile=%s; digest=%s\n",
|
||||
inspectionRootPath(resolved), inspectionProfile(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline))
|
||||
return err
|
||||
}
|
||||
|
||||
// ConfigShow validates and renders one fully resolved effective pipeline
|
||||
// without loading session state or constructing runtime collaborators.
|
||||
func ConfigShow(_ context.Context, args []string, out io.Writer) error {
|
||||
flags, err := parseInspectionFlags("config show", args, out)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
resolved, err := resolveInspectionConfig(flags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config show: %w", err)
|
||||
}
|
||||
data, err := config.MarshalEffectivePipeline(resolved.Pipeline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config show: %w", err)
|
||||
}
|
||||
_, err = out.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// ConfigSources validates and reports deterministic effective configuration
|
||||
// ownership without loading session state or runtime collaborators.
|
||||
func ConfigSources(_ context.Context, args []string, out io.Writer) error {
|
||||
flags, err := parseInspectionFlags("config sources", args, out)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
resolved, err := resolveInspectionConfig(flags)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config sources: %w", err)
|
||||
}
|
||||
records, err := config.EffectivePipelineSources(resolved.Pipeline, resolved.Party)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config sources: %w", err)
|
||||
}
|
||||
records = append(records, config.EffectiveCampaignSources(resolved.CampaignPath, resolved.Campaign, resolved.Party)...)
|
||||
sortInspectionSourceRecords(records)
|
||||
if err := writeInspectionSourceHeader(out, resolved); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
if _, err := fmt.Fprintf(out, "%s\t%s\t%s\n", record.Path, record.Role, record.Source); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseInspectionFlags(command string, args []string, out io.Writer) (inspectionFlags, error) {
|
||||
fs := flag.NewFlagSet(command, flag.ContinueOnError)
|
||||
fs.SetOutput(out)
|
||||
var flags inspectionFlags
|
||||
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
|
||||
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
||||
flags.profile.name = "profile"
|
||||
fs.Var(&flags.profile, "profile", "named pipeline profile")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(out, "Usage: narratio %s [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>]\n\n", command)
|
||||
fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Flags:")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return inspectionFlags{}, fmt.Errorf("%s: invalid flags: %w", command, err)
|
||||
}
|
||||
if fs.NArg() != 0 {
|
||||
return inspectionFlags{}, fmt.Errorf("%s: unexpected positional arguments", command)
|
||||
}
|
||||
if strings.TrimSpace(flags.campaignPath) != "" && strings.TrimSpace(flags.campaignFilePath) != "" {
|
||||
return inspectionFlags{}, fmt.Errorf("%s: --campaign and --campaign-file are mutually exclusive", command)
|
||||
}
|
||||
return flags, nil
|
||||
}
|
||||
|
||||
func resolveInspectionConfig(flags inspectionFlags) (*inspectionConfig, error) {
|
||||
pipelinePath, pipeline, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
needsCampaign := pipelineHasArtifactFamilies(pipeline)
|
||||
hasCampaignSelection := strings.TrimSpace(flags.campaignPath) != "" || strings.TrimSpace(flags.campaignFilePath) != ""
|
||||
if !needsCampaign && !hasCampaignSelection {
|
||||
if err := validateInspectionPipeline(pipelinePath, pipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inspectionConfig{PipelinePath: pipelinePath, Pipeline: pipeline}, nil
|
||||
}
|
||||
|
||||
campaignPath, err := resolveCampaignConfigPath(pipeline, flags.campaignPath, flags.campaignFilePath)
|
||||
if err != nil {
|
||||
if needsCampaign {
|
||||
return nil, fmt.Errorf("pipeline.scriptorium.artifact_families requires a campaign: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
campaign, err := config.LoadCampaign(campaignPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if selectedID := strings.TrimSpace(flags.campaignPath); selectedID != "" && strings.TrimSpace(flags.campaignFilePath) == "" {
|
||||
if got := config.CampaignID(campaign); got != selectedID {
|
||||
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", campaignPath, got, selectedID)
|
||||
}
|
||||
}
|
||||
loaded, err := config.LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateInspectionPipeline(loaded.PipelinePath, loaded.Pipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateInspectionCampaign(loaded.CampaignPath, loaded.Campaign); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inspectionConfig{
|
||||
PipelinePath: loaded.PipelinePath,
|
||||
Pipeline: loaded.Pipeline,
|
||||
CampaignPath: loaded.CampaignPath,
|
||||
Campaign: loaded.Campaign,
|
||||
Party: loaded.Party,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pipelineHasArtifactFamilies(pipeline *config.PipelineConfig) bool {
|
||||
return pipeline != nil && pipeline.Scriptorium != nil && len(pipeline.Scriptorium.ArtifactFamilies) != 0
|
||||
}
|
||||
|
||||
func validateInspectionPipeline(path string, pipeline *config.PipelineConfig) error {
|
||||
if err := config.ValidatePipelineConfig(pipeline); err != nil {
|
||||
return fmt.Errorf("pipeline config %q invalid: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInspectionCampaign(path string, campaign *config.CampaignConfig) error {
|
||||
if err := config.ValidateCampaignConfig(campaign); err != nil {
|
||||
return fmt.Errorf("campaign config %q invalid: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inspectionRootPath(resolved *inspectionConfig) string {
|
||||
if resolved == nil {
|
||||
return ""
|
||||
}
|
||||
if root := config.EffectivePipelineRootPath(resolved.Pipeline); root != "" {
|
||||
return root
|
||||
}
|
||||
return resolved.PipelinePath
|
||||
}
|
||||
|
||||
func inspectionProfile(pipeline *config.PipelineConfig) string {
|
||||
if profile, ok := config.SelectedPipelineProfile(pipeline); ok {
|
||||
return profile.Name
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
func writeConfigCommandUsage(out io.Writer) {
|
||||
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|diff>")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, or config diff to compare two profiles.")
|
||||
}
|
||||
|
||||
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
|
||||
if _, err := fmt.Fprintf(out, "root: %s\n", inspectionRootPath(resolved)); err != nil {
|
||||
return err
|
||||
}
|
||||
imports := config.EffectivePipelineImports(resolved.Pipeline)
|
||||
if len(imports) == 0 {
|
||||
if _, err := fmt.Fprintln(out, "imports: none"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, imported := range imports {
|
||||
if _, err := fmt.Fprintf(out, "import: %s\n", config.NormalizedConfigurationPath(imported)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if profile, ok := config.SelectedPipelineProfile(resolved.Pipeline); ok {
|
||||
if _, err := fmt.Fprintf(out, "profile: name=%s selection=%s overlay=%s\n", profile.Name, profile.Source, config.NormalizedConfigurationPath(profile.OverlayPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := fmt.Fprintln(out, "profile: none"); err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved.Campaign == nil {
|
||||
if _, err := fmt.Fprintln(out, "campaign: none"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(out, "party: none"); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := fmt.Fprintf(out, "campaign: id=%s source=%s\n", config.CampaignID(resolved.Campaign), config.NormalizedConfigurationPath(resolved.CampaignPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "party: mode=%s source=%s\n", resolved.Party.Mode, config.NormalizedConfigurationPath(resolved.Party.Source.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintf(out, "digest: %s\n", config.EffectivePipelineDigest(resolved.Pipeline)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(out, "path\trole\tsource")
|
||||
return err
|
||||
}
|
||||
|
||||
func sortInspectionSourceRecords(records []config.EffectivePipelineSourceRecord) {
|
||||
sort.Slice(records, func(left, right int) bool {
|
||||
if records[left].Path != records[right].Path {
|
||||
return records[left].Path < records[right].Path
|
||||
}
|
||||
if records[left].Role != records[right].Role {
|
||||
return records[left].Role < records[right].Role
|
||||
}
|
||||
return records[left].Source < records[right].Source
|
||||
})
|
||||
}
|
||||
557
internal/app/config_commands_test.go
Normal file
557
internal/app/config_commands_test.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestConfigCommandHelpAndDispatch(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
code int
|
||||
}{
|
||||
{name: "top level help", args: []string{"config", "--help"}, want: "Usage: narratio config <validate|show|sources|diff>"},
|
||||
{name: "validate help", args: []string{"config", "validate", "--help"}, want: "Usage: narratio config validate"},
|
||||
{name: "show help", args: []string{"config", "show", "--help"}, want: "Usage: narratio config show"},
|
||||
{name: "sources help", args: []string{"config", "sources", "--help"}, want: "Usage: narratio config sources"},
|
||||
{name: "diff help", args: []string{"config", "diff", "--help"}, want: "Usage: narratio config diff"},
|
||||
{name: "unknown", args: []string{"config", "unknown"}, want: `config: unknown subcommand "unknown"`, code: 1},
|
||||
{name: "missing", args: []string{"config"}, want: "config: expected subcommand: validate|show|sources|diff", code: 1},
|
||||
{name: "session flag", args: []string{"config", "validate", "--session", "session.yml"}, want: "flag provided but not defined", code: 1},
|
||||
{name: "stage flag", args: []string{"config", "show", "--from", "prepare"}, want: "flag provided but not defined", code: 1},
|
||||
{name: "force flag", args: []string{"config", "show", "--force"}, want: "flag provided but not defined", code: 1},
|
||||
{name: "artifact flag", args: []string{"config", "show", "--artifacts", "recap"}, want: "flag provided but not defined", code: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := Execute(test.args, &stdout, &stderr)
|
||||
if code != test.code {
|
||||
t.Fatalf("exit code = %d, want %d; stdout=%q stderr=%q", code, test.code, stdout.String(), stderr.String())
|
||||
}
|
||||
combined := stdout.String() + stderr.String()
|
||||
if !strings.Contains(combined, test.want) {
|
||||
t.Fatalf("output = %q, want %q", combined, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateAndShowPipelineOnlyAreSideEffectFree(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
workspaceRoot := filepath.Join(dir, "workspace-does-not-exist")
|
||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||
secretsDir := filepath.Join(dir, "secrets")
|
||||
const secret = "inspection-secret-must-not-escape"
|
||||
mustWriteTestFile(t, filepath.Join(secretsDir, "INSPECTION_SECRET"), secret+"\n")
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { calls++ }))
|
||||
defer server.Close()
|
||||
mustWriteTestFile(t, pipelinePath, `workspace:
|
||||
root: `+workspaceRoot+`
|
||||
secrets:
|
||||
env_dir: `+secretsDir+`
|
||||
whisperx:
|
||||
transcribe_url: `+server.URL+`
|
||||
`)
|
||||
t.Setenv("INSPECTION_SECRET", secret)
|
||||
|
||||
var validateOut bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &validateOut); err != nil {
|
||||
t.Fatalf("ConfigValidate() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; profile=none; digest=") {
|
||||
t.Fatalf("validate output = %q", validateOut.String())
|
||||
}
|
||||
|
||||
var showOut bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &showOut); err != nil {
|
||||
t.Fatalf("ConfigShow() error = %v", err)
|
||||
}
|
||||
show := showOut.String()
|
||||
if !strings.HasSuffix(show, "\n") || !strings.Contains(show, "workspace:\n") || !strings.Contains(show, "root: "+workspaceRoot) {
|
||||
t.Fatalf("show output = %q", show)
|
||||
}
|
||||
if strings.Contains(show, "artifact_families") || strings.Contains(show, "resolution") {
|
||||
t.Fatalf("show output leaked resolution-only fields: %q", show)
|
||||
}
|
||||
if strings.Contains(show, secret) {
|
||||
t.Fatalf("show output leaked a raw secret: %q", show)
|
||||
}
|
||||
var sourcesOut bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath}, &sourcesOut); err != nil {
|
||||
t.Fatalf("ConfigSources() error = %v", err)
|
||||
}
|
||||
if strings.Contains(sourcesOut.String(), secret) {
|
||||
t.Fatalf("sources output leaked a raw secret: %q", sourcesOut.String())
|
||||
}
|
||||
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
|
||||
var diffOut bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &diffOut); err != nil {
|
||||
t.Fatalf("ConfigDiff() error = %v", err)
|
||||
}
|
||||
if strings.Contains(diffOut.String(), secret) {
|
||||
t.Fatalf("diff output leaked a raw secret: %q", diffOut.String())
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("inspection invoked configured external endpoint %d time(s)", calls)
|
||||
}
|
||||
if _, err := os.Stat(workspaceRoot); !os.IsNotExist(err) {
|
||||
t.Fatalf("workspace root stat error = %v, want absent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffReportsSemanticProfileChanges(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
writeInspectionProfiles(t, pipelinePath, `workspace:
|
||||
cleanup_after_publish: true
|
||||
whisperx:
|
||||
language: en
|
||||
audita:
|
||||
modules: [glossary, grammar]
|
||||
scriptorium:
|
||||
artifacts:
|
||||
production_only:
|
||||
enabled: false
|
||||
output_path: artifacts/production.md
|
||||
`, `workspace:
|
||||
cleanup_after_publish: false
|
||||
whisperx:
|
||||
language: fr
|
||||
audita:
|
||||
modules: []
|
||||
scriptorium:
|
||||
artifacts:
|
||||
testing_only:
|
||||
enabled: false
|
||||
output_path: artifacts/testing.md
|
||||
`)
|
||||
|
||||
args := []string{"production", "testing", "--config", pipelinePath}
|
||||
var first, second bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), args, &first); err != nil {
|
||||
t.Fatalf("ConfigDiff() error = %v", err)
|
||||
}
|
||||
if err := ConfigDiff(context.Background(), args, &second); err != nil {
|
||||
t.Fatalf("second ConfigDiff() error = %v", err)
|
||||
}
|
||||
if first.String() != second.String() {
|
||||
t.Fatalf("diff output is not deterministic:\nfirst=%q\nsecond=%q", first.String(), second.String())
|
||||
}
|
||||
output := first.String()
|
||||
for _, want := range []string{
|
||||
"changed\taudita.modules\t[\"glossary\",\"grammar\"]\t[]\n",
|
||||
"changed\tworkspace.cleanup_after_publish\ttrue\tfalse\n",
|
||||
"changed\twhisperx.language\t\"en\"\t\"fr\"\n",
|
||||
"removed\tscriptorium.artifacts.production_only.enabled\tfalse\n",
|
||||
"added\tscriptorium.artifacts.testing_only.enabled\tfalse\n",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("diff output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
if strings.Index(output, "audita.modules") > strings.Index(output, "workspace.cleanup_after_publish") {
|
||||
t.Fatalf("diff records are not sorted by path:\n%s", output)
|
||||
}
|
||||
var reversed bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), []string{"testing", "production", "--config", pipelinePath}, &reversed); err != nil {
|
||||
t.Fatalf("reversed ConfigDiff() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(reversed.String(), "changed\twhisperx.language\t\"fr\"\t\"en\"\n") {
|
||||
t.Fatalf("reversed diff did not independently resolve profiles:\n%s", reversed.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
writeInspectionProfiles(t, pipelinePath, `scriptorium:
|
||||
artifact_families:
|
||||
character_note:
|
||||
enabled: false
|
||||
member_vars:
|
||||
character_name: character.name
|
||||
publish:
|
||||
enabled: true
|
||||
required: false
|
||||
`, `scriptorium:
|
||||
artifact_families:
|
||||
character_note:
|
||||
enabled: true
|
||||
prompt_id: dnd.character_note
|
||||
member_vars:
|
||||
character_name: character.class_summary
|
||||
publish:
|
||||
enabled: true
|
||||
required: true
|
||||
`)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), []string{
|
||||
"production", "testing", "--config", pipelinePath, "--campaign-file", campaignPath,
|
||||
}, &out); err != nil {
|
||||
t.Fatalf("ConfigDiff() error = %v", err)
|
||||
}
|
||||
output := out.String()
|
||||
for _, want := range []string{
|
||||
"changed\tscriptorium.artifacts.character_note_arannis.enabled\tfalse\ttrue\n",
|
||||
"changed\tscriptorium.artifacts.character_note_arannis.vars.character_name\t\"Arannis\"\t\"wizard\"\n",
|
||||
"changed\tpublish.outputs\t",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("family diff output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: en\n")
|
||||
|
||||
var equal bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath}, &equal); err != nil {
|
||||
t.Fatalf("ConfigDiff() equal profiles error = %v", err)
|
||||
}
|
||||
if got := equal.String(); got != "no differences\n" {
|
||||
t.Fatalf("equal diff output = %q", got)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"production", "testing", "extra", "--config", pipelinePath},
|
||||
{"production", "--config", pipelinePath},
|
||||
{"production", "production", "--config", pipelinePath},
|
||||
{"unknown", "testing", "--config", pipelinePath},
|
||||
{"production", "testing", "--config", pipelinePath, "--profile", "production"},
|
||||
{"production", "testing", "--config", pipelinePath, "--config", pipelinePath},
|
||||
{"production", "testing", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath},
|
||||
} {
|
||||
if err := ConfigDiff(context.Background(), args, io.Discard); err == nil {
|
||||
t.Fatalf("ConfigDiff(%q) succeeded, want error", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testing.T) {
|
||||
examplesDir := filepath.Join("..", "..", "examples")
|
||||
pipelinePath := filepath.Join(examplesDir, "production-testing", "pipeline.yml")
|
||||
campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml")
|
||||
workspacePath := filepath.Join(examplesDir, "production-testing", "workspace")
|
||||
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("example workspace stat = %v, want absent", err)
|
||||
}
|
||||
|
||||
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow, ConfigSources} {
|
||||
if err := command(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "production"}, io.Discard); err != nil {
|
||||
t.Fatalf("inspection command %T error = %v", command, err)
|
||||
}
|
||||
}
|
||||
var diff bytes.Buffer
|
||||
if err := ConfigDiff(context.Background(), []string{"production", "testing", "--config", pipelinePath, "--campaign-file", campaignPath}, &diff); err != nil {
|
||||
t.Fatalf("ConfigDiff() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(diff.String(), "audita.model") {
|
||||
t.Fatalf("split bundle profile diff = %q, want model change", diff.String())
|
||||
}
|
||||
if _, err := os.Stat(workspacePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("inspection created example workspace: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeInspectionProfiles(t *testing.T, pipelinePath, production, testing string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
composition := "composition:\n default_profile: production\n profiles:\n production:\n overlay: production.yml\n testing:\n overlay: testing.yml\n"
|
||||
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(pipelinePath)
|
||||
mustWriteTestFile(t, filepath.Join(dir, "production.yml"), production)
|
||||
mustWriteTestFile(t, filepath.Join(dir, "testing.yml"), testing)
|
||||
}
|
||||
|
||||
func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
enableCommandTestProfiles(t, pipelinePath)
|
||||
|
||||
var defaultOut bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath}, &defaultOut); err != nil {
|
||||
t.Fatalf("default ConfigValidate() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(defaultOut.String(), "profile=production") {
|
||||
t.Fatalf("default output = %q", defaultOut.String())
|
||||
}
|
||||
|
||||
var explicitOut bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &explicitOut); err != nil {
|
||||
t.Fatalf("explicit ConfigShow() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(explicitOut.String(), "language: fr") {
|
||||
t.Fatalf("explicit show output = %q", explicitOut.String())
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"--config", pipelinePath, "--campaign", "sample-campaign"},
|
||||
{"--config", pipelinePath, "--campaign-file", campaignPath},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), args, &out); err != nil {
|
||||
t.Fatalf("ConfigValidate(%q) error = %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigCommandsExpandFamiliesAndRequireCampaign(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
|
||||
for _, command := range []func(context.Context, []string, io.Writer) error{ConfigValidate, ConfigShow} {
|
||||
var out bytes.Buffer
|
||||
err := command(context.Background(), []string{"--config", pipelinePath}, &out)
|
||||
if err == nil || !strings.Contains(err.Error(), "artifact_families requires a campaign") {
|
||||
t.Fatalf("family command without campaign error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var show bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &show); err != nil {
|
||||
t.Fatalf("ConfigShow() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(show.String(), "character_note_arannis:") || strings.Contains(show.String(), "artifact_families") {
|
||||
t.Fatalf("expanded show output = %q", show.String())
|
||||
}
|
||||
|
||||
loadedPipeline, err := config.LoadPipeline(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loadedCampaign, err := config.LoadCampaign(campaignPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.LoadPipelineCampaign(pipelinePath, loadedPipeline, campaignPath, loadedCampaign)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var validateOut bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validateOut); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(validateOut.String(), "digest="+config.EffectivePipelineDigest(loaded.Pipeline)) {
|
||||
t.Fatalf("inspection digest = %q, want %q", validateOut.String(), config.EffectivePipelineDigest(loaded.Pipeline))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateReportsCanonicalPartyAndImportErrors(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
mustWriteTestFile(t, filepath.Join(filepath.Dir(campaignPath), "party.yml"), "schema_version: narratio.party.v2\ncharacters: {}\n")
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, io.Discard); err == nil || !strings.Contains(err.Error(), "unsupported") {
|
||||
t.Fatalf("canonical party error = %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
rootPath := filepath.Join(dir, "pipeline.yml")
|
||||
mustWriteTestFile(t, rootPath, `composition:
|
||||
imports: [conf.yml]
|
||||
workspace:
|
||||
root: /one
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
mustWriteTestFile(t, filepath.Join(dir, "conf.yml"), "workspace:\n root: /two\n")
|
||||
err := ConfigValidate(context.Background(), []string{"--config", rootPath}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "workspace.root") || !strings.Contains(err.Error(), "pipeline.yml") || !strings.Contains(err.Error(), "conf.yml") {
|
||||
t.Fatalf("import diagnostic = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesReportsStableOwnership(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
enableCommandTestProfiles(t, pipelinePath)
|
||||
|
||||
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"}
|
||||
var first, second bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), args, &first); err != nil {
|
||||
t.Fatalf("first ConfigSources() error = %v", err)
|
||||
}
|
||||
if err := ConfigSources(context.Background(), args, &second); err != nil {
|
||||
t.Fatalf("second ConfigSources() error = %v", err)
|
||||
}
|
||||
if first.String() != second.String() {
|
||||
t.Fatalf("sources output is not deterministic:\nfirst=%q\nsecond=%q", first.String(), second.String())
|
||||
}
|
||||
output := first.String()
|
||||
for _, want := range []string{
|
||||
"root: ",
|
||||
"imports: none",
|
||||
"profile: name=testing selection=cli overlay=",
|
||||
"campaign: id=sample-campaign source=",
|
||||
"party: mode=legacy source=",
|
||||
"digest: ",
|
||||
"path\trole\tsource\n",
|
||||
"workspace.root\troot\t",
|
||||
"whisperx.language\tprofile\t",
|
||||
"campaign.inputs.players_file\tlegacy_player\t",
|
||||
"trim.enabled\tdefault\tdefault",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("sources output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
var out bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil {
|
||||
t.Fatalf("ConfigSources() error = %v", err)
|
||||
}
|
||||
output := out.String()
|
||||
partyPath := filepath.Join(filepath.Dir(campaignPath), "party.yml")
|
||||
for _, want := range []string{
|
||||
"party: mode=canonical source=" + partyPath,
|
||||
"derived.players\tparty\t" + partyPath,
|
||||
"scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + pipelinePath,
|
||||
"scriptorium.artifacts.character_note_arannis.enabled\tparty\t" + partyPath,
|
||||
"scriptorium.artifacts.character_note_arannis.depends_on\tfamily\t" + pipelinePath,
|
||||
"scriptorium.artifacts.character_note_arannis.inputs.prior.source\tparty\t" + partyPath,
|
||||
"publish.outputs[",
|
||||
"\tfamily\t" + pipelinePath,
|
||||
"\tparty\t" + partyPath,
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("sources output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
data, err := os.ReadFile(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profilePath := filepath.Join(filepath.Dir(pipelinePath), "testing.yml")
|
||||
composition := "composition:\n default_profile: testing\n profiles:\n testing:\n overlay: testing.yml\n"
|
||||
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustWriteTestFile(t, profilePath, `scriptorium:
|
||||
artifact_families:
|
||||
character_note:
|
||||
enabled: true
|
||||
prompt_id: dnd.character_note
|
||||
`)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &out); err != nil {
|
||||
t.Fatalf("ConfigSources() error = %v", err)
|
||||
}
|
||||
want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + profilePath
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("sources output missing profile-generated ownership %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesReportsRootImportProfileAndDefaultOwnership(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := filepath.Join(dir, "pipeline.yml")
|
||||
importPath := filepath.Join(dir, "base.yml")
|
||||
profilePath := filepath.Join(dir, "testing.yml")
|
||||
mustWriteTestFile(t, rootPath, `composition:
|
||||
imports: [base.yml]
|
||||
default_profile: testing
|
||||
profiles:
|
||||
testing:
|
||||
overlay: testing.yml
|
||||
workspace:
|
||||
root: /srv/narratio
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
mustWriteTestFile(t, importPath, "storage:\n backend: local\n")
|
||||
mustWriteTestFile(t, profilePath, "whisperx:\n language: fr\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", rootPath}, &out); err != nil {
|
||||
t.Fatalf("ConfigSources() error = %v", err)
|
||||
}
|
||||
output := out.String()
|
||||
for _, want := range []string{
|
||||
"import: " + importPath,
|
||||
"profile: name=testing selection=default overlay=" + profilePath,
|
||||
"workspace.root\troot\t" + rootPath,
|
||||
"storage.backend\timport\t" + importPath,
|
||||
"whisperx.language\tprofile\t" + profilePath,
|
||||
"trim.enabled\tdefault\tdefault",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Fatalf("sources output missing %q:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeInspectionFamilyConfig(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
campaignRoot := filepath.Join(dir, "campaigns")
|
||||
campaignDir := filepath.Join(campaignRoot, "sample-campaign")
|
||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||
campaignPath := filepath.Join(campaignDir, "campaign.yml")
|
||||
mustWriteTestFile(t, pipelinePath, `workspace:
|
||||
root: `+filepath.Join(dir, "workspace")+`
|
||||
campaigns:
|
||||
root: `+campaignRoot+`
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
scriptorium:
|
||||
artifact_families:
|
||||
character_meta:
|
||||
enabled: false
|
||||
for_each: party.characters
|
||||
output_path_pattern: artifacts/characters/{character_id}/meta.md
|
||||
character_note:
|
||||
enabled: false
|
||||
for_each: party.characters
|
||||
output_path_pattern: artifacts/characters/{character_id}/note.md
|
||||
member_dependencies: [character_meta]
|
||||
inputs:
|
||||
prior: {source: narratio.member_artifact.character_meta, required: true}
|
||||
member_vars:
|
||||
character_name: character.name
|
||||
publish:
|
||||
enabled: true
|
||||
required: true
|
||||
publish:
|
||||
enabled: true
|
||||
upload_run: false
|
||||
`)
|
||||
mustWriteTestFile(t, campaignPath, `campaign_id: sample-campaign
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
party_file: ./party.yml
|
||||
`)
|
||||
mustWriteTestFile(t, filepath.Join(campaignDir, "party.yml"), `schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character: {name: Arannis, classes: [{name: wizard}]}
|
||||
`)
|
||||
return pipelinePath, campaignPath
|
||||
}
|
||||
302
internal/app/config_diff.go
Normal file
302
internal/app/config_diff.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
type configDiffFlags struct {
|
||||
pipelinePath singletonStringFlag
|
||||
campaignPath singletonStringFlag
|
||||
campaignFilePath singletonStringFlag
|
||||
}
|
||||
|
||||
type configDiffRequest struct {
|
||||
leftProfile string
|
||||
rightProfile string
|
||||
flags configDiffFlags
|
||||
}
|
||||
|
||||
type configDiffResolved struct {
|
||||
left *inspectionConfig
|
||||
right *inspectionConfig
|
||||
}
|
||||
|
||||
type configDiffRecord struct {
|
||||
Kind string
|
||||
Path string
|
||||
Left string
|
||||
Right string
|
||||
}
|
||||
|
||||
// ConfigDiff compares two explicitly selected profiles through the same
|
||||
// read-only configuration resolution boundary used by config validate, show,
|
||||
// and sources. Differences describe normalized effective values rather than
|
||||
// the layout of root, import, or overlay files.
|
||||
func ConfigDiff(_ context.Context, args []string, out io.Writer) error {
|
||||
request, err := parseConfigDiffRequest(args, out)
|
||||
if err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
resolved, err := resolveConfigDiff(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config diff: %w", err)
|
||||
}
|
||||
leftValues, err := config.EffectivePipelineValues(resolved.left.Pipeline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config diff: project left effective configuration: %w", err)
|
||||
}
|
||||
rightValues, err := config.EffectivePipelineValues(resolved.right.Pipeline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config diff: project right effective configuration: %w", err)
|
||||
}
|
||||
records, err := diffEffectivePipelineValues(leftValues, rightValues)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config diff: %w", err)
|
||||
}
|
||||
leftDigest := config.EffectivePipelineDigest(resolved.left.Pipeline)
|
||||
rightDigest := config.EffectivePipelineDigest(resolved.right.Pipeline)
|
||||
if len(records) == 0 {
|
||||
if leftDigest != rightDigest {
|
||||
return fmt.Errorf("internal error: effective pipeline digests differ without a semantic difference")
|
||||
}
|
||||
_, err := fmt.Fprintln(out, "no differences")
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
switch record.Kind {
|
||||
case "added":
|
||||
if _, err := fmt.Fprintf(out, "added\t%s\t%s\n", record.Path, record.Right); err != nil {
|
||||
return err
|
||||
}
|
||||
case "removed":
|
||||
if _, err := fmt.Fprintf(out, "removed\t%s\t%s\n", record.Path, record.Left); err != nil {
|
||||
return err
|
||||
}
|
||||
case "changed":
|
||||
if _, err := fmt.Fprintf(out, "changed\t%s\t%s\t%s\n", record.Path, record.Left, record.Right); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("internal error: unsupported difference kind %q", record.Kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, error) {
|
||||
profiles, flagArgs := splitConfigDiffArguments(args)
|
||||
fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
|
||||
fs.SetOutput(out)
|
||||
var request configDiffRequest
|
||||
request.flags.pipelinePath.name = "config"
|
||||
request.flags.campaignPath.name = "campaign"
|
||||
request.flags.campaignFilePath.name = "campaign-file"
|
||||
fs.Var(&request.flags.pipelinePath, "config", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.Var(&request.flags.campaignPath, "campaign", "campaign ID")
|
||||
fs.Var(&request.flags.campaignFilePath, "campaign-file", "path to campaign.yml")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(out, "Usage: narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>]")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Compares fully resolved profiles without a session, workspace, manifest, adapters, or external services.")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Flags:")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(flagArgs); err != nil {
|
||||
return configDiffRequest{}, fmt.Errorf("config diff: invalid flags: %w", err)
|
||||
}
|
||||
profiles = append(profiles, fs.Args()...)
|
||||
if len(profiles) != 2 || strings.TrimSpace(profiles[0]) == "" || strings.TrimSpace(profiles[1]) == "" {
|
||||
return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty profile names are required")
|
||||
}
|
||||
if request.flags.campaignPath.set && request.flags.campaignFilePath.set {
|
||||
return configDiffRequest{}, fmt.Errorf("config diff: --campaign and --campaign-file are mutually exclusive")
|
||||
}
|
||||
request.leftProfile = profiles[0]
|
||||
request.rightProfile = profiles[1]
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// splitConfigDiffArguments accepts the documented positional-first syntax and
|
||||
// also permits flags before or between profile names. Values belonging to the
|
||||
// supported string flags stay with their flag so they are not mistaken for
|
||||
// profile names.
|
||||
func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) {
|
||||
for index := 0; index < len(args); index++ {
|
||||
argument := args[index]
|
||||
if !strings.HasPrefix(argument, "-") || argument == "-" {
|
||||
profiles = append(profiles, argument)
|
||||
continue
|
||||
}
|
||||
flagArgs = append(flagArgs, argument)
|
||||
name, hasValue := strings.CutPrefix(argument, "--")
|
||||
if !hasValue {
|
||||
continue
|
||||
}
|
||||
name, _, hasInlineValue := strings.Cut(name, "=")
|
||||
if hasInlineValue || !configDiffStringFlag(name) || index+1 >= len(args) {
|
||||
continue
|
||||
}
|
||||
index++
|
||||
flagArgs = append(flagArgs, args[index])
|
||||
}
|
||||
return profiles, flagArgs
|
||||
}
|
||||
|
||||
func configDiffStringFlag(name string) bool {
|
||||
switch name {
|
||||
case "config", "campaign", "campaign-file", "profile":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveConfigDiff(request configDiffRequest) (*configDiffResolved, error) {
|
||||
pipelinePath, err := resolvePipelineConfigPath(request.flags.pipelinePath.value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leftPipeline, rightPipeline, err := config.LoadPipelineProfilePair(pipelinePath, request.leftProfile, request.rightProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
needsCampaign := pipelineHasArtifactFamilies(leftPipeline) || pipelineHasArtifactFamilies(rightPipeline)
|
||||
hasCampaignSelection := request.flags.campaignPath.set || request.flags.campaignFilePath.set
|
||||
if !needsCampaign && !hasCampaignSelection {
|
||||
if err := validateInspectionPipeline(pipelinePath, leftPipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateInspectionPipeline(pipelinePath, rightPipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &configDiffResolved{
|
||||
left: &inspectionConfig{PipelinePath: pipelinePath, Pipeline: leftPipeline},
|
||||
right: &inspectionConfig{PipelinePath: pipelinePath, Pipeline: rightPipeline},
|
||||
}, nil
|
||||
}
|
||||
|
||||
campaignPath, err := resolveSharedDiffCampaignPath(leftPipeline, rightPipeline, request.flags)
|
||||
if err != nil {
|
||||
if needsCampaign {
|
||||
return nil, fmt.Errorf("pipeline.scriptorium.artifact_families requires one shared campaign: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
campaign, err := config.LoadCampaign(campaignPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if request.flags.campaignPath.set && !request.flags.campaignFilePath.set {
|
||||
if got := config.CampaignID(campaign); got != request.flags.campaignPath.value {
|
||||
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", campaignPath, got, request.flags.campaignPath.value)
|
||||
}
|
||||
}
|
||||
leftLoaded, err := config.LoadPipelineCampaign(pipelinePath, leftPipeline, campaignPath, campaign)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rightLoaded, err := config.LoadPipelineCampaignWithParty(pipelinePath, rightPipeline, campaignPath, campaign, leftLoaded.Party)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateInspectionPipeline(leftLoaded.PipelinePath, leftLoaded.Pipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateInspectionPipeline(rightLoaded.PipelinePath, rightLoaded.Pipeline); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateInspectionCampaign(campaignPath, campaign); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &configDiffResolved{
|
||||
left: &inspectionConfig{
|
||||
PipelinePath: leftLoaded.PipelinePath,
|
||||
Pipeline: leftLoaded.Pipeline,
|
||||
CampaignPath: leftLoaded.CampaignPath,
|
||||
Campaign: campaign,
|
||||
Party: leftLoaded.Party,
|
||||
},
|
||||
right: &inspectionConfig{
|
||||
PipelinePath: rightLoaded.PipelinePath,
|
||||
Pipeline: rightLoaded.Pipeline,
|
||||
CampaignPath: rightLoaded.CampaignPath,
|
||||
Campaign: campaign,
|
||||
Party: rightLoaded.Party,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveSharedDiffCampaignPath(left, right *config.PipelineConfig, flags configDiffFlags) (string, error) {
|
||||
leftPath, leftErr := resolveCampaignConfigPath(left, flags.campaignPath.value, flags.campaignFilePath.value)
|
||||
if leftErr != nil {
|
||||
return "", leftErr
|
||||
}
|
||||
rightPath, rightErr := resolveCampaignConfigPath(right, flags.campaignPath.value, flags.campaignFilePath.value)
|
||||
if rightErr != nil {
|
||||
return "", rightErr
|
||||
}
|
||||
if leftPath != rightPath {
|
||||
return "", fmt.Errorf("profile selections resolve different campaign files; specify --campaign-file to compare one campaign")
|
||||
}
|
||||
return leftPath, nil
|
||||
}
|
||||
|
||||
func diffEffectivePipelineValues(left, right []config.EffectivePipelineValueRecord) ([]configDiffRecord, error) {
|
||||
leftByPath, err := effectivePipelineValueMap("left", left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rightByPath, err := effectivePipelineValueMap("right", right)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths := make([]string, 0, len(leftByPath)+len(rightByPath))
|
||||
seen := make(map[string]struct{}, len(leftByPath)+len(rightByPath))
|
||||
for path := range leftByPath {
|
||||
seen[path] = struct{}{}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
for path := range rightByPath {
|
||||
if _, exists := seen[path]; !exists {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
records := make([]configDiffRecord, 0)
|
||||
for _, path := range paths {
|
||||
leftValue, leftExists := leftByPath[path]
|
||||
rightValue, rightExists := rightByPath[path]
|
||||
switch {
|
||||
case !leftExists:
|
||||
records = append(records, configDiffRecord{Kind: "added", Path: path, Right: rightValue})
|
||||
case !rightExists:
|
||||
records = append(records, configDiffRecord{Kind: "removed", Path: path, Left: leftValue})
|
||||
case leftValue != rightValue:
|
||||
records = append(records, configDiffRecord{Kind: "changed", Path: path, Left: leftValue, Right: rightValue})
|
||||
}
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func effectivePipelineValueMap(side string, records []config.EffectivePipelineValueRecord) (map[string]string, error) {
|
||||
values := make(map[string]string, len(records))
|
||||
for _, record := range records {
|
||||
if _, exists := values[record.Path]; exists {
|
||||
return nil, fmt.Errorf("internal error: %s effective configuration has duplicate path %q", side, record.Path)
|
||||
}
|
||||
values[record.Path] = record.Value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -14,14 +14,10 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
)
|
||||
|
||||
type pipelineCampaignConfig struct {
|
||||
PipelinePath string
|
||||
CampaignPath string
|
||||
Pipeline *config.PipelineConfig
|
||||
Campaign *config.CampaignConfig
|
||||
}
|
||||
type pipelineCampaignConfig = config.LoadedPipelineCampaign
|
||||
|
||||
var downloadObjectToTempFn = storage.DownloadObjectToTemp
|
||||
var loadPipelineConfigFn = config.LoadPipelineWithOptions
|
||||
|
||||
type commandConfig struct {
|
||||
Config *config.Config
|
||||
@@ -52,13 +48,13 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
}
|
||||
}()
|
||||
|
||||
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag)
|
||||
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag, config.PipelineLoadOptions{Profile: sessionOpts.Profile})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" {
|
||||
cfg, err := config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, explicitSession, sessionOpts)
|
||||
cfg, err := config.LoadSessionWithPipelineCampaignOptions(*base, explicitSession, sessionOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -70,7 +66,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
return nil, err
|
||||
}
|
||||
if discoveredSession.Path != "" {
|
||||
cfg, err := config.LoadWithSessionOptions(base.PipelinePath, base.CampaignPath, discoveredSession.Path, sessionOpts)
|
||||
cfg, err := config.LoadSessionWithPipelineCampaignOptions(*base, discoveredSession.Path, sessionOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,11 +84,9 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
}
|
||||
sessionPrefix := artifacts.S3SessionPrefix(rootPrefix, config.CampaignID(base.Campaign), sessionID)
|
||||
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
|
||||
partialCfg := &config.Config{
|
||||
Pipeline: base.Pipeline,
|
||||
Campaign: base.Campaign,
|
||||
PipelinePath: base.PipelinePath,
|
||||
CampaignPath: base.CampaignPath,
|
||||
partialCfg, err := config.ResolveLoadedPipelineCampaign(*base, "", nil, config.SessionSource{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := newCommandObjectStore(ctx, partialCfg, nil)
|
||||
if err != nil {
|
||||
@@ -122,11 +116,8 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg, err := config.Resolve(
|
||||
base.PipelinePath,
|
||||
base.Pipeline,
|
||||
base.CampaignPath,
|
||||
base.Campaign,
|
||||
cfg, err := config.ResolveLoadedPipelineCampaign(
|
||||
*base,
|
||||
sessionTempPath,
|
||||
sessionCfg,
|
||||
config.SessionSource{
|
||||
@@ -146,12 +137,8 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) {
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
||||
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string, pipelineOpts config.PipelineLoadOptions) (*pipelineCampaignConfig, error) {
|
||||
loadedPipelinePath, pipelineCfg, err := loadPipelineConfig(pipelineFlag, pipelineOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,12 +155,23 @@ func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag str
|
||||
return nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID)
|
||||
}
|
||||
}
|
||||
return &pipelineCampaignConfig{
|
||||
PipelinePath: resolvedPipelinePath,
|
||||
CampaignPath: resolvedCampaignPath,
|
||||
Pipeline: pipelineCfg,
|
||||
Campaign: campaignCfg,
|
||||
}, nil
|
||||
loaded, err := config.LoadPipelineCampaign(loadedPipelinePath, pipelineCfg, resolvedCampaignPath, campaignCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &loaded, nil
|
||||
}
|
||||
|
||||
func loadPipelineConfig(pipelineFlag string, opts config.PipelineLoadOptions) (string, *config.PipelineConfig, error) {
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
pipelineCfg, err := loadPipelineConfigFn(resolvedPipelinePath, opts)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return resolvedPipelinePath, pipelineCfg, nil
|
||||
}
|
||||
|
||||
func findRemoteSessionConfig(ctx context.Context, store storage.ObjectStore, sessionPrefix, remoteKey string) (storage.ObjectInfo, error) {
|
||||
|
||||
50
internal/app/config_loader_test.go
Normal file
50
internal/app/config_loader_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestLoadCommandConfigRetainsInitiallyLoadedPipeline(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
originalLoader := loadPipelineConfigFn
|
||||
loadCalls := 0
|
||||
loadPipelineConfigFn = func(path string, opts config.PipelineLoadOptions) (*config.PipelineConfig, error) {
|
||||
loaded, err := originalLoader(path, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loadCalls++
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changedRoot := workspaceRoot + "-changed"
|
||||
updated := strings.ReplaceAll(string(data), workspaceRoot, changedRoot)
|
||||
if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
t.Cleanup(func() { loadPipelineConfigFn = originalLoader })
|
||||
|
||||
loaded, err := loadCommandConfig(context.Background(), pipelinePath, "", campaignPath, sessionPath, config.SessionLoadOptions{SessionID: "2026-05-03"})
|
||||
if err != nil {
|
||||
t.Fatalf("loadCommandConfig() error = %v", err)
|
||||
}
|
||||
defer func() { _ = loaded.Close() }()
|
||||
|
||||
if loadCalls != 1 {
|
||||
t.Fatalf("pipeline load calls = %d, want 1", loadCalls)
|
||||
}
|
||||
if got := loaded.Config.Pipeline.Workspace.Root; got != workspaceRoot {
|
||||
t.Fatalf("resolved workspace root = %q, want originally loaded %q", got, workspaceRoot)
|
||||
}
|
||||
}
|
||||
70
internal/app/config_provenance.go
Normal file
70
internal/app/config_provenance.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func effectiveConfigProvenance(cfg *config.Config) *manifest.EffectiveConfigProvenance {
|
||||
if cfg == nil || cfg.Pipeline == nil {
|
||||
return nil
|
||||
}
|
||||
provenance := &manifest.EffectiveConfigProvenance{
|
||||
EffectiveConfigDigest: config.EffectivePipelineDigest(cfg.Pipeline),
|
||||
}
|
||||
if selected, ok := config.SelectedPipelineProfile(cfg.Pipeline); ok {
|
||||
provenance.SelectedProfile = &manifest.SelectedProfileProvenance{
|
||||
Name: selected.Name,
|
||||
Source: selected.Source,
|
||||
}
|
||||
}
|
||||
if provenance.SelectedProfile == nil && provenance.EffectiveConfigDigest == "" {
|
||||
return nil
|
||||
}
|
||||
return provenance
|
||||
}
|
||||
|
||||
func applyEffectiveConfigProvenance(m *manifest.Manifest, cfg *config.Config) {
|
||||
if m != nil {
|
||||
m.EffectiveConfig = effectiveConfigProvenance(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func applyEffectiveConfigProvenanceToRun(m *manifest.RunManifest, cfg *config.Config) {
|
||||
if m != nil {
|
||||
m.EffectiveConfig = effectiveConfigProvenance(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveConfigSummary(cfg *config.Config) string {
|
||||
provenance := effectiveConfigProvenance(cfg)
|
||||
if provenance == nil {
|
||||
return "profile=none digest=unavailable"
|
||||
}
|
||||
profile := "none"
|
||||
if provenance.SelectedProfile != nil {
|
||||
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
|
||||
}
|
||||
digest := provenance.EffectiveConfigDigest
|
||||
if digest == "" {
|
||||
digest = "unavailable"
|
||||
}
|
||||
return fmt.Sprintf("profile=%s digest=%s", profile, digest)
|
||||
}
|
||||
|
||||
func persistedEffectiveConfigSummary(provenance *manifest.EffectiveConfigProvenance) string {
|
||||
if provenance == nil {
|
||||
return "profile=none digest=unavailable"
|
||||
}
|
||||
profile := "none"
|
||||
if provenance.SelectedProfile != nil {
|
||||
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
|
||||
}
|
||||
digest := provenance.EffectiveConfigDigest
|
||||
if digest == "" {
|
||||
digest = "unavailable"
|
||||
}
|
||||
return fmt.Sprintf("profile=%s digest=%s", profile, digest)
|
||||
}
|
||||
@@ -43,12 +43,13 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
|
||||
}
|
||||
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
||||
fmt.Fprintln(out, "Configured:")
|
||||
origins := config.ArtifactFamilies(cfg.Pipeline).Members
|
||||
for _, entry := range catalog.ListConfigured() {
|
||||
state := "unavailable"
|
||||
if entry.Available {
|
||||
state = "available"
|
||||
}
|
||||
writeExtractionArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet)
|
||||
writeConfiguredArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet, origins)
|
||||
}
|
||||
fmt.Fprintln(out, "Extraction:")
|
||||
for _, entry := range catalog.ListExtraction() {
|
||||
@@ -70,6 +71,22 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfiguredArtifactLine(out io.Writer, source, state, provenance string, lockSet map[string]config.PublishLockRule, origins map[string]config.ArtifactFamilyMemberOrigin) {
|
||||
parts := []string{source, "planned", state}
|
||||
if key, ok := artifactpolicy.ParseConfiguredSource(source); ok {
|
||||
if origin, family := origins[key]; family {
|
||||
parts = append(parts, "family="+origin.Family, "character_id="+origin.CharacterID)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(provenance) != "" {
|
||||
parts = append(parts, "provenance="+strings.TrimSpace(provenance))
|
||||
}
|
||||
if _, ok := lockSet[source]; ok {
|
||||
parts = append(parts, "locked")
|
||||
}
|
||||
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func writeExtractionArtifactLine(out io.Writer, source, state, provenance string, lockSet map[string]config.PublishLockRule) {
|
||||
parts := []string{source, "planned", state}
|
||||
if strings.TrimSpace(provenance) != "" {
|
||||
|
||||
@@ -20,6 +20,7 @@ type commonConfigFlags struct {
|
||||
sessionPath string
|
||||
sessionID string
|
||||
previousSessionID string
|
||||
profile singletonStringFlag
|
||||
}
|
||||
|
||||
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
||||
@@ -29,12 +30,15 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
||||
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
|
||||
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||
flags.profile.name = "profile"
|
||||
fs.Var(&flags.profile, "profile", "named pipeline profile")
|
||||
}
|
||||
|
||||
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
||||
return config.SessionLoadOptions{
|
||||
SessionID: f.sessionID,
|
||||
PreviousSessionID: f.previousSessionID,
|
||||
Profile: f.profile.pointer(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
||||
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
||||
var profile singletonStringFlag
|
||||
var remote, force bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||
@@ -33,6 +34,8 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
||||
fs.StringVar(&output, "output", "", "local output session.yml path")
|
||||
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
|
||||
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
|
||||
profile.name = "profile"
|
||||
fs.Var(&profile, "profile", "named pipeline profile")
|
||||
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
|
||||
fs.BoolVar(&force, "force", false, "overwrite existing target")
|
||||
if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil {
|
||||
@@ -48,7 +51,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
||||
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
|
||||
}
|
||||
|
||||
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath)
|
||||
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Profile: profile.pointer()})
|
||||
if err != nil {
|
||||
return fmt.Errorf("session init: %w", err)
|
||||
}
|
||||
@@ -79,7 +82,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("session init: %w", err)
|
||||
}
|
||||
cfg, err := config.Resolve(base.PipelinePath, base.Pipeline, base.CampaignPath, base.Campaign, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
|
||||
cfg, err := config.ResolveLoadedPipelineCampaign(*base, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
|
||||
if err != nil {
|
||||
return fmt.Errorf("session init: %w", err)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
||||
fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign)
|
||||
fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
|
||||
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
|
||||
fmt.Fprintf(out, "Configuration (current): %s\n", effectiveConfigSummary(cfg))
|
||||
writeStatusStableInputs(out, inspectStableInputs(cfg))
|
||||
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
|
||||
|
||||
@@ -51,6 +52,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
||||
} else {
|
||||
localManifest = m
|
||||
fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath)
|
||||
fmt.Fprintf(out, "Configuration (last persisted): %s\n", persistedEffectiveConfigSummary(m.EffectiveConfig))
|
||||
writeStageStatuses(out, m)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,11 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
if err := config.Validate(cfg); err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
effective, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
|
||||
selectedArtifacts, err := normalizeArtifactSelection(cfg, request.SelectedArtifacts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
effective, err := resolveEffectiveArtifacts(cfg, selectedArtifacts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
@@ -55,27 +59,29 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
stages := request.Plan.Stages()
|
||||
stageEnv := &stage.Env{
|
||||
Config: cfg, SelectedArtifactKeys: append([]string(nil), request.SelectedArtifacts...),
|
||||
Config: cfg, SelectedArtifactKeys: append([]string(nil), selectedArtifacts...),
|
||||
EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force,
|
||||
}
|
||||
|
||||
runCount := 0
|
||||
skipCount := 0
|
||||
if _, err := fmt.Fprintf(out, "narratio session plan: read-only workdir at %s\n", paths.Root); err != nil {
|
||||
if _, err := fmt.Fprintf(out, "narratio session plan: read-only workdir at %s; %s\n", paths.Root, effectiveConfigSummary(cfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, selectedStage := range stages {
|
||||
semanticConfig, err := currentStageSemanticConfig(selectedStage, stageEnv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: fingerprint semantic configuration for stage %q: %w", selectedStage.Name(), err)
|
||||
}
|
||||
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 action == stageActionSkip {
|
||||
checked, validationErr := evaluateStageResume(ctx, selectedStage, stageEnv, model, semanticConfig)
|
||||
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 {
|
||||
validation = checked
|
||||
if checked != nil && !checked.Resumable {
|
||||
at := time.Now().UTC()
|
||||
model.MarkStageStale(selectedStage.Name(), at, checked.Reason)
|
||||
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(
|
||||
@@ -86,6 +92,16 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
action = stageActionRun
|
||||
}
|
||||
}
|
||||
if action == stageActionRun && selectedStage.Name() == "analyze" {
|
||||
if validator, ok := selectedStage.(stage.ResumeValidator); ok {
|
||||
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 == stageActionRun {
|
||||
runCount++
|
||||
} else {
|
||||
@@ -100,7 +116,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
}
|
||||
}
|
||||
if action == stageActionRun {
|
||||
if err := modelPlannedStageRun(model, selectedStage, cfg, request.Force); err != nil {
|
||||
if err := modelPlannedStageRun(model, selectedStage, cfg, request.Force, semanticConfig); err != nil {
|
||||
return fmt.Errorf("plan: model stage %q: %w", selectedStage.Name(), err)
|
||||
}
|
||||
}
|
||||
@@ -129,7 +145,13 @@ func cloneManifestForPlan(source *manifest.Manifest, cfg *config.Config) (*manif
|
||||
return &cloned, nil
|
||||
}
|
||||
|
||||
func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, cfg *config.Config, force bool) error {
|
||||
func modelPlannedStageRun(
|
||||
model *manifest.Manifest,
|
||||
selectedStage stage.Stage,
|
||||
cfg *config.Config,
|
||||
force bool,
|
||||
semanticConfig *manifest.SemanticConfigFingerprint,
|
||||
) error {
|
||||
prior := capturePriorStageOutcome(model, selectedStage.Name())
|
||||
at := time.Now().UTC()
|
||||
model.MarkStageRunning(selectedStage.Name(), at)
|
||||
@@ -142,6 +164,7 @@ func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, c
|
||||
}
|
||||
if reason := plannedSelfSkipReason(selectedStage.Name(), cfg); reason != "" {
|
||||
model.MarkStageSkipped(selectedStage.Name(), at, reason)
|
||||
setSessionStageSemanticConfig(model, selectedStage.Name(), semanticConfig)
|
||||
if !prior.isSameSelfSkip(reason) {
|
||||
_, err := invalidateDependentSucceededStagesWithReason(
|
||||
model, selectedStage.Name(), at, staleReasonSelfSkip,
|
||||
@@ -151,6 +174,7 @@ func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, c
|
||||
return nil
|
||||
}
|
||||
model.MarkStageSucceeded(selectedStage.Name(), at, nil)
|
||||
setSessionStageSemanticConfig(model, selectedStage.Name(), semanticConfig)
|
||||
if !prior.exists || prior.status != manifest.StatusSucceeded {
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||
model, selectedStage.Name(), at, staleReasonChangedResult,
|
||||
@@ -206,7 +230,11 @@ func planArtifactList(values []stage.AnalyzeResumeArtifact) string {
|
||||
if value.Forced {
|
||||
detail += ":forced"
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s(%s)", value.Key, detail))
|
||||
identity := value.Key
|
||||
if value.Family != "" {
|
||||
identity += fmt.Sprintf("[family=%s character_id=%s]", value.Family, value.CharacterID)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s(%s)", identity, detail))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
@@ -61,9 +61,13 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
|
||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath)
|
||||
m, err := store.Load(context.Background(), manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load prepared manifest: %v", err)
|
||||
}
|
||||
m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil)
|
||||
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe")
|
||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||
t.Fatalf("save manifest: %v", err)
|
||||
}
|
||||
|
||||
72
internal/app/profile_commands_test.go
Normal file
72
internal/app/profile_commands_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
enableCommandTestProfiles(t, pipelinePath)
|
||||
|
||||
original := executeStagesFn
|
||||
t.Cleanup(func() { executeStagesFn = original })
|
||||
var language string
|
||||
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
|
||||
language = cfg.Pipeline.WhisperX.Language
|
||||
return &RunSummary{SessionID: cfg.Session.SessionID, ManifestPath: "manifest.json"}, nil
|
||||
}
|
||||
|
||||
if err := Run(context.Background(), []string{
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "testing",
|
||||
}, io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if language != "fr" {
|
||||
t.Fatalf("explicit profile language = %q, want fr", language)
|
||||
}
|
||||
|
||||
if err := Run(context.Background(), []string{
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||
}, io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if language != "en" {
|
||||
t.Fatalf("default profile language = %q, want en", language)
|
||||
}
|
||||
|
||||
for _, profile := range []string{"unknown", ""} {
|
||||
err := Run(context.Background(), []string{
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", profile,
|
||||
}, io.Discard)
|
||||
if err == nil || !strings.Contains(err.Error(), "profile") {
|
||||
t.Fatalf("profile %q error = %v, want selection rejection", profile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enableCommandTestProfiles(t *testing.T, pipelinePath string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
composition := "composition:\n default_profile: production\n profiles:\n production:\n overlay: production.yml\n testing:\n overlay: testing.yml\n"
|
||||
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(pipelinePath)
|
||||
if err := os.WriteFile(filepath.Join(dir, "production.yml"), []byte("whisperx:\n language: en\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "testing.yml"), []byte("whisperx:\n language: fr\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
|
||||
"--artifacts=player_handout",
|
||||
"--config", "pipeline.yml",
|
||||
"--campaign", "sample-campaign",
|
||||
"--profile", "testing",
|
||||
}, io.Discard, io.Discard)
|
||||
if code != 0 {
|
||||
t.Fatalf("Execute() code = %d, want 0", code)
|
||||
@@ -34,6 +35,7 @@ func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
|
||||
"--artifacts=player_handout",
|
||||
"--config", "pipeline.yml",
|
||||
"--campaign", "sample-campaign",
|
||||
"--profile", "testing",
|
||||
}
|
||||
if !reflect.DeepEqual(captured, want) {
|
||||
t.Fatalf("forwarded args = %#v, want %#v", captured, want)
|
||||
|
||||
@@ -44,6 +44,49 @@ inputs:
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSessionLoadingRetainsCampaignCanonicalParty(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
canonicalCampaign := `campaign_id: sample-campaign
|
||||
inputs:
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
party_file: ./party.yml
|
||||
`
|
||||
if err := os.WriteFile(campaignPath, []byte(canonicalCampaign), 0o644); err != nil {
|
||||
t.Fatalf("write canonical campaign: %v", err)
|
||||
}
|
||||
canonicalParty := `schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Arannis
|
||||
classes: [{name: wizard}]
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(campaignPath), "party.yml"), []byte(canonicalParty), 0o644); err != nil {
|
||||
t.Fatalf("write canonical party: %v", err)
|
||||
}
|
||||
fake := &storage.FakeBackend{}
|
||||
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
|
||||
inputs:
|
||||
audio_s3:
|
||||
prefix: audio/
|
||||
`)
|
||||
var storeInitCalls int
|
||||
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
|
||||
|
||||
loaded, err := loadCommandConfig(context.Background(), pipelinePath, "", campaignPath, "", config.SessionLoadOptions{SessionID: "2026-05-03"})
|
||||
if err != nil {
|
||||
t.Fatalf("loadCommandConfig() error = %v", err)
|
||||
}
|
||||
defer func() { _ = loaded.Close() }()
|
||||
if loaded.Config.Party.Mode != config.PartyModeCanonical || loaded.Config.StableInputs.PlayersFile.Source != "derived_from_party" {
|
||||
t.Fatalf("remote config party = %#v, players = %#v", loaded.Config.Party, loaded.Config.StableInputs.PlayersFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -166,6 +209,9 @@ inputs:
|
||||
if err := loaded.Close(); err != nil {
|
||||
t.Fatalf("second Close() error = %v", err)
|
||||
}
|
||||
if loaded.Config == nil || loaded.Config.Pipeline == nil || loaded.Config.Campaign == nil || loaded.Config.Session == nil {
|
||||
t.Fatal("closing remote session cleanup discarded retained configuration")
|
||||
}
|
||||
if _, err := os.Stat(downloadedPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("downloaded remote session path still exists or could not be inspected: %q, err=%v", downloadedPath, err)
|
||||
}
|
||||
|
||||
@@ -29,13 +29,17 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||
if err := config.Validate(cfg); err != nil {
|
||||
return fmt.Errorf("run: %w", err)
|
||||
}
|
||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
|
||||
selectedArtifacts, err := normalizeArtifactSelection(cfg, request.SelectedArtifacts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run: %w", err)
|
||||
}
|
||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, selectedArtifacts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run: %w", err)
|
||||
}
|
||||
summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{
|
||||
Force: request.Force,
|
||||
SelectedArtifacts: request.SelectedArtifacts,
|
||||
SelectedArtifacts: selectedArtifacts,
|
||||
EffectiveArtifacts: effectiveArtifacts,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -44,11 +48,12 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
_, err = fmt.Fprintf(
|
||||
out,
|
||||
"narratio run: session %s; executed=%d skipped=%d; manifest=%s\n",
|
||||
"narratio run: session %s; executed=%d skipped=%d; manifest=%s; %s\n",
|
||||
summary.SessionID,
|
||||
len(summary.Executed),
|
||||
len(summary.Skipped),
|
||||
summary.ManifestPath,
|
||||
effectiveConfigSummary(cfg),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -216,13 +216,17 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
|
||||
if err := config.Validate(cfg); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||
}
|
||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, req.SelectedArtifacts)
|
||||
selectedArtifacts, err := normalizeArtifactSelection(cfg, req.SelectedArtifacts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||
}
|
||||
effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, selectedArtifacts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||
}
|
||||
summary, err := executeStagesFn(ctx, cfg, plan, RunOptions{
|
||||
Force: req.Force,
|
||||
SelectedArtifacts: req.SelectedArtifacts,
|
||||
SelectedArtifacts: selectedArtifacts,
|
||||
EffectiveArtifacts: effectiveArtifacts,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -19,20 +19,21 @@ func TestRunContinuesAfterCompletedStages(t *testing.T) {
|
||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
m.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath)
|
||||
m, err := store.Load(context.Background(), manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load prepared manifest: %v", err)
|
||||
}
|
||||
m.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), nil)
|
||||
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe")
|
||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||
t.Fatalf("save manifest: %v", err)
|
||||
}
|
||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "raw", "alice.json"), `{"segments":[]}`)
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "speakers.yml"), "match:\n - speaker: Alice\n match: [\"alice\"]\n")
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "autocorrect.yml"), "[]\n")
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
err = Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
@@ -55,17 +56,22 @@ func TestRunNoRemainingStagesRecordsSkippedStages(t *testing.T) {
|
||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath)
|
||||
m, err := store.Load(context.Background(), manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load prepared manifest: %v", err)
|
||||
}
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||
m.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
m.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), "notarius_disabled")
|
||||
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "publish", "notify")
|
||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||
t.Fatalf("save manifest: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
err = Run(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
@@ -172,6 +178,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
|
||||
store := &manifest.LocalStore{}
|
||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
m.MarkStageSucceeded("polish", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), m, "polish")
|
||||
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||
t.Fatalf("save manifest: %v", err)
|
||||
}
|
||||
@@ -201,19 +208,23 @@ func TestRunStageForceMarksDownstreamStaleAndRunContinuesFromStale(t *testing.T)
|
||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`)
|
||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
||||
materializePrepareResumeFixture(t, pipelinePath, campaignPath, sessionPath)
|
||||
seed, err := store.Load(context.Background(), manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load prepared manifest: %v", err)
|
||||
}
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
||||
seed.MarkStageSucceeded(name, time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
seedCurrentSemanticEvidence(t, loadConfigForSemanticEvidence(t, pipelinePath, campaignPath, sessionPath), seed, "prepare", "transcribe", "merge")
|
||||
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||
t.Fatalf("save manifest: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||
err = RunStage(context.Background(), []string{"polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--force"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage(force) error = %v", err)
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
||||
return nil, fmt.Errorf("validate bounded run prerequisites under session lock: %w", err)
|
||||
}
|
||||
identity.applyToSessionManifest(m)
|
||||
applyEffectiveConfigProvenance(m, cfg)
|
||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
||||
}
|
||||
@@ -172,6 +173,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
||||
return nil, fmt.Errorf("create run manifest: %w", err)
|
||||
}
|
||||
identity.applyToRunManifest(runManifest, manifestPath)
|
||||
applyEffectiveConfigProvenanceToRun(runManifest, cfg)
|
||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||
@@ -259,19 +261,24 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
||||
for _, s := range stages {
|
||||
stageEnv.Force = opts.Force
|
||||
runNames = append(runNames, s.Name())
|
||||
semanticConfig, err := currentStageSemanticConfig(s, stageEnv)
|
||||
if err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||
fmt.Errorf("fingerprint semantic configuration for stage %q: %w", s.Name(), err),
|
||||
)
|
||||
}
|
||||
action := decideStageAction(s, m, opts.Force)
|
||||
|
||||
if action == stageActionSkip {
|
||||
if validator, ok := s.(stage.ResumeValidator); ok {
|
||||
validation, err := validator.ValidateResume(ctx, stageEnv, m)
|
||||
validation, err := evaluateStageResume(ctx, s, stageEnv, m, semanticConfig)
|
||||
if err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||
fmt.Errorf("validate resume for stage %q: %w", s.Name(), err),
|
||||
)
|
||||
}
|
||||
validation = validation.Normalized()
|
||||
if !validation.Resumable {
|
||||
if validation != nil && !validation.Resumable {
|
||||
staleAt := nowUTC()
|
||||
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||
@@ -292,13 +299,13 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
||||
action = stageActionRun
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if action == stageActionSkip {
|
||||
skipped = append(skipped, s.Name())
|
||||
skipAt := nowUTC()
|
||||
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
|
||||
runManifest.MarkStageSkipped(s.Name(), skipAt, "already_succeeded")
|
||||
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
|
||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||
@@ -380,6 +387,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
||||
skippedAt := nowUTC()
|
||||
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||
applyStageResultToManifest(m, s.Name(), result)
|
||||
setSessionStageSemanticConfig(m, s.Name(), semanticConfig)
|
||||
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
@@ -396,6 +404,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
||||
}
|
||||
runManifest.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
||||
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
|
||||
identity.applyToRunManifest(runManifest, manifestPath)
|
||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
@@ -418,6 +427,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
||||
m.MarkStageSucceeded(s.Name(), succeededAt, sessionOutputs)
|
||||
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
||||
applyStageResultToManifest(m, s.Name(), result)
|
||||
setSessionStageSemanticConfig(m, s.Name(), semanticConfig)
|
||||
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
@@ -445,6 +455,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
||||
}
|
||||
runManifest.MarkStageSucceeded(s.Name(), succeededAt, runOutputs)
|
||||
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
||||
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
|
||||
identity.applyToRunManifest(runManifest, manifestPath)
|
||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||
return nil, persistTerminalFailure(
|
||||
|
||||
@@ -642,6 +642,7 @@ func TestExecuteStagesForceRerunsSucceeded(t *testing.T) {
|
||||
|
||||
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||
existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
||||
seedCurrentSemanticEvidence(t, cfg, existing, "transcribe")
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
@@ -983,6 +984,7 @@ func TestExecuteStagesRunManifestRecordsSkippedStage(t *testing.T) {
|
||||
manifestPath := manifestPathFor(cfg)
|
||||
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||
existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
||||
seedCurrentSemanticEvidence(t, cfg, existing, "transcribe")
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
@@ -1232,6 +1234,7 @@ func TestExecuteStagesSkippedStagePreservesExistingOutputsProvenance(t *testing.
|
||||
ProducerRunID: "20260501T000000Z-deadbeef",
|
||||
},
|
||||
})
|
||||
seedCurrentSemanticEvidence(t, cfg, existing, "transcribe")
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
@@ -1403,6 +1406,7 @@ inputs:
|
||||
mustWriteFile(t, pipelinePath, pipelineYAML)
|
||||
mustWriteFile(t, campaignPath, campaignYAML)
|
||||
mustWriteFile(t, sessionPath, sessionYAML)
|
||||
mustWriteFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
|
||||
|
||||
cfg, err := config.Load(pipelinePath, sessionPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,8 +2,10 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
@@ -13,3 +15,37 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage
|
||||
plan := BoundedPlan{stages: append([]stage.Stage(nil), stages...)}
|
||||
return executePlan(ctx, cfg, plan, opts)
|
||||
}
|
||||
|
||||
func seedCurrentSemanticEvidence(t *testing.T, cfg *config.Config, m *manifest.Manifest, names ...string) {
|
||||
t.Helper()
|
||||
providers := make(map[string]stage.SemanticConfigFingerprinter)
|
||||
for _, candidate := range stage.All() {
|
||||
if provider, ok := candidate.(stage.SemanticConfigFingerprinter); ok {
|
||||
providers[candidate.Name()] = provider
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
record := m.Stages[name]
|
||||
if record == nil {
|
||||
t.Fatalf("stage %q must exist before semantic evidence is seeded", name)
|
||||
}
|
||||
provider, ok := providers[name]
|
||||
if !ok {
|
||||
t.Fatalf("stage %q has no semantic fingerprint provider", name)
|
||||
}
|
||||
fingerprint, err := provider.SemanticConfigFingerprint(&stage.Env{Config: cfg})
|
||||
if err != nil {
|
||||
t.Fatalf("fingerprint stage %q: %v", name, err)
|
||||
}
|
||||
record.SemanticConfig = &fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfigForSemanticEvidence(t *testing.T, pipelinePath, campaignPath, sessionPath string) *config.Config {
|
||||
t.Helper()
|
||||
cfg, err := config.Load(pipelinePath, campaignPath, sessionPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load config for semantic evidence: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
95
internal/app/semantic_resume.go
Normal file
95
internal/app/semantic_resume.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
func currentStageSemanticConfig(selected stage.Stage, env *stage.Env) (*manifest.SemanticConfigFingerprint, error) {
|
||||
provider, ok := selected.(stage.SemanticConfigFingerprinter)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
record, err := provider.SemanticConfigFingerprint(env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid current semantic configuration fingerprint: %w", err)
|
||||
}
|
||||
return cloneSemanticConfig(&record), nil
|
||||
}
|
||||
|
||||
func validateStageSemanticResume(selected stage.Stage, m *manifest.Manifest, current *manifest.SemanticConfigFingerprint) stage.ResumeValidation {
|
||||
if current == nil {
|
||||
return stage.Resumable()
|
||||
}
|
||||
var persisted *manifest.SemanticConfigFingerprint
|
||||
if m != nil && m.Stages != nil && m.Stages[selected.Name()] != nil {
|
||||
persisted = m.Stages[selected.Name()].SemanticConfig
|
||||
}
|
||||
if persisted == nil {
|
||||
return stage.NonResumable("semantic configuration evidence is missing; rerun the stage to establish current evidence")
|
||||
}
|
||||
if err := persisted.Validate(); err != nil {
|
||||
return stage.NonResumable("persisted semantic configuration evidence is malformed; rerun the stage")
|
||||
}
|
||||
if persisted.Version != current.Version {
|
||||
return stage.NonResumable("semantic configuration contract version changed; rerun the stage")
|
||||
}
|
||||
if persisted.Digest != current.Digest {
|
||||
return stage.NonResumable("result-affecting configuration changed; rerun the stage")
|
||||
}
|
||||
return stage.Resumable()
|
||||
}
|
||||
|
||||
func evaluateStageResume(
|
||||
ctx context.Context,
|
||||
selected stage.Stage,
|
||||
env *stage.Env,
|
||||
m *manifest.Manifest,
|
||||
current *manifest.SemanticConfigFingerprint,
|
||||
) (*stage.ResumeValidation, error) {
|
||||
semantic := validateStageSemanticResume(selected, m, current).Normalized()
|
||||
if !semantic.Resumable {
|
||||
return &semantic, nil
|
||||
}
|
||||
validator, ok := selected.(stage.ResumeValidator)
|
||||
if !ok {
|
||||
if current == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &semantic, nil
|
||||
}
|
||||
validation, err := validator.ValidateResume(ctx, env, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validation = validation.Normalized()
|
||||
return &validation, nil
|
||||
}
|
||||
|
||||
func setSessionStageSemanticConfig(m *manifest.Manifest, name string, value *manifest.SemanticConfigFingerprint) {
|
||||
if m == nil || m.Stages == nil || m.Stages[name] == nil {
|
||||
return
|
||||
}
|
||||
m.Stages[name].SemanticConfig = cloneSemanticConfig(value)
|
||||
}
|
||||
|
||||
func setRunStageSemanticConfig(m *manifest.RunManifest, name string, value *manifest.SemanticConfigFingerprint) {
|
||||
if m == nil || m.Stages == nil || m.Stages[name] == nil {
|
||||
return
|
||||
}
|
||||
m.Stages[name].SemanticConfig = cloneSemanticConfig(value)
|
||||
}
|
||||
|
||||
func cloneSemanticConfig(value *manifest.SemanticConfigFingerprint) *manifest.SemanticConfigFingerprint {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *value
|
||||
return ©
|
||||
}
|
||||
577
internal/app/semantic_resume_test.go
Normal file
577
internal/app/semantic_resume_test.go
Normal file
@@ -0,0 +1,577 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
||||
)
|
||||
|
||||
type semanticStage struct {
|
||||
name string
|
||||
fingerprint manifest.SemanticConfigFingerprint
|
||||
fingerprintErr error
|
||||
result *stage.StageResult
|
||||
runErr error
|
||||
runs *int
|
||||
}
|
||||
|
||||
func (s semanticStage) Name() string { return s.name }
|
||||
func (s semanticStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||
if s.runs != nil {
|
||||
*s.runs++
|
||||
}
|
||||
return s.result, s.runErr
|
||||
}
|
||||
func (s semanticStage) SemanticConfigFingerprint(_ *stage.Env) (manifest.SemanticConfigFingerprint, error) {
|
||||
return s.fingerprint, s.fingerprintErr
|
||||
}
|
||||
|
||||
type semanticResumeCheckingStage struct {
|
||||
semanticStage
|
||||
validation stage.ResumeValidation
|
||||
validationCalls *int
|
||||
}
|
||||
|
||||
type semanticContractRunStub struct {
|
||||
name string
|
||||
provider stage.SemanticConfigFingerprinter
|
||||
runs *int
|
||||
}
|
||||
|
||||
func (s semanticContractRunStub) Name() string { return s.name }
|
||||
func (s semanticContractRunStub) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||
*s.runs++
|
||||
return &stage.StageResult{}, nil
|
||||
}
|
||||
func (s semanticContractRunStub) SemanticConfigFingerprint(env *stage.Env) (manifest.SemanticConfigFingerprint, error) {
|
||||
return s.provider.SemanticConfigFingerprint(env)
|
||||
}
|
||||
|
||||
func (s semanticResumeCheckingStage) ValidateResume(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (stage.ResumeValidation, error) {
|
||||
if s.validationCalls != nil {
|
||||
*s.validationCalls++
|
||||
}
|
||||
return s.validation, nil
|
||||
}
|
||||
|
||||
func TestSemanticResumeComparison(t *testing.T) {
|
||||
current := semanticFingerprint(1, "a")
|
||||
selected := semanticStage{name: "render", fingerprint: current}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
persisted *manifest.SemanticConfigFingerprint
|
||||
resumable bool
|
||||
want string
|
||||
}{
|
||||
{name: "matching", persisted: ¤t, resumable: true},
|
||||
{name: "missing", want: "missing"},
|
||||
{name: "version", persisted: fingerprintPointer(semanticFingerprint(2, "a")), want: "version"},
|
||||
{name: "digest", persisted: fingerprintPointer(semanticFingerprint(1, "b")), want: "configuration changed"},
|
||||
{name: "malformed", persisted: &manifest.SemanticConfigFingerprint{Version: 1, Digest: "bad"}, want: "malformed"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
model := manifest.New("session", time.Now().UTC())
|
||||
model.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||
model.Stages["render"].SemanticConfig = test.persisted
|
||||
got := validateStageSemanticResume(selected, model, ¤t)
|
||||
if got.Resumable != test.resumable || (test.want != "" && !strings.Contains(got.Reason, test.want)) {
|
||||
t.Fatalf("validation = %#v, want resumable=%v reason %q", got, test.resumable, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesSemanticEvidenceLifecycle(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
persisted *manifest.SemanticConfigFingerprint
|
||||
force bool
|
||||
wantRuns int
|
||||
wantSkipped int
|
||||
}{
|
||||
{name: "matching skips", persisted: fingerprintPointer(semanticFingerprint(1, "current")), wantSkipped: 1},
|
||||
{name: "legacy missing reruns", wantRuns: 1},
|
||||
{name: "version reruns", persisted: fingerprintPointer(semanticFingerprint(2, "current")), wantRuns: 1},
|
||||
{name: "digest reruns", persisted: fingerprintPointer(semanticFingerprint(1, "old")), wantRuns: 1},
|
||||
{name: "force reruns matching", persisted: fingerprintPointer(semanticFingerprint(1, "current")), force: true, wantRuns: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
seed.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||
seed.Stages["render"].SemanticConfig = test.persisted
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs := 0
|
||||
current := semanticFingerprint(1, "current")
|
||||
candidate := semanticStage{name: "render", fingerprint: current, result: &stage.StageResult{}, runs: &runs}
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{Force: test.force})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runs != test.wantRuns || len(summary.Skipped) != test.wantSkipped {
|
||||
t.Fatalf("runs=%d summary=%#v", runs, summary)
|
||||
}
|
||||
loaded, err := store.Load(context.Background(), summary.ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Stages["render"].SemanticConfig == nil || !loaded.Stages["render"].SemanticConfig.Equal(current) {
|
||||
t.Fatalf("session evidence = %#v", loaded.Stages["render"].SemanticConfig)
|
||||
}
|
||||
run, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if run.Stages["render"].SemanticConfig == nil || !run.Stages["render"].SemanticConfig.Equal(current) {
|
||||
t.Fatalf("run evidence = %#v", run.Stages["render"].SemanticConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticEvidenceRequiresBothChecksAndNeverPromotesFailure(t *testing.T) {
|
||||
t.Run("semantic mismatch precedes resume validator", func(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
seed.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||
seed.Stages["render"].SemanticConfig = fingerprintPointer(semanticFingerprint(1, "old"))
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, validations := 0, 0
|
||||
candidate := semanticResumeCheckingStage{
|
||||
semanticStage: semanticStage{name: "render", fingerprint: semanticFingerprint(1, "new"), result: &stage.StageResult{}, runs: &runs},
|
||||
validation: stage.Resumable(), validationCalls: &validations,
|
||||
}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runs != 1 || validations != 0 {
|
||||
t.Fatalf("runs=%d validations=%d", runs, validations)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matching semantic evidence still requires validator", func(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
store := &manifest.LocalStore{}
|
||||
current := semanticFingerprint(1, "same")
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
seed.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||
seed.Stages["render"].SemanticConfig = ¤t
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs, validations := 0, 0
|
||||
candidate := semanticResumeCheckingStage{
|
||||
semanticStage: semanticStage{name: "render", fingerprint: current, result: &stage.StageResult{}, runs: &runs},
|
||||
validation: stage.NonResumable("output changed"), validationCalls: &validations,
|
||||
}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runs != 1 || validations != 1 {
|
||||
t.Fatalf("runs=%d validations=%d", runs, validations)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("failed execution has no promoted evidence", func(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
runs := 0
|
||||
candidate := semanticStage{
|
||||
name: "render", fingerprint: semanticFingerprint(1, "new"), runErr: errors.New("render failed"), runs: &runs,
|
||||
}
|
||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("executeStages() error = nil")
|
||||
}
|
||||
loaded, loadErr := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
|
||||
if loadErr != nil {
|
||||
t.Fatal(loadErr)
|
||||
}
|
||||
if loaded.Stages["render"].SemanticConfig != nil || runs != 1 {
|
||||
t.Fatalf("failed evidence=%#v runs=%d", loaded.Stages["render"].SemanticConfig, runs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntentionalStageSkipPersistsSemanticEvidence(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
current := semanticFingerprint(1, "disabled")
|
||||
runs := 0
|
||||
candidate := semanticStage{
|
||||
name: "render", fingerprint: current, runs: &runs,
|
||||
result: &stage.StageResult{Disposition: stage.StageDispositionSkipped, SkipReason: "render disabled"},
|
||||
}
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &manifest.LocalStore{}
|
||||
session, err := store.Load(context.Background(), summary.ManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, err := store.LoadRun(context.Background(), summary.RunManifestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runs != 1 || session.Stages["render"].SemanticConfig == nil || run.Stages["render"].SemanticConfig == nil ||
|
||||
!session.Stages["render"].SemanticConfig.Equal(current) || !run.Stages["render"].SemanticConfig.Equal(current) {
|
||||
t.Fatalf("runs=%d session=%#v run=%#v", runs, session.Stages["render"], run.Stages["render"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidCurrentSemanticEvidenceStopsBeforeExecution(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
runs := 0
|
||||
candidate := semanticStage{
|
||||
name: "render", fingerprint: manifest.SemanticConfigFingerprint{Version: 1, Digest: "invalid"}, runs: &runs,
|
||||
}
|
||||
_, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{Force: true})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid current semantic configuration fingerprint") || runs != 0 {
|
||||
t.Fatalf("error=%v runs=%d", err, runs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlySemanticResumeDecisionMutatesOnlyPlanModel(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
original := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
original.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||
original.MarkStageSucceeded("extract", time.Now().UTC(), nil)
|
||||
original.MarkStageSucceeded("analyze", time.Now().UTC(), nil)
|
||||
original.Stages["render"].SemanticConfig = fingerprintPointer(semanticFingerprint(1, "old"))
|
||||
model, err := cloneManifestForPlan(original, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected := semanticStage{name: "render", fingerprint: semanticFingerprint(1, "new")}
|
||||
current, err := currentStageSemanticConfig(selected, &stage.Env{Config: cfg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
validation, err := evaluateStageResume(context.Background(), selected, &stage.Env{Config: cfg}, model, current)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if validation == nil || validation.Resumable {
|
||||
t.Fatalf("validation = %#v, want planned rerun", validation)
|
||||
}
|
||||
at := time.Now().UTC()
|
||||
model.MarkStageStale("render", at, validation.Reason)
|
||||
if _, err := invalidateDependentSucceededStagesWithReason(model, "render", at, staleReasonNotResumable); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if model.Stages["render"].Status != manifest.StatusStale || model.Stages["analyze"].Status != manifest.StatusStale {
|
||||
t.Fatalf("model render=%q analyze=%q", model.Stages["render"].Status, model.Stages["analyze"].Status)
|
||||
}
|
||||
if original.Stages["render"].Status != manifest.StatusSucceeded || original.Stages["analyze"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("authoritative manifest mutated: render=%q analyze=%q", original.Stages["render"].Status, original.Stages["analyze"].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticMismatchInvalidatesOnlyFixedDependents(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
for _, name := range []string{"render", "extract", "analyze", "publish", "notify"} {
|
||||
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
}
|
||||
seed.Stages["render"].SemanticConfig = fingerprintPointer(semanticFingerprint(1, "old"))
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runs := 0
|
||||
candidate := semanticStage{name: "render", fingerprint: semanticFingerprint(1, "new"), result: &stage.StageResult{}, runs: &runs}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Stages["render"].Status != manifest.StatusSucceeded || loaded.Stages["extract"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("render=%q extract=%q", loaded.Stages["render"].Status, loaded.Stages["extract"].Status)
|
||||
}
|
||||
for _, name := range []string{"analyze", "publish", "notify"} {
|
||||
if loaded.Stages[name].Status != manifest.StatusStale {
|
||||
t.Fatalf("%s status = %q, want stale", name, loaded.Stages[name].Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitialPipelineSemanticChangesRerunOnlyAffectedLineage(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*config.Config)
|
||||
wantRuns [3]int
|
||||
}{
|
||||
{name: "prepare selection", mutate: func(cfg *config.Config) {
|
||||
cfg.Session.PreviousSessionID = "2026-04-26"
|
||||
}, wantRuns: [3]int{1, 1, 1}},
|
||||
{name: "transcribe language", mutate: func(cfg *config.Config) {
|
||||
cfg.Pipeline.WhisperX.Language = "fr"
|
||||
}, wantRuns: [3]int{0, 1, 1}},
|
||||
{name: "merge transformation", mutate: func(cfg *config.Config) {
|
||||
value := 1.75
|
||||
cfg.Pipeline.Seriatim.CoalesceGap = &value
|
||||
}, wantRuns: [3]int{0, 0, 1}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
names := []string{"prepare", "transcribe", "merge"}
|
||||
providers := make([]stage.SemanticConfigFingerprinter, len(names))
|
||||
for index, name := range names {
|
||||
providers[index] = canonicalSemanticProvider(t, name)
|
||||
}
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
for _, candidate := range stage.All() {
|
||||
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
|
||||
}
|
||||
for index, name := range names {
|
||||
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: cfg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.Stages[name].SemanticConfig = &fingerprint
|
||||
}
|
||||
store := &manifest.LocalStore{}
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
test.mutate(cfg)
|
||||
|
||||
runs := [3]int{}
|
||||
selected := make([]stage.Stage, 0, len(names))
|
||||
for index, name := range names {
|
||||
selected = append(selected, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
|
||||
}
|
||||
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runs != test.wantRuns {
|
||||
t.Fatalf("runs = %v, want %v", runs, test.wantRuns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefinementSemanticChangesRespectDependencyBranches(t *testing.T) {
|
||||
names := []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*config.Config)
|
||||
wantRuns [7]int
|
||||
wantExtractStatus manifest.StageStatus
|
||||
}{
|
||||
{name: "polish model", mutate: func(cfg *config.Config) {
|
||||
cfg.Pipeline.Audita.Model = "production"
|
||||
}, wantRuns: [7]int{0, 0, 0, 1, 1, 1, 1}, wantExtractStatus: manifest.StatusStale},
|
||||
{name: "normalize schema", mutate: func(cfg *config.Config) {
|
||||
cfg.Pipeline.Normalize = &config.NormalizeConfig{OutputSchema: "seriatim.transcript.v2"}
|
||||
}, wantRuns: [7]int{0, 0, 0, 0, 1, 1, 1}, wantExtractStatus: manifest.StatusStale},
|
||||
{name: "trim prompt", mutate: func(cfg *config.Config) {
|
||||
enabled := true
|
||||
cfg.Pipeline.Trim = &config.TrimConfig{
|
||||
Enabled: &enabled,
|
||||
Bounds: config.TrimBoundsConfig{PromptID: "session-bounds-v2"},
|
||||
}
|
||||
}, wantRuns: [7]int{0, 0, 0, 0, 0, 1, 1}, wantExtractStatus: manifest.StatusStale},
|
||||
{name: "render format", mutate: func(cfg *config.Config) {
|
||||
cfg.Pipeline.Render = &config.RenderConfig{Format: "html"}
|
||||
}, wantRuns: [7]int{0, 0, 0, 0, 0, 0, 1}, wantExtractStatus: manifest.StatusSucceeded},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
providers := make([]stage.SemanticConfigFingerprinter, len(names))
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
for _, candidate := range stage.All() {
|
||||
seed.MarkStageSucceeded(candidate.Name(), time.Now().UTC(), nil)
|
||||
}
|
||||
for index, name := range names {
|
||||
providers[index] = canonicalSemanticProvider(t, name)
|
||||
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: cfg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.Stages[name].SemanticConfig = &fingerprint
|
||||
}
|
||||
store := &manifest.LocalStore{}
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
test.mutate(cfg)
|
||||
|
||||
runs := [7]int{}
|
||||
selected := make([]stage.Stage, 0, len(names))
|
||||
for index, name := range names {
|
||||
selected = append(selected, semanticContractRunStub{name: name, provider: providers[index], runs: &runs[index]})
|
||||
}
|
||||
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runs != test.wantRuns {
|
||||
t.Fatalf("runs = %v, want %v", runs, test.wantRuns)
|
||||
}
|
||||
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := loaded.Stages["extract"].Status; got != test.wantExtractStatus {
|
||||
t.Fatalf("extract status = %q, want %q", got, test.wantExtractStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverySemanticChangesInvalidateOnlyTheirFixedDependents(t *testing.T) {
|
||||
t.Run("extract contract", func(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
configureDeliverySemanticConfig(cfg)
|
||||
provider := canonicalSemanticProvider(t, "extract")
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
for _, name := range []string{"render", "extract", "analyze", "publish", "notify"} {
|
||||
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
}
|
||||
fingerprint, err := provider.SemanticConfigFingerprint(&stage.Env{Config: cfg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.Stages["extract"].SemanticConfig = &fingerprint
|
||||
store := &manifest.LocalStore{}
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Pipeline.Notarius.PipelineID = "session-v2"
|
||||
runs := 0
|
||||
selected := semanticContractRunStub{name: "extract", provider: provider, runs: &runs}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{selected}, RunOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runs != 1 || loaded.Stages["render"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("runs=%d render=%q", runs, loaded.Stages["render"].Status)
|
||||
}
|
||||
for _, name := range []string{"analyze", "publish", "notify"} {
|
||||
if loaded.Stages[name].Status != manifest.StatusStale {
|
||||
t.Fatalf("%s status = %q, want stale", name, loaded.Stages[name].Status)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*config.Config)
|
||||
wantRuns [2]int
|
||||
}{
|
||||
{name: "publish destination", mutate: func(cfg *config.Config) {
|
||||
cfg.Pipeline.Publish.Outputs[0].Dest = "published/alternate.json"
|
||||
}, wantRuns: [2]int{1, 1}},
|
||||
{name: "publish credentials", mutate: func(cfg *config.Config) {
|
||||
cfg.Pipeline.Storage.S3.AccessKeyIDEnv = "OTHER_ACCESS_KEY"
|
||||
cfg.Pipeline.Storage.S3.SecretKeyEnv = "OTHER_SECRET_KEY"
|
||||
}, wantRuns: [2]int{0, 0}},
|
||||
{name: "notify mode", mutate: func(cfg *config.Config) {
|
||||
cfg.Pipeline.Notification.Mode = "webhook"
|
||||
}, wantRuns: [2]int{0, 1}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
configureDeliverySemanticConfig(cfg)
|
||||
names := []string{"publish", "notify"}
|
||||
providers := []stage.SemanticConfigFingerprinter{
|
||||
canonicalSemanticProvider(t, names[0]), canonicalSemanticProvider(t, names[1]),
|
||||
}
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
for index, name := range names {
|
||||
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
fingerprint, err := providers[index].SemanticConfigFingerprint(&stage.Env{Config: cfg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seed.Stages[name].SemanticConfig = &fingerprint
|
||||
}
|
||||
store := &manifest.LocalStore{}
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
test.mutate(cfg)
|
||||
runs := [2]int{}
|
||||
selected := []stage.Stage{
|
||||
semanticContractRunStub{name: names[0], provider: providers[0], runs: &runs[0]},
|
||||
semanticContractRunStub{name: names[1], provider: providers[1], runs: &runs[1]},
|
||||
}
|
||||
if _, err := executeStages(context.Background(), cfg, selected, RunOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if runs != test.wantRuns {
|
||||
t.Fatalf("runs = %v, want %v", runs, test.wantRuns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func configureDeliverySemanticConfig(cfg *config.Config) {
|
||||
enabled := true
|
||||
disabled := false
|
||||
cfg.Pipeline.Notarius = &config.NotariusConfig{
|
||||
Enabled: true, PipelineID: "session",
|
||||
References: map[string]string{"party": "narratio.input.party"},
|
||||
Outputs: map[string]config.NotariusOutputConfig{
|
||||
"encounters": {LaneID: "encounters", MediaType: "application/json", SchemaID: "encounters", SchemaVersion: "1"},
|
||||
},
|
||||
}
|
||||
cfg.Pipeline.Publish = &config.PublishConfig{
|
||||
Enabled: &disabled, UploadRun: &enabled,
|
||||
Outputs: []config.PublishOutputRule{{
|
||||
Source: "narratio.transcript.final_trimmed", Dest: "transcripts/final.trimmed.json", Required: &enabled,
|
||||
}},
|
||||
}
|
||||
cfg.Pipeline.Storage = config.StorageConfig{
|
||||
Backend: config.StorageBackendLocal,
|
||||
S3: &config.StorageS3Config{
|
||||
Bucket: "campaign", RootPrefix: "narratio", Region: "us-east-1",
|
||||
Endpoint: "https://objects.example", AccessKeyIDEnv: "ACCESS_KEY", SecretKeyEnv: "SECRET_KEY",
|
||||
},
|
||||
}
|
||||
cfg.Pipeline.Notification.Mode = config.DefaultNotificationMode
|
||||
}
|
||||
|
||||
func canonicalSemanticProvider(t *testing.T, name string) stage.SemanticConfigFingerprinter {
|
||||
t.Helper()
|
||||
for _, candidate := range stage.All() {
|
||||
if candidate.Name() != name {
|
||||
continue
|
||||
}
|
||||
provider, ok := candidate.(stage.SemanticConfigFingerprinter)
|
||||
if !ok {
|
||||
t.Fatalf("canonical stage %q does not implement semantic fingerprinting", name)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
t.Fatalf("canonical stage %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func semanticFingerprint(version int, seed string) manifest.SemanticConfigFingerprint {
|
||||
digest := sha256.Sum256([]byte(seed))
|
||||
return manifest.SemanticConfigFingerprint{Version: version, Digest: hex.EncodeToString(digest[:])}
|
||||
}
|
||||
|
||||
func fingerprintPointer(value manifest.SemanticConfigFingerprint) *manifest.SemanticConfigFingerprint {
|
||||
return &value
|
||||
}
|
||||
@@ -13,9 +13,17 @@ import (
|
||||
// analyze invocation will execute.
|
||||
type EffectiveArtifactSet struct {
|
||||
keys []string
|
||||
origins map[string]EffectiveArtifactOrigin
|
||||
resolved bool
|
||||
}
|
||||
|
||||
// EffectiveArtifactOrigin identifies a concrete artifact produced by a
|
||||
// resolved family declaration.
|
||||
type EffectiveArtifactOrigin struct {
|
||||
Family string
|
||||
CharacterID string
|
||||
}
|
||||
|
||||
// ResolveEffectiveArtifactSet applies an explicit artifact selection when one
|
||||
// is supplied; otherwise it selects the configured enabled artifacts.
|
||||
func ResolveEffectiveArtifactSet(
|
||||
@@ -88,6 +96,27 @@ func (s EffectiveArtifactSet) Resolved() bool {
|
||||
return s.resolved
|
||||
}
|
||||
|
||||
// WithOrigins attaches optional resolution provenance without changing the
|
||||
// selected concrete keys or lookup semantics.
|
||||
func (s EffectiveArtifactSet) WithOrigins(origins map[string]EffectiveArtifactOrigin) EffectiveArtifactSet {
|
||||
if len(origins) == 0 {
|
||||
return s
|
||||
}
|
||||
s.origins = make(map[string]EffectiveArtifactOrigin, len(origins))
|
||||
for key, origin := range origins {
|
||||
if s.Includes(key) {
|
||||
s.origins[key] = origin
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Origin reports optional family provenance for a concrete artifact key.
|
||||
func (s EffectiveArtifactSet) Origin(key string) (EffectiveArtifactOrigin, bool) {
|
||||
origin, ok := s.origins[strings.TrimSpace(key)]
|
||||
return origin, ok
|
||||
}
|
||||
|
||||
func newEffectiveArtifactSet(set map[string]struct{}) EffectiveArtifactSet {
|
||||
keys := make([]string, 0, len(set))
|
||||
for key := range set {
|
||||
|
||||
475
internal/config/artifact_families.go
Normal file
475
internal/config/artifact_families.go
Normal file
@@ -0,0 +1,475 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
)
|
||||
|
||||
const (
|
||||
partyCharactersFamilySource = "party.characters"
|
||||
characterIDToken = "{character_id}"
|
||||
memberArtifactSourcePrefix = "narratio.member_artifact."
|
||||
)
|
||||
|
||||
var memberVariableSelectors = map[string]func(PartyCharacter) string{
|
||||
"character_id": func(character PartyCharacter) string { return character.ID },
|
||||
"player.name": func(character PartyCharacter) string { return character.Player.Name },
|
||||
"character.name": func(character PartyCharacter) string { return character.Character.Name },
|
||||
"character.class_summary": func(character PartyCharacter) string { return character.ClassSummary() },
|
||||
"character.alias_summary": func(character PartyCharacter) string { return character.AliasSummary() },
|
||||
}
|
||||
|
||||
func retainArtifactFamilyDeclarations(cfg *PipelineConfig) {
|
||||
if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil {
|
||||
return
|
||||
}
|
||||
cfg.resolution.artifactFamilies = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies)
|
||||
}
|
||||
|
||||
func expandPipelineArtifactFamilies(cfg *PipelineConfig, party ResolvedParty) error {
|
||||
if cfg == nil || cfg.Scriptorium == nil || cfg.resolution == nil || cfg.resolution.artifactFamiliesExpanded {
|
||||
return nil
|
||||
}
|
||||
families := cfg.resolution.artifactFamilies
|
||||
if len(families) == 0 {
|
||||
families = cloneArtifactFamilyDefinitions(cfg.Scriptorium.ArtifactFamilies)
|
||||
}
|
||||
if len(families) == 0 {
|
||||
return nil
|
||||
}
|
||||
if party.Mode != PartyModeCanonical || party.Canonical == nil {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifact_families requires a canonical campaign party")
|
||||
}
|
||||
|
||||
expanded := cloneArtifactDefinitions(cfg.Scriptorium.Artifacts)
|
||||
if err := rejectConcreteMemberArtifactSources(expanded); err != nil {
|
||||
return err
|
||||
}
|
||||
familyKeys := sortedFamilyKeys(families)
|
||||
for _, familyKey := range familyKeys {
|
||||
if !artifactpolicy.IsConfiguredKey(familyKey) {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifact_families keys must match ^[a-z][a-z0-9_]*$")
|
||||
}
|
||||
if _, exists := expanded[familyKey]; exists {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s collides with configured artifact key %q", familyKey, familyKey)
|
||||
}
|
||||
}
|
||||
if err := validateFamilyMemberDependencies(families, familyKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
characters := append([]PartyCharacter(nil), party.Canonical.Characters...)
|
||||
sort.Slice(characters, func(left, right int) bool { return characters[left].ID < characters[right].ID })
|
||||
catalog := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(familyKeys)), Members: map[string]ArtifactFamilyMemberOrigin{}}
|
||||
outputOwners := make(map[string]string, len(expanded))
|
||||
for key, artifact := range expanded {
|
||||
if output := strings.TrimSpace(artifact.OutputPath); output != "" {
|
||||
outputOwners[output] = key
|
||||
}
|
||||
}
|
||||
for _, familyKey := range familyKeys {
|
||||
family := families[familyKey]
|
||||
prefix := "pipeline.scriptorium.artifact_families." + familyKey
|
||||
if strings.TrimSpace(family.ForEach) != partyCharactersFamilySource {
|
||||
return fmt.Errorf("%s.for_each must be %q", prefix, partyCharactersFamilySource)
|
||||
}
|
||||
if err := validateFamilyOutputPattern(prefix+".output_path_pattern", family.OutputPathPattern); err != nil {
|
||||
return err
|
||||
}
|
||||
familyOrigin := ArtifactFamilyOrigin{
|
||||
Members: make([]string, 0, len(characters)),
|
||||
MemberDependencies: append([]string(nil), family.MemberDependencies...),
|
||||
Publish: cloneArtifactFamilyPublish(family.Publish),
|
||||
Source: artifactFamilySource(cfg, familyKey),
|
||||
}
|
||||
for _, character := range characters {
|
||||
key := familyKey + "_" + character.ID
|
||||
if !artifactpolicy.IsConfiguredKey(key) {
|
||||
return fmt.Errorf("%s generates invalid artifact key %q", prefix, key)
|
||||
}
|
||||
if _, exists := expanded[key]; exists {
|
||||
return fmt.Errorf("%s generates artifact key %q that collides with another artifact", prefix, key)
|
||||
}
|
||||
outputPath := strings.ReplaceAll(family.OutputPathPattern, characterIDToken, character.ID)
|
||||
if prior, exists := outputOwners[outputPath]; exists {
|
||||
return fmt.Errorf("%s.output_path_pattern generates output path %q already used by artifact %q", prefix, outputPath, prior)
|
||||
}
|
||||
vars, err := resolveFamilyVars(prefix, family, character)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dependencies, err := expandedFamilyDependencies(prefix, family, character.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inputs, err := expandFamilyInputs(prefix, family, character.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expanded[key] = ScriptoriumArtifactConfig{
|
||||
Enabled: family.Enabled, DependsOn: dependencies, RenderDebug: family.RenderDebug,
|
||||
PromptID: family.PromptID, ProfileID: family.ProfileID, OutputPath: outputPath, Timeout: family.Timeout,
|
||||
Inputs: inputs, Vars: vars,
|
||||
}
|
||||
outputOwners[outputPath] = key
|
||||
familyOrigin.Members = append(familyOrigin.Members, key)
|
||||
catalog.Members[key] = ArtifactFamilyMemberOrigin{Family: familyKey, CharacterID: character.ID, Source: familyOrigin.Source, Dependencies: append([]string(nil), dependencies...), Inputs: inputSourceMap(inputs)}
|
||||
}
|
||||
catalog.Families[familyKey] = familyOrigin
|
||||
}
|
||||
cfg.Scriptorium.Artifacts = expanded
|
||||
if err := expandFamilyPublishRules(cfg, catalog); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Scriptorium.ArtifactFamilies = nil
|
||||
cfg.familyCatalog = catalog
|
||||
cfg.resolution.artifactFamiliesExpanded = true
|
||||
if err := rejectConcreteMemberArtifactSources(expanded); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateScriptorium(cfg.Scriptorium, cfg.Notarius); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePublish(cfg.Publish, cfg.Scriptorium, cfg.Notarius); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recomputePipelineEffectiveDigest(cfg); err != nil {
|
||||
return fmt.Errorf("refresh expanded pipeline digest: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func expandFamilyPublishRules(cfg *PipelineConfig, catalog ArtifactFamilyCatalog) error {
|
||||
if cfg == nil || cfg.Scriptorium == nil {
|
||||
return nil
|
||||
}
|
||||
if cfg.Publish == nil {
|
||||
for familyKey, family := range catalog.Families {
|
||||
if family.Publish != nil && family.Publish.Enabled {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish requires pipeline.publish", familyKey)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
generated := make([]PublishOutputRule, 0)
|
||||
explicitSources := make(map[string]struct{}, len(cfg.Publish.Outputs))
|
||||
for _, rule := range cfg.Publish.Outputs {
|
||||
explicitSources[strings.TrimSpace(rule.Source)] = struct{}{}
|
||||
}
|
||||
for familyKey, family := range catalog.Families {
|
||||
if family.Publish == nil || !family.Publish.Enabled {
|
||||
continue
|
||||
}
|
||||
if cfg.resolution == nil || !cfg.resolution.publishDeclared {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish requires pipeline.publish", familyKey)
|
||||
}
|
||||
pattern := strings.TrimSpace(family.Publish.DestPattern)
|
||||
if pattern != "" {
|
||||
if err := validateFamilyOutputPattern("pipeline.scriptorium.artifact_families."+familyKey+".publish.dest_pattern", pattern); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, key := range family.Members {
|
||||
if _, conflict := explicitSources[artifactpolicy.ConfiguredSourceID(key)]; conflict {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifact_families.%s.publish conflicts with explicit publish source %q", familyKey, artifactpolicy.ConfiguredSourceID(key))
|
||||
}
|
||||
origin := catalog.Members[key]
|
||||
dest := cfg.Scriptorium.Artifacts[key].OutputPath
|
||||
if pattern != "" {
|
||||
dest = strings.ReplaceAll(pattern, characterIDToken, origin.CharacterID)
|
||||
}
|
||||
required := family.Publish.Required
|
||||
generated = append(generated, PublishOutputRule{Source: artifactpolicy.ConfiguredSourceID(key), Dest: dest, Required: &required})
|
||||
}
|
||||
}
|
||||
if len(generated) == 0 {
|
||||
return nil
|
||||
}
|
||||
cfg.Publish.Outputs = append(cfg.Publish.Outputs, generated...)
|
||||
sort.Slice(cfg.Publish.Outputs, func(i, j int) bool {
|
||||
if cfg.Publish.Outputs[i].Dest == cfg.Publish.Outputs[j].Dest {
|
||||
return cfg.Publish.Outputs[i].Source < cfg.Publish.Outputs[j].Source
|
||||
}
|
||||
return cfg.Publish.Outputs[i].Dest < cfg.Publish.Outputs[j].Dest
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFamilyMemberDependencies(families map[string]ScriptoriumArtifactFamilyConfig, familyKeys []string) error {
|
||||
for _, familyKey := range familyKeys {
|
||||
family := families[familyKey]
|
||||
seen := make(map[string]struct{}, len(family.MemberDependencies))
|
||||
for index, raw := range family.MemberDependencies {
|
||||
dependency := strings.TrimSpace(raw)
|
||||
prefix := fmt.Sprintf("pipeline.scriptorium.artifact_families.%s.member_dependencies[%d]", familyKey, index)
|
||||
if !artifactpolicy.IsConfiguredKey(dependency) {
|
||||
return fmt.Errorf("%s must be a valid family key", prefix)
|
||||
}
|
||||
if dependency == familyKey {
|
||||
return fmt.Errorf("%s must not reference its own family", prefix)
|
||||
}
|
||||
if _, duplicate := seen[dependency]; duplicate {
|
||||
return fmt.Errorf("%s duplicates family %q", prefix, dependency)
|
||||
}
|
||||
seen[dependency] = struct{}{}
|
||||
dependencyFamily, exists := families[dependency]
|
||||
if !exists {
|
||||
return fmt.Errorf("%s references unknown family %q", prefix, dependency)
|
||||
}
|
||||
if strings.TrimSpace(dependencyFamily.ForEach) != partyCharactersFamilySource {
|
||||
return fmt.Errorf("%s family %q must use %q", prefix, dependency, partyCharactersFamilySource)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func expandedFamilyDependencies(prefix string, family ScriptoriumArtifactFamilyConfig, characterID string) ([]string, error) {
|
||||
dependencies := append([]string(nil), family.DependsOn...)
|
||||
memberDependencies := append([]string(nil), family.MemberDependencies...)
|
||||
sort.Strings(memberDependencies)
|
||||
for _, familyKey := range memberDependencies {
|
||||
dependencies = append(dependencies, familyKey+"_"+characterID)
|
||||
}
|
||||
return normalizedFamilyDependencies(prefix, dependencies), nil
|
||||
}
|
||||
|
||||
func normalizedFamilyDependencies(_ string, dependencies []string) []string {
|
||||
seen := make(map[string]struct{}, len(dependencies))
|
||||
normalized := make([]string, 0, len(dependencies))
|
||||
for _, dependency := range dependencies {
|
||||
dependency = strings.TrimSpace(dependency)
|
||||
if _, exists := seen[dependency]; exists {
|
||||
continue
|
||||
}
|
||||
seen[dependency] = struct{}{}
|
||||
normalized = append(normalized, dependency)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func expandFamilyInputs(prefix string, family ScriptoriumArtifactFamilyConfig, characterID string) (map[string]ScriptoriumInputConfig, error) {
|
||||
inputs := cloneArtifactInputs(family.Inputs)
|
||||
for name, input := range inputs {
|
||||
familyKey, matched, err := parseMemberArtifactSource(input.Source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s.inputs.%s.source: %w", prefix, name, err)
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
if !familyDependencyContains(family.MemberDependencies, familyKey) {
|
||||
return nil, fmt.Errorf("%s.inputs.%s.source %q requires member_dependencies entry %q", prefix, name, input.Source, familyKey)
|
||||
}
|
||||
input.Source = artifactpolicy.ConfiguredSourceID(familyKey + "_" + characterID)
|
||||
inputs[name] = input
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
func parseMemberArtifactSource(source string) (string, bool, error) {
|
||||
trimmed := strings.TrimSpace(source)
|
||||
if !strings.HasPrefix(trimmed, "narratio.member_artifact") {
|
||||
return "", false, nil
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, memberArtifactSourcePrefix) {
|
||||
return "", true, fmt.Errorf("malformed member artifact source %q", source)
|
||||
}
|
||||
family := strings.TrimPrefix(trimmed, memberArtifactSourcePrefix)
|
||||
if !artifactpolicy.IsConfiguredKey(family) {
|
||||
return "", true, fmt.Errorf("malformed member artifact source %q", source)
|
||||
}
|
||||
return family, true, nil
|
||||
}
|
||||
|
||||
func rejectConcreteMemberArtifactSources(artifacts map[string]ScriptoriumArtifactConfig) error {
|
||||
for artifactKey, artifact := range artifacts {
|
||||
for inputName, input := range artifact.Inputs {
|
||||
if _, matched, err := parseMemberArtifactSource(input.Source); matched {
|
||||
if err != nil {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source: %w", artifactKey, inputName, err)
|
||||
}
|
||||
return fmt.Errorf("pipeline.scriptorium.artifacts.%s.inputs.%s.source uses member artifact syntax outside an artifact family", artifactKey, inputName)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func familyDependencyContains(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func inputSourceMap(inputs map[string]ScriptoriumInputConfig) map[string]string {
|
||||
if len(inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(inputs))
|
||||
for name, input := range inputs {
|
||||
out[name] = input.Source
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateFamilyOutputPattern(field, pattern string) error {
|
||||
if strings.Count(pattern, characterIDToken) != 1 {
|
||||
return fmt.Errorf("%s must contain exactly one %s token", field, characterIDToken)
|
||||
}
|
||||
remainder := strings.ReplaceAll(pattern, characterIDToken, "")
|
||||
if strings.ContainsAny(remainder, "{}") {
|
||||
return fmt.Errorf("%s contains unsupported brace syntax", field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveFamilyVars(prefix string, family ScriptoriumArtifactFamilyConfig, character PartyCharacter) (map[string]any, error) {
|
||||
vars := make(map[string]any, len(family.Vars)+len(family.MemberVars))
|
||||
for name, value := range family.Vars {
|
||||
vars[name] = value
|
||||
}
|
||||
for name, selector := range family.MemberVars {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, fmt.Errorf("%s.member_vars keys must be non-empty", prefix)
|
||||
}
|
||||
if _, exists := vars[name]; exists {
|
||||
return nil, fmt.Errorf("%s.member_vars.%s conflicts with static vars", prefix, name)
|
||||
}
|
||||
resolve, ok := memberVariableSelectors[strings.TrimSpace(selector)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s.member_vars.%s selector %q is unsupported", prefix, name, selector)
|
||||
}
|
||||
vars[name] = resolve(character)
|
||||
}
|
||||
return vars, nil
|
||||
}
|
||||
|
||||
func sortedFamilyKeys(families map[string]ScriptoriumArtifactFamilyConfig) []string {
|
||||
keys := make([]string, 0, len(families))
|
||||
for key := range families {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func artifactFamilySource(cfg *PipelineConfig, family string) string {
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
return ""
|
||||
}
|
||||
prefix := "scriptorium.artifact_families." + family + "."
|
||||
var fallback string
|
||||
for _, ownership := range cfg.resolution.ownership {
|
||||
if !strings.HasPrefix(ownership.path, prefix) || len(ownership.sources) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, source := range ownership.sources {
|
||||
if source != pipelineDefaultOwnershipSource {
|
||||
return source
|
||||
}
|
||||
if fallback == "" {
|
||||
fallback = source
|
||||
}
|
||||
}
|
||||
}
|
||||
if fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
return pipelineDefaultOwnershipSource
|
||||
}
|
||||
|
||||
func cloneArtifactDefinitions(in map[string]ScriptoriumArtifactConfig) map[string]ScriptoriumArtifactConfig {
|
||||
if len(in) == 0 {
|
||||
return map[string]ScriptoriumArtifactConfig{}
|
||||
}
|
||||
out := make(map[string]ScriptoriumArtifactConfig, len(in))
|
||||
for key, artifact := range in {
|
||||
artifact.DependsOn = append([]string(nil), artifact.DependsOn...)
|
||||
artifact.Inputs = cloneArtifactInputs(artifact.Inputs)
|
||||
artifact.Vars = cloneArtifactVars(artifact.Vars)
|
||||
out[key] = artifact
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneArtifactInputs(in map[string]ScriptoriumInputConfig) map[string]ScriptoriumInputConfig {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]ScriptoriumInputConfig, len(in))
|
||||
for key, input := range in {
|
||||
out[key] = input
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneArtifactVars(in map[string]any) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneArtifactFamilyDefinitions(in map[string]ScriptoriumArtifactFamilyConfig) map[string]ScriptoriumArtifactFamilyConfig {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]ScriptoriumArtifactFamilyConfig, len(in))
|
||||
for key, family := range in {
|
||||
family.DependsOn = append([]string(nil), family.DependsOn...)
|
||||
family.MemberDependencies = append([]string(nil), family.MemberDependencies...)
|
||||
family.Inputs = cloneArtifactInputs(family.Inputs)
|
||||
family.Vars = cloneArtifactVars(family.Vars)
|
||||
if len(family.MemberVars) > 0 {
|
||||
memberVars := make(map[string]string, len(family.MemberVars))
|
||||
for name, selector := range family.MemberVars {
|
||||
memberVars[name] = selector
|
||||
}
|
||||
family.MemberVars = memberVars
|
||||
}
|
||||
family.Publish = cloneArtifactFamilyPublish(family.Publish)
|
||||
out[key] = family
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneArtifactFamilyPublish(in *ScriptoriumArtifactFamilyPublishConfig) *ScriptoriumArtifactFamilyPublishConfig {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *in
|
||||
return ©
|
||||
}
|
||||
|
||||
func cloneArtifactFamilyCatalog(in ArtifactFamilyCatalog) ArtifactFamilyCatalog {
|
||||
out := ArtifactFamilyCatalog{Families: make(map[string]ArtifactFamilyOrigin, len(in.Families)), Members: make(map[string]ArtifactFamilyMemberOrigin, len(in.Members))}
|
||||
for key, family := range in.Families {
|
||||
family.Members = append([]string(nil), family.Members...)
|
||||
family.MemberDependencies = append([]string(nil), family.MemberDependencies...)
|
||||
family.Publish = cloneArtifactFamilyPublish(family.Publish)
|
||||
out.Families[key] = family
|
||||
}
|
||||
for key, member := range in.Members {
|
||||
member.Dependencies = append([]string(nil), member.Dependencies...)
|
||||
if len(member.Inputs) > 0 {
|
||||
inputs := make(map[string]string, len(member.Inputs))
|
||||
for name, source := range member.Inputs {
|
||||
inputs[name] = source
|
||||
}
|
||||
member.Inputs = inputs
|
||||
}
|
||||
out.Members[key] = member
|
||||
}
|
||||
return out
|
||||
}
|
||||
368
internal/config/artifact_families_test.go
Normal file
368
internal/config/artifact_families_test.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArtifactFamiliesExpandCanonicalCharactersDeterministically(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePartyResolutionFile(t, dir+"/party.yml", `schema_version: narratio.party.v1
|
||||
characters:
|
||||
zeta:
|
||||
player: {name: Zara}
|
||||
character:
|
||||
name: Zeta
|
||||
alias: [Z]
|
||||
classes: [{name: wizard, level: 8}, {name: fighter}]
|
||||
alpha:
|
||||
player: {name: Ada}
|
||||
character:
|
||||
name: Alpha
|
||||
alias: [A, The First]
|
||||
classes: [{name: ranger}]
|
||||
`)
|
||||
pipelinePath, campaignPath, sessionPath := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(`
|
||||
character_items:
|
||||
enabled: false
|
||||
for_each: party.characters
|
||||
prompt_id: dnd.character_items
|
||||
profile_id: production
|
||||
output_path_pattern: artifacts/characters/{character_id}/items.md
|
||||
inputs:
|
||||
transcript: {source: narratio.transcript.final_trimmed, required: true}
|
||||
member_vars:
|
||||
id: character_id
|
||||
player: player.name
|
||||
name: character.name
|
||||
classes: character.class_summary
|
||||
aliases: character.alias_summary
|
||||
vars:
|
||||
static_flag: true
|
||||
character_meta:
|
||||
enabled: true
|
||||
for_each: party.characters
|
||||
prompt_id: dnd.character_meta
|
||||
profile_id: production
|
||||
output_path_pattern: artifacts/characters/{character_id}/meta.md
|
||||
inputs:
|
||||
transcript: {source: narratio.transcript.final_trimmed, required: true}
|
||||
`))
|
||||
|
||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||
}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
if len(cfg.Pipeline.Scriptorium.ArtifactFamilies) != 0 {
|
||||
t.Fatalf("adapter-facing artifact families = %#v, want none", cfg.Pipeline.Scriptorium.ArtifactFamilies)
|
||||
}
|
||||
keys := sortedArtifactKeys(cfg.Pipeline.Scriptorium.Artifacts)
|
||||
wantKeys := []string{"character_items_alpha", "character_items_zeta", "character_meta_alpha", "character_meta_zeta"}
|
||||
if !reflect.DeepEqual(keys, wantKeys) {
|
||||
t.Fatalf("artifact keys = %#v, want %#v", keys, wantKeys)
|
||||
}
|
||||
alpha := cfg.Pipeline.Scriptorium.Artifacts["character_items_alpha"]
|
||||
if alpha.Enabled || alpha.OutputPath != "artifacts/characters/alpha/items.md" {
|
||||
t.Fatalf("alpha artifact = %#v", alpha)
|
||||
}
|
||||
if !reflect.DeepEqual(alpha.Vars, map[string]any{
|
||||
"static_flag": true, "id": "alpha", "player": "Ada", "name": "Alpha", "classes": "ranger", "aliases": "A, The First",
|
||||
}) {
|
||||
t.Fatalf("alpha vars = %#v", alpha.Vars)
|
||||
}
|
||||
zeta := cfg.Pipeline.Scriptorium.Artifacts["character_items_zeta"]
|
||||
if got := zeta.Vars["classes"]; got != "wizard 8 / fighter" {
|
||||
t.Fatalf("zeta class summary = %#v", got)
|
||||
}
|
||||
catalog := ArtifactFamilies(cfg.Pipeline)
|
||||
if got, want := catalog.Families["character_meta"].Source, absolutePath(t, pipelinePath); got != want {
|
||||
t.Fatalf("family source = %q, want %q", got, want)
|
||||
}
|
||||
if got := catalog.Families["character_meta"].Members; !reflect.DeepEqual(got, []string{"character_meta_alpha", "character_meta_zeta"}) {
|
||||
t.Fatalf("meta members = %#v", got)
|
||||
}
|
||||
if got := catalog.Members["character_items_zeta"]; got.Family != "character_items" || got.CharacterID != "zeta" || got.Source == "" {
|
||||
t.Fatalf("zeta origin = %#v", got)
|
||||
}
|
||||
|
||||
firstDigest := EffectivePipelineDigest(cfg.Pipeline)
|
||||
writePartyResolutionFile(t, dir+"/party.yml", `schema_version: narratio.party.v1
|
||||
characters:
|
||||
alpha:
|
||||
player: {name: Ada}
|
||||
character:
|
||||
name: Alpha
|
||||
alias: [A, The First]
|
||||
classes: [{name: ranger}]
|
||||
zeta:
|
||||
player: {name: Zara}
|
||||
character:
|
||||
name: Zeta
|
||||
alias: [Z]
|
||||
classes: [{name: wizard, level: 8}, {name: fighter}]
|
||||
`)
|
||||
again, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("reordered LoadWithSessionOptions() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.Pipeline.Scriptorium.Artifacts, again.Pipeline.Scriptorium.Artifacts) || !reflect.DeepEqual(ArtifactFamilies(cfg.Pipeline), ArtifactFamilies(again.Pipeline)) || firstDigest != EffectivePipelineDigest(again.Pipeline) {
|
||||
t.Fatalf("reordered source maps changed expansion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactFamiliesRejectInvalidExpansionDeclarations(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
party string
|
||||
families string
|
||||
wantError string
|
||||
}{
|
||||
{name: "legacy party", party: "legacy: party\n", families: validFamilyYAML, wantError: "requires a canonical campaign party"},
|
||||
{name: "invalid iteration", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "party.characters", "party.players", 1), wantError: ".for_each must be"},
|
||||
{name: "invalid token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "{character_id}", "{character_name}", 1), wantError: "must contain exactly one"},
|
||||
{name: "repeated token", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "meta.md", "{character_id}.md", 1), wantError: "must contain exactly one"},
|
||||
{name: "member selector", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "character.name", "character.unknown", 1), wantError: "selector"},
|
||||
{name: "static variable collision", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "member_vars:\n character_name", "vars:\n character_name: static\n member_vars:\n character_name", 1), wantError: "conflicts with static vars"},
|
||||
{name: "unsafe output", party: canonicalPartyFixture, families: strings.Replace(validFamilyYAML, "artifacts/characters/{character_id}/meta.md", "../{character_id}.md", 1), wantError: "must not contain path traversal"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePartyResolutionFile(t, dir+"/party.yml", test.party)
|
||||
pipelinePath, campaignPath, sessionPath := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(test.families))
|
||||
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v, want %q", err, test.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactFamilyStrictDecodeRejectsUnknownFields(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pipelinePath, _, _ := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(validFamilyYAML+" unsupported: true\n"))
|
||||
_, err := LoadPipeline(pipelinePath)
|
||||
if err == nil || !strings.Contains(err.Error(), "field unsupported not found") {
|
||||
t.Fatalf("LoadPipeline() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactFamiliesRejectGeneratedKeyAndOutputCollisions(t *testing.T) {
|
||||
document, err := ParseParty([]byte(canonicalPartyFixture))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := ScriptoriumArtifactFamilyConfig{
|
||||
ForEach: partyCharactersFamilySource, PromptID: "dnd.character_meta",
|
||||
OutputPathPattern: "artifacts/characters/{character_id}/meta.md",
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
artifacts map[string]ScriptoriumArtifactConfig
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "generated key", artifacts: map[string]ScriptoriumArtifactConfig{
|
||||
"character_meta_arannis": {OutputPath: "artifacts/other.md"},
|
||||
}, wantError: "generates artifact key",
|
||||
},
|
||||
{
|
||||
name: "generated output", artifacts: map[string]ScriptoriumArtifactConfig{
|
||||
"existing": {OutputPath: "artifacts/characters/arannis/meta.md"},
|
||||
}, wantError: "generates output path",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
pipeline := &PipelineConfig{
|
||||
Scriptorium: &ScriptoriumConfig{Artifacts: test.artifacts},
|
||||
resolution: &pipelineResolutionMetadata{artifactFamilies: map[string]ScriptoriumArtifactFamilyConfig{"character_meta": base}},
|
||||
}
|
||||
err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical})
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("expandPipelineArtifactFamilies() error = %v, want %q", err, test.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactFamiliesExpandSameMemberDependenciesAndInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), `schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character: {name: Arannis, classes: [{name: wizard}]}
|
||||
bryn:
|
||||
player: {name: Bri}
|
||||
character: {name: Bryn, classes: [{name: fighter}]}
|
||||
`)
|
||||
families := `
|
||||
character_meta:
|
||||
enabled: true
|
||||
for_each: party.characters
|
||||
prompt_id: dnd.character_meta
|
||||
output_path_pattern: artifacts/characters/{character_id}/meta.md
|
||||
character_items:
|
||||
enabled: true
|
||||
for_each: party.characters
|
||||
prompt_id: dnd.character_items
|
||||
output_path_pattern: artifacts/characters/{character_id}/items.md
|
||||
depends_on: [session_recap]
|
||||
member_dependencies: [character_meta]
|
||||
inputs:
|
||||
transcript: {source: narratio.transcript.final_trimmed, required: true}
|
||||
prior_meta: {source: narratio.member_artifact.character_meta, required: true}
|
||||
`
|
||||
pipelinePath, campaignPath, sessionPath := writeArtifactFamilyConfig(t, dir, strings.Replace(artifactFamilyPipelineYAML(families), " artifact_families:", " artifacts:\n session_recap:\n enabled: false\n output_path: artifacts/session_recap.md\n artifact_families:", 1))
|
||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := cfg.Pipeline.Scriptorium.Artifacts["character_items_arannis"]
|
||||
if !reflect.DeepEqual(items.DependsOn, []string{"session_recap", "character_meta_arannis"}) {
|
||||
t.Fatalf("member dependencies = %#v", items.DependsOn)
|
||||
}
|
||||
if got := items.Inputs["prior_meta"].Source; got != "narratio.artifact.character_meta_arannis" {
|
||||
t.Fatalf("rewritten member source = %q", got)
|
||||
}
|
||||
catalog := ArtifactFamilies(cfg.Pipeline)
|
||||
if got := catalog.Members["character_items_arannis"]; !reflect.DeepEqual(got.Dependencies, items.DependsOn) || got.Inputs["prior_meta"] != "narratio.artifact.character_meta_arannis" {
|
||||
t.Fatalf("member provenance = %#v", got)
|
||||
}
|
||||
for key, artifact := range cfg.Pipeline.Scriptorium.Artifacts {
|
||||
for name, input := range artifact.Inputs {
|
||||
if strings.Contains(input.Source, "narratio.member_artifact.") {
|
||||
t.Fatalf("unresolved source at %s.%s = %q", key, name, input.Source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactFamiliesRejectInvalidMemberDependenciesAndSources(t *testing.T) {
|
||||
for _, test := range []struct{ name, families, want string }{
|
||||
{name: "missing family", families: strings.Replace(validFamilyYAML, "member_vars:", "member_dependencies: [missing]\n member_vars:", 1), want: "unknown family"},
|
||||
{name: "self dependency", families: strings.Replace(validFamilyYAML, "member_vars:", "member_dependencies: [character_meta]\n member_vars:", 1), want: "must not reference its own family"},
|
||||
{name: "duplicate dependency", families: strings.Replace(validFamilyYAML, "member_vars:", "member_dependencies: [other, other]\n member_vars:", 1) + `
|
||||
other:
|
||||
for_each: party.characters
|
||||
output_path_pattern: artifacts/characters/{character_id}/other.md
|
||||
`, want: "duplicates family"},
|
||||
{name: "undeclared source dependency", families: strings.Replace(validFamilyYAML, "member_vars:", "inputs:\n prior: {source: narratio.member_artifact.other, required: true}\n member_vars:", 1) + `
|
||||
other:
|
||||
for_each: party.characters
|
||||
output_path_pattern: artifacts/characters/{character_id}/other.md
|
||||
`, want: "requires member_dependencies"},
|
||||
{name: "transitive cycle", families: strings.Replace(validFamilyYAML, "member_vars:", "member_dependencies: [other]\n member_vars:", 1) + `
|
||||
other:
|
||||
for_each: party.characters
|
||||
output_path_pattern: artifacts/characters/{character_id}/other.md
|
||||
member_dependencies: [character_meta]
|
||||
`, want: "dependencies must not contain cycles"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), canonicalPartyFixture)
|
||||
pipelinePath, campaignPath, sessionPath := writeArtifactFamilyConfig(t, dir, artifactFamilyPipelineYAML(test.families))
|
||||
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pipeline := &PipelineConfig{
|
||||
Scriptorium: &ScriptoriumConfig{Artifacts: map[string]ScriptoriumArtifactConfig{
|
||||
"explicit": {Inputs: map[string]ScriptoriumInputConfig{
|
||||
"bad": {Source: "narratio.member_artifact.character_meta"},
|
||||
}},
|
||||
}},
|
||||
resolution: &pipelineResolutionMetadata{artifactFamilies: map[string]ScriptoriumArtifactFamilyConfig{
|
||||
"character_meta": {ForEach: partyCharactersFamilySource, OutputPathPattern: "artifacts/{character_id}.md"},
|
||||
}},
|
||||
}
|
||||
document, err := ParseParty([]byte(canonicalPartyFixture))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical}); err == nil || !strings.Contains(err.Error(), "outside an artifact family") {
|
||||
t.Fatalf("concrete member source error = %v", err)
|
||||
}
|
||||
pipeline.Scriptorium.Artifacts["explicit"] = ScriptoriumArtifactConfig{Inputs: map[string]ScriptoriumInputConfig{"bad": {Source: "narratio.member_artifact."}}}
|
||||
pipeline.resolution.artifactFamiliesExpanded = false
|
||||
if err := expandPipelineArtifactFamilies(pipeline, ResolvedParty{Mode: PartyModeCanonical, Canonical: document.Canonical}); err == nil || !strings.Contains(err.Error(), "malformed member artifact source") {
|
||||
t.Fatalf("malformed concrete member source error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFamilyPublishRulesExpandToConcreteArtifacts(t *testing.T) {
|
||||
required := true
|
||||
cfg := &PipelineConfig{
|
||||
Scriptorium: &ScriptoriumConfig{Artifacts: map[string]ScriptoriumArtifactConfig{
|
||||
"character_meta_alpha": {OutputPath: "artifacts/characters/alpha/meta.md"},
|
||||
"character_meta_zeta": {OutputPath: "artifacts/characters/zeta/meta.md"},
|
||||
}},
|
||||
Publish: &PublishConfig{},
|
||||
resolution: &pipelineResolutionMetadata{publishDeclared: true},
|
||||
}
|
||||
catalog := ArtifactFamilyCatalog{Families: map[string]ArtifactFamilyOrigin{
|
||||
"character_meta": {Members: []string{"character_meta_alpha", "character_meta_zeta"}, Publish: &ScriptoriumArtifactFamilyPublishConfig{Enabled: true, Required: required, DestPattern: "published/{character_id}.md"}},
|
||||
}, Members: map[string]ArtifactFamilyMemberOrigin{
|
||||
"character_meta_alpha": {CharacterID: "alpha"}, "character_meta_zeta": {CharacterID: "zeta"},
|
||||
}}
|
||||
if err := expandFamilyPublishRules(cfg, catalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cfg.Publish.Outputs; len(got) != 2 || got[0].Source != "narratio.artifact.character_meta_alpha" || got[0].Dest != "published/alpha.md" || got[0].Required == nil || !*got[0].Required {
|
||||
t.Fatalf("generated publish rules = %#v", got)
|
||||
}
|
||||
cfg.Publish.Outputs = nil
|
||||
catalog.Families["character_meta"] = ArtifactFamilyOrigin{Members: []string{"character_meta_alpha"}, Publish: &ScriptoriumArtifactFamilyPublishConfig{Enabled: true}}
|
||||
cfg.resolution.publishDeclared = false
|
||||
if err := expandFamilyPublishRules(cfg, catalog); err == nil || !strings.Contains(err.Error(), "requires pipeline.publish") {
|
||||
t.Fatalf("missing publish error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const validFamilyYAML = `
|
||||
character_meta:
|
||||
enabled: true
|
||||
for_each: party.characters
|
||||
prompt_id: dnd.character_meta
|
||||
profile_id: production
|
||||
output_path_pattern: artifacts/characters/{character_id}/meta.md
|
||||
member_vars:
|
||||
character_name: character.name
|
||||
`
|
||||
|
||||
func artifactFamilyPipelineYAML(families string) string {
|
||||
lines := strings.Split(strings.TrimPrefix(families, "\n"), "\n")
|
||||
for index, line := range lines {
|
||||
if line != "" {
|
||||
lines[index] = " " + line
|
||||
}
|
||||
}
|
||||
return "workspace:\n root: /tmp/narratio-work\nwhisperx:\n transcribe_url: https://example.test/transcribe\nnotification:\n mode: noop\nscriptorium:\n binary: scriptorium\n artifact_families:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func writeArtifactFamilyConfig(t *testing.T, dir, pipeline string) (string, string, string) {
|
||||
t.Helper()
|
||||
pipelinePath := writePartyResolutionFile(t, filepath.Join(dir, "pipeline.yml"), pipeline)
|
||||
campaignPath := writePartyResolutionFile(t, filepath.Join(dir, "campaign.yml"), "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n party_file: party.yml\n")
|
||||
sessionPath := writePartyResolutionFile(t, filepath.Join(dir, "session.yml"), "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n")
|
||||
return pipelinePath, campaignPath, sessionPath
|
||||
}
|
||||
|
||||
func sortedArtifactKeys(artifacts map[string]ScriptoriumArtifactConfig) []string {
|
||||
keys := make([]string, 0, len(artifacts))
|
||||
for key := range artifacts {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -206,21 +206,17 @@ func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) {
|
||||
func TestLegacyCampaignRequiresPlayersInput(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",
|
||||
"campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n party_file: ./party.yml\n",
|
||||
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||
)
|
||||
|
||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||
}
|
||||
err = Validate(cfg)
|
||||
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
t.Fatal("expected load error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "campaign.inputs.players_file is required") {
|
||||
if !strings.Contains(err.Error(), "players_file is required with a legacy party") {
|
||||
t.Fatalf("error = %q, want players_file required", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -274,6 +270,11 @@ func writeCampaignConfigTestFiles(t *testing.T, campaignYAML, sessionYAML string
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
for _, name := range []string{"party.yml", "campaign-party.yml", "session-party.yml"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("legacy: party\n"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return pipelinePath, campaignPath, sessionPath
|
||||
}
|
||||
|
||||
796
internal/config/composition.go
Normal file
796
internal/config/composition.go
Normal file
@@ -0,0 +1,796 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// compositionDocument is the presence-aware representation used while
|
||||
// assembling pipeline configuration sources. It deliberately models YAML
|
||||
// mechanics rather than duplicating PipelineConfig's field schema.
|
||||
type compositionDocument struct {
|
||||
root *compositionNode
|
||||
sources []string
|
||||
}
|
||||
|
||||
type compositionNode struct {
|
||||
kind yaml.Kind
|
||||
tag string
|
||||
value string
|
||||
path string
|
||||
sources []string
|
||||
line int
|
||||
column int
|
||||
fields []compositionField
|
||||
items []*compositionNode
|
||||
}
|
||||
|
||||
type compositionField struct {
|
||||
key string
|
||||
value *compositionNode
|
||||
order int
|
||||
line int
|
||||
column int
|
||||
}
|
||||
|
||||
// compositionValueRecord is a deterministic semantic leaf projection. Lists
|
||||
// are atomic configuration values, while mappings are traversed recursively.
|
||||
type compositionValueRecord struct {
|
||||
Path string
|
||||
Kind yaml.Kind
|
||||
Value string
|
||||
Sources []string
|
||||
}
|
||||
|
||||
// parseCompositionDocument parses exactly one YAML mapping while retaining
|
||||
// source ownership, explicit zero values, and declaration order.
|
||||
func parseCompositionDocument(source string, reader io.Reader) (*compositionDocument, error) {
|
||||
if strings.TrimSpace(source) == "" {
|
||||
return nil, fmt.Errorf("configuration source identity is required")
|
||||
}
|
||||
if reader == nil {
|
||||
return nil, fmt.Errorf("configuration source %q: reader is nil", source)
|
||||
}
|
||||
|
||||
decoder := yaml.NewDecoder(reader)
|
||||
var document yaml.Node
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
if err == io.EOF {
|
||||
return nil, fmt.Errorf("configuration source %q: document is empty", source)
|
||||
}
|
||||
return nil, fmt.Errorf("configuration source %q: decode YAML: %w", source, err)
|
||||
}
|
||||
var trailing yaml.Node
|
||||
if err := decoder.Decode(&trailing); err == nil {
|
||||
return nil, fmt.Errorf("configuration source %q: must contain exactly one YAML document", source)
|
||||
} else if err != io.EOF {
|
||||
return nil, fmt.Errorf("configuration source %q: decode trailing YAML: %w", source, err)
|
||||
}
|
||||
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 {
|
||||
return nil, fmt.Errorf("configuration source %q: must contain exactly one YAML document", source)
|
||||
}
|
||||
if document.Content[0].Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf(
|
||||
"configuration source %q: top-level document must be a mapping, got %s",
|
||||
source,
|
||||
yamlKindName(document.Content[0].Kind),
|
||||
)
|
||||
}
|
||||
|
||||
root, err := buildCompositionNode(document.Content[0], source, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &compositionDocument{root: root, sources: []string{source}}, nil
|
||||
}
|
||||
|
||||
func parseCompositionBytes(source string, data []byte) (*compositionDocument, error) {
|
||||
return parseCompositionDocument(source, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
func buildCompositionNode(node *yaml.Node, source, path string) (*compositionNode, error) {
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("configuration source %q at %s: YAML node is nil", source, displayCompositionPath(path))
|
||||
}
|
||||
if node.Kind == yaml.AliasNode {
|
||||
return nil, compositionNodeError(source, path, node, "YAML aliases are not supported because source ownership would be ambiguous")
|
||||
}
|
||||
result := &compositionNode{
|
||||
kind: node.Kind, tag: node.Tag, value: node.Value, path: path,
|
||||
sources: []string{source}, line: node.Line, column: node.Column,
|
||||
}
|
||||
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
if len(node.Content)%2 != 0 {
|
||||
return nil, compositionNodeError(source, path, node, "mapping has an incomplete key/value pair")
|
||||
}
|
||||
seen := make(map[string]*yaml.Node, len(node.Content)/2)
|
||||
for index := 0; index < len(node.Content); index += 2 {
|
||||
keyNode := node.Content[index]
|
||||
valueNode := node.Content[index+1]
|
||||
if keyNode.Kind == yaml.AliasNode {
|
||||
return nil, compositionNodeError(source, path, keyNode, "YAML aliases are not supported because source ownership would be ambiguous")
|
||||
}
|
||||
if valueNode.Kind == yaml.AliasNode {
|
||||
return nil, compositionNodeError(source, appendCompositionPath(path, keyNode.Value), valueNode, "YAML aliases are not supported because source ownership would be ambiguous")
|
||||
}
|
||||
if keyNode.Kind != yaml.ScalarNode || keyNode.Tag != "!!str" {
|
||||
return nil, compositionNodeError(source, path, keyNode, "mapping keys must be strings")
|
||||
}
|
||||
key := keyNode.Value
|
||||
fieldPath := appendCompositionPath(path, key)
|
||||
if prior, duplicate := seen[key]; duplicate {
|
||||
return nil, fmt.Errorf(
|
||||
"configuration source %q at %s: duplicate YAML key %q (first declared at line %d, column %d; repeated at line %d, column %d)",
|
||||
source, displayCompositionPath(fieldPath), key,
|
||||
prior.Line, prior.Column, keyNode.Line, keyNode.Column,
|
||||
)
|
||||
}
|
||||
seen[key] = keyNode
|
||||
child, err := buildCompositionNode(valueNode, source, fieldPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.fields = append(result.fields, compositionField{
|
||||
key: key, value: child, order: len(result.fields),
|
||||
line: keyNode.Line, column: keyNode.Column,
|
||||
})
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
for index, childNode := range node.Content {
|
||||
childPath := fmt.Sprintf("%s[%d]", path, index)
|
||||
child, err := buildCompositionNode(childNode, source, childPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.items = append(result.items, child)
|
||||
}
|
||||
case yaml.ScalarNode:
|
||||
// Scalar tag and lexical value retain distinctions such as explicit
|
||||
// false, zero, an empty string, and null until final strict decoding.
|
||||
default:
|
||||
return nil, compositionNodeError(source, path, node, fmt.Sprintf("unsupported YAML node kind %s", yamlKindName(node.Kind)))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// mergeAdditiveComposition recursively combines disjoint mappings. A scalar,
|
||||
// list, or final keyed value may have only one base owner, regardless of
|
||||
// whether duplicate values happen to be equal.
|
||||
func mergeAdditiveComposition(base, incoming *compositionDocument) (*compositionDocument, error) {
|
||||
return mergeAdditiveCompositions(base, incoming)
|
||||
}
|
||||
|
||||
// mergeAdditiveCompositions validates the complete base source set before
|
||||
// merging so a conflict names every source that claims the same final path.
|
||||
func mergeAdditiveCompositions(documents ...*compositionDocument) (*compositionDocument, error) {
|
||||
if len(documents) == 0 {
|
||||
return nil, fmt.Errorf("configuration additive base merge requires at least one document")
|
||||
}
|
||||
for index, document := range documents {
|
||||
if err := validateCompositionDocument(document, fmt.Sprintf("base[%d]", index)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := validateAdditiveClaims(documents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &compositionDocument{
|
||||
root: cloneCompositionNode(documents[0].root),
|
||||
sources: append([]string(nil), documents[0].sources...),
|
||||
}
|
||||
for _, incoming := range documents[1:] {
|
||||
merged, err := mergeAdditiveNodes(result.root, incoming.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.root = merged
|
||||
result.sources = appendUniqueStrings(result.sources, incoming.sources...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateAdditiveClaims(documents []*compositionDocument) error {
|
||||
claims := make(map[string][]*compositionNode)
|
||||
for _, document := range documents {
|
||||
appendCompositionClaims(document.root, claims)
|
||||
}
|
||||
paths := make([]string, 0, len(claims))
|
||||
for path := range claims {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Slice(paths, func(i, j int) bool {
|
||||
leftDepth := compositionPathDepth(paths[i])
|
||||
rightDepth := compositionPathDepth(paths[j])
|
||||
if leftDepth != rightDepth {
|
||||
return leftDepth < rightDepth
|
||||
}
|
||||
return paths[i] < paths[j]
|
||||
})
|
||||
for _, path := range paths {
|
||||
values := claims[path]
|
||||
if len(values) < 2 {
|
||||
continue
|
||||
}
|
||||
allPopulatedMappings := true
|
||||
for _, value := range values {
|
||||
if value.kind != yaml.MappingNode || len(value.fields) == 0 {
|
||||
allPopulatedMappings = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allPopulatedMappings {
|
||||
return newCompositionConflict("additive base merge", path, values...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendCompositionClaims(node *compositionNode, claims map[string][]*compositionNode) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.path != "" {
|
||||
claims[node.path] = append(claims[node.path], node)
|
||||
}
|
||||
for _, field := range node.fields {
|
||||
appendCompositionClaims(field.value, claims)
|
||||
}
|
||||
}
|
||||
|
||||
func compositionPathDepth(path string) int {
|
||||
if path == "" {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(path, ".") + strings.Count(path, "[") + 1
|
||||
}
|
||||
|
||||
func mergeAdditiveNodes(base, incoming *compositionNode) (*compositionNode, error) {
|
||||
if base.kind != yaml.MappingNode || incoming.kind != yaml.MappingNode {
|
||||
return nil, newCompositionConflict("additive base merge", base.path, base, incoming)
|
||||
}
|
||||
base.sources = appendUniqueStrings(base.sources, incoming.sources...)
|
||||
for _, incomingField := range incoming.fields {
|
||||
index := compositionFieldIndex(base.fields, incomingField.key)
|
||||
if index < 0 {
|
||||
field := cloneCompositionField(incomingField)
|
||||
field.order = len(base.fields)
|
||||
base.fields = append(base.fields, field)
|
||||
continue
|
||||
}
|
||||
baseValue := base.fields[index].value
|
||||
incomingValue := incomingField.value
|
||||
if baseValue.kind == yaml.MappingNode && incomingValue.kind == yaml.MappingNode {
|
||||
if len(baseValue.fields) == 0 || len(incomingValue.fields) == 0 {
|
||||
return nil, newCompositionConflict("additive base merge", incomingValue.path, baseValue, incomingValue)
|
||||
}
|
||||
merged, err := mergeAdditiveNodes(baseValue, incomingValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base.fields[index].value = merged
|
||||
continue
|
||||
}
|
||||
return nil, newCompositionConflict("additive base merge", incomingValue.path, baseValue, incomingValue)
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// mergeOverlayComposition applies the sole overwrite layer. Mappings merge
|
||||
// recursively; same-kind scalars and lists replace; null and kind changes are
|
||||
// rejected.
|
||||
func mergeOverlayComposition(base, overlay *compositionDocument) (*compositionDocument, error) {
|
||||
if err := validateCompositionDocument(base, "base"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateCompositionDocument(overlay, "overlay"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if null := firstNullCompositionNode(overlay.root); null != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"configuration overlay at %s from %s: null cannot delete an effective value",
|
||||
displayCompositionPath(null.path), formatCompositionSources(null.sources),
|
||||
)
|
||||
}
|
||||
merged, err := mergeOverlayNodes(cloneCompositionNode(base.root), overlay.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &compositionDocument{
|
||||
root: merged,
|
||||
sources: appendUniqueStrings(
|
||||
append([]string(nil), base.sources...), overlay.sources...,
|
||||
),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mergeOverlayNodes(base, overlay *compositionNode) (*compositionNode, error) {
|
||||
if base.kind != yaml.MappingNode || overlay.kind != yaml.MappingNode {
|
||||
return nil, newCompositionConflict("profile overlay", base.path, base, overlay)
|
||||
}
|
||||
base.sources = appendUniqueStrings(base.sources, overlay.sources...)
|
||||
for _, overlayField := range overlay.fields {
|
||||
index := compositionFieldIndex(base.fields, overlayField.key)
|
||||
if index < 0 {
|
||||
field := cloneCompositionField(overlayField)
|
||||
field.order = len(base.fields)
|
||||
base.fields = append(base.fields, field)
|
||||
continue
|
||||
}
|
||||
baseValue := base.fields[index].value
|
||||
overlayValue := overlayField.value
|
||||
if baseValue.kind != overlayValue.kind {
|
||||
return nil, newCompositionConflict("profile overlay kind change", overlayValue.path, baseValue, overlayValue)
|
||||
}
|
||||
if baseValue.kind == yaml.MappingNode {
|
||||
merged, err := mergeOverlayNodes(baseValue, overlayValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base.fields[index].value = merged
|
||||
continue
|
||||
}
|
||||
base.fields[index].value = cloneCompositionNode(overlayValue)
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// canonicalYAML renders the effective mapping with sorted keys and normalized
|
||||
// presentation while retaining sequence order and scalar YAML types.
|
||||
func (document *compositionDocument) canonicalYAML() ([]byte, error) {
|
||||
if err := validateCompositionDocument(document, "document"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := compositionYAMLNode(document.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&buffer)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(root); err != nil {
|
||||
return nil, fmt.Errorf("render effective configuration YAML: %w", err)
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finish effective configuration YAML: %w", err)
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
// canonicalDigestInput provides a deterministic, formatting-independent byte
|
||||
// representation for later secret-free effective configuration digesting.
|
||||
func (document *compositionDocument) canonicalDigestInput() ([]byte, error) {
|
||||
if err := validateCompositionDocument(document, "document"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := canonicalCompositionValue(document.root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serialize canonical configuration digest input: %w", err)
|
||||
}
|
||||
return append(data, '\n'), nil
|
||||
}
|
||||
|
||||
// semanticRecords returns sorted atomic values for future effective diff and
|
||||
// source-report projections. A caller receives copies of all source slices.
|
||||
func (document *compositionDocument) semanticRecords() ([]compositionValueRecord, error) {
|
||||
if err := validateCompositionDocument(document, "document"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var records []compositionValueRecord
|
||||
if err := appendCompositionRecords(document.root, &records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool { return records[i].Path < records[j].Path })
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// compactSemanticRecords returns the same logical atomic paths as
|
||||
// semanticRecords, but represents values as compact JSON-compatible YAML
|
||||
// values rather than the typed structural form used for digesting. It is the
|
||||
// stable human-facing projection for semantic comparisons.
|
||||
func (document *compositionDocument) compactSemanticRecords() ([]compositionValueRecord, error) {
|
||||
if err := validateCompositionDocument(document, "document"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var records []compositionValueRecord
|
||||
if err := appendCompactCompositionRecords(document.root, &records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool { return records[i].Path < records[j].Path })
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func appendCompositionRecords(node *compositionNode, records *[]compositionValueRecord) error {
|
||||
if node.kind == yaml.MappingNode && len(node.fields) > 0 {
|
||||
for _, field := range node.fields {
|
||||
if err := appendCompositionRecords(field.value, records); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
value, err := canonicalCompositionValue(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize configuration value at %s: %w", displayCompositionPath(node.path), err)
|
||||
}
|
||||
*records = append(*records, compositionValueRecord{
|
||||
Path: node.path, Kind: node.kind, Value: string(encoded),
|
||||
Sources: append([]string(nil), node.sources...),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendCompactCompositionRecords(node *compositionNode, records *[]compositionValueRecord) error {
|
||||
if node.kind == yaml.MappingNode && len(node.fields) > 0 {
|
||||
for _, field := range node.fields {
|
||||
if err := appendCompactCompositionRecords(field.value, records); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
value, err := compactCompositionValue(node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize configuration value at %s: %w", displayCompositionPath(node.path), err)
|
||||
}
|
||||
*records = append(*records, compositionValueRecord{
|
||||
Path: node.path, Kind: node.kind, Value: string(encoded),
|
||||
Sources: append([]string(nil), node.sources...),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func compactCompositionValue(node *compositionNode) (any, error) {
|
||||
switch node.kind {
|
||||
case yaml.MappingNode:
|
||||
values := make(map[string]any, len(node.fields))
|
||||
for _, field := range node.fields {
|
||||
value, err := compactCompositionValue(field.value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[field.key] = value
|
||||
}
|
||||
return values, nil
|
||||
case yaml.SequenceNode:
|
||||
values := make([]any, 0, len(node.items))
|
||||
for _, item := range node.items {
|
||||
value, err := compactCompositionValue(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
return values, nil
|
||||
case yaml.ScalarNode:
|
||||
return canonicalScalarValue(node)
|
||||
default:
|
||||
return nil, fmt.Errorf("configuration at %s has unsupported YAML kind %s", displayCompositionPath(node.path), yamlKindName(node.kind))
|
||||
}
|
||||
}
|
||||
|
||||
type canonicalCompositionField struct {
|
||||
Key string `json:"key"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type canonicalCompositionNode struct {
|
||||
Kind string `json:"kind"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Value any `json:"value,omitempty"`
|
||||
Fields []canonicalCompositionField `json:"fields,omitempty"`
|
||||
Items []any `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
func canonicalCompositionValue(node *compositionNode) (any, error) {
|
||||
switch node.kind {
|
||||
case yaml.MappingNode:
|
||||
fields := append([]compositionField(nil), node.fields...)
|
||||
sort.Slice(fields, func(i, j int) bool { return fields[i].key < fields[j].key })
|
||||
result := canonicalCompositionNode{Kind: "mapping"}
|
||||
if len(fields) == 0 {
|
||||
result.Fields = []canonicalCompositionField{}
|
||||
}
|
||||
for _, field := range fields {
|
||||
value, err := canonicalCompositionValue(field.value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Fields = append(result.Fields, canonicalCompositionField{Key: field.key, Value: value})
|
||||
}
|
||||
return result, nil
|
||||
case yaml.SequenceNode:
|
||||
result := canonicalCompositionNode{Kind: "sequence", Items: make([]any, 0, len(node.items))}
|
||||
for _, item := range node.items {
|
||||
value, err := canonicalCompositionValue(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Items = append(result.Items, value)
|
||||
}
|
||||
return result, nil
|
||||
case yaml.ScalarNode:
|
||||
value, err := canonicalScalarValue(node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return canonicalCompositionNode{Kind: "scalar", Tag: node.tag, Value: value}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("configuration at %s has unsupported YAML kind %s", displayCompositionPath(node.path), yamlKindName(node.kind))
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalScalarValue(node *compositionNode) (any, error) {
|
||||
raw := &yaml.Node{Kind: yaml.ScalarNode, Tag: node.tag, Value: node.value}
|
||||
var value any
|
||||
if err := raw.Decode(&value); err != nil {
|
||||
return nil, fmt.Errorf("decode scalar at %s: %w", displayCompositionPath(node.path), err)
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case nil, bool, string, int, int64, uint64, float64:
|
||||
return typed, nil
|
||||
default:
|
||||
// yaml.v3 may decode timestamps or uncommon scalar tags into types that
|
||||
// encoding/json can serialize deterministically. Preserve the resolved
|
||||
// tag alongside the value in the containing canonical node.
|
||||
return typed, nil
|
||||
}
|
||||
}
|
||||
|
||||
func compositionYAMLNode(node *compositionNode) (*yaml.Node, error) {
|
||||
switch node.kind {
|
||||
case yaml.MappingNode:
|
||||
result := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
|
||||
fields := append([]compositionField(nil), node.fields...)
|
||||
sort.Slice(fields, func(i, j int) bool { return fields[i].key < fields[j].key })
|
||||
for _, field := range fields {
|
||||
value, err := compositionYAMLNode(field.value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Content = append(result.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: field.key},
|
||||
value,
|
||||
)
|
||||
}
|
||||
return result, nil
|
||||
case yaml.SequenceNode:
|
||||
result := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
|
||||
for _, item := range node.items {
|
||||
value, err := compositionYAMLNode(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Content = append(result.Content, value)
|
||||
}
|
||||
return result, nil
|
||||
case yaml.ScalarNode:
|
||||
return normalizedCompositionScalarNode(node)
|
||||
default:
|
||||
return nil, fmt.Errorf("configuration at %s has unsupported YAML kind %s", displayCompositionPath(node.path), yamlKindName(node.kind))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedCompositionScalarNode(node *compositionNode) (*yaml.Node, error) {
|
||||
raw := &yaml.Node{Kind: yaml.ScalarNode, Tag: node.tag, Value: node.value}
|
||||
var value any
|
||||
if err := raw.Decode(&value); err != nil {
|
||||
return nil, fmt.Errorf("normalize scalar at %s: %w", displayCompositionPath(node.path), err)
|
||||
}
|
||||
normalized := &yaml.Node{}
|
||||
if err := normalized.Encode(value); err != nil {
|
||||
return nil, fmt.Errorf("encode normalized scalar at %s: %w", displayCompositionPath(node.path), err)
|
||||
}
|
||||
if normalized.Kind != yaml.ScalarNode {
|
||||
return nil, fmt.Errorf("normalize scalar at %s produced YAML kind %s", displayCompositionPath(node.path), yamlKindName(normalized.Kind))
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func validateCompositionDocument(document *compositionDocument, role string) error {
|
||||
if document == nil || document.root == nil {
|
||||
return fmt.Errorf("configuration composition %s document is nil", role)
|
||||
}
|
||||
if document.root.kind != yaml.MappingNode {
|
||||
return fmt.Errorf("configuration composition %s root must be a mapping", role)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNullCompositionNode(node *compositionNode) *compositionNode {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if node.kind == yaml.ScalarNode && node.tag == "!!null" {
|
||||
return node
|
||||
}
|
||||
for _, field := range node.fields {
|
||||
if found := firstNullCompositionNode(field.value); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
for _, item := range node.items {
|
||||
if found := firstNullCompositionNode(item); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneCompositionNode(node *compositionNode) *compositionNode {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
clone := &compositionNode{
|
||||
kind: node.kind, tag: node.tag, value: node.value, path: node.path,
|
||||
sources: append([]string(nil), node.sources...), line: node.line, column: node.column,
|
||||
}
|
||||
for _, field := range node.fields {
|
||||
clone.fields = append(clone.fields, cloneCompositionField(field))
|
||||
}
|
||||
for _, item := range node.items {
|
||||
clone.items = append(clone.items, cloneCompositionNode(item))
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneCompositionField(field compositionField) compositionField {
|
||||
return compositionField{
|
||||
key: field.key, value: cloneCompositionNode(field.value), order: field.order,
|
||||
line: field.line, column: field.column,
|
||||
}
|
||||
}
|
||||
|
||||
func compositionFieldIndex(fields []compositionField, key string) int {
|
||||
for index := range fields {
|
||||
if fields[index].key == key {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func appendCompositionPath(parent, key string) string {
|
||||
if isSimpleCompositionPathSegment(key) {
|
||||
if parent == "" {
|
||||
return key
|
||||
}
|
||||
return parent + "." + key
|
||||
}
|
||||
if parent == "" {
|
||||
return "[" + strconv.Quote(key) + "]"
|
||||
}
|
||||
return parent + "[" + strconv.Quote(key) + "]"
|
||||
}
|
||||
|
||||
func isSimpleCompositionPathSegment(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for index, char := range value {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || char == '_' || (index > 0 && char >= '0' && char <= '9') || (index > 0 && char == '-') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func displayCompositionPath(path string) string {
|
||||
if path == "" {
|
||||
return "<root>"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func compositionNodeError(source, path string, node *yaml.Node, message string) error {
|
||||
line, column := 0, 0
|
||||
if node != nil {
|
||||
line, column = node.Line, node.Column
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"configuration source %q at %s (line %d, column %d): %s",
|
||||
source, displayCompositionPath(path), line, column, message,
|
||||
)
|
||||
}
|
||||
|
||||
func newCompositionConflict(operation, path string, values ...*compositionNode) error {
|
||||
var sources []string
|
||||
var kinds []string
|
||||
for _, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
sources = appendUniqueStrings(sources, compositionClaimSources(value)...)
|
||||
kind := yamlKindName(value.kind)
|
||||
if !containsString(kinds, kind) {
|
||||
kinds = append(kinds, kind)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"configuration %s conflict at %s: claimed by %s (YAML kinds: %s)",
|
||||
operation, displayCompositionPath(path), formatCompositionSources(sources), strings.Join(kinds, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
func compositionClaimSources(node *compositionNode) []string {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
sources := append([]string(nil), node.sources...)
|
||||
for _, field := range node.fields {
|
||||
sources = appendUniqueStrings(sources, compositionClaimSources(field.value)...)
|
||||
}
|
||||
for _, item := range node.items {
|
||||
sources = appendUniqueStrings(sources, compositionClaimSources(item)...)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func appendUniqueStrings(values []string, additions ...string) []string {
|
||||
seen := make(map[string]struct{}, len(values)+len(additions))
|
||||
result := make([]string, 0, len(values)+len(additions))
|
||||
for _, value := range append(append([]string(nil), values...), additions...) {
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func containsString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func formatCompositionSources(sources []string) string {
|
||||
quoted := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
quoted = append(quoted, strconv.Quote(source))
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
func yamlKindName(kind yaml.Kind) string {
|
||||
switch kind {
|
||||
case yaml.DocumentNode:
|
||||
return "document"
|
||||
case yaml.MappingNode:
|
||||
return "mapping"
|
||||
case yaml.SequenceNode:
|
||||
return "sequence"
|
||||
case yaml.ScalarNode:
|
||||
return "scalar"
|
||||
case yaml.AliasNode:
|
||||
return "alias"
|
||||
default:
|
||||
return fmt.Sprintf("kind(%d)", kind)
|
||||
}
|
||||
}
|
||||
353
internal/config/composition_test.go
Normal file
353
internal/config/composition_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestParseCompositionDocumentRetainsPresenceOwnershipAndDeclarationOrder(t *testing.T) {
|
||||
document := mustParseComposition(t, "root.yml", `zeta: false
|
||||
zero: 0
|
||||
empty_map: {}
|
||||
empty_list: []
|
||||
nested:
|
||||
value: ""
|
||||
`)
|
||||
|
||||
wantOrder := []string{"zeta", "zero", "empty_map", "empty_list", "nested"}
|
||||
gotOrder := make([]string, 0, len(document.root.fields))
|
||||
for _, field := range document.root.fields {
|
||||
gotOrder = append(gotOrder, field.key)
|
||||
}
|
||||
if !reflect.DeepEqual(gotOrder, wantOrder) {
|
||||
t.Fatalf("declaration order = %#v, want %#v", gotOrder, wantOrder)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
kind yaml.Kind
|
||||
tag string
|
||||
value string
|
||||
}{
|
||||
{path: "zeta", kind: yaml.ScalarNode, tag: "!!bool", value: "false"},
|
||||
{path: "zero", kind: yaml.ScalarNode, tag: "!!int", value: "0"},
|
||||
{path: "empty_map", kind: yaml.MappingNode, tag: "!!map"},
|
||||
{path: "empty_list", kind: yaml.SequenceNode, tag: "!!seq"},
|
||||
{path: "nested.value", kind: yaml.ScalarNode, tag: "!!str", value: ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
node := compositionNodeAtPath(t, document.root, tt.path)
|
||||
if node.kind != tt.kind || node.tag != tt.tag || node.value != tt.value || !reflect.DeepEqual(node.sources, []string{"root.yml"}) {
|
||||
t.Fatalf("node %s = kind=%v tag=%q value=%q sources=%#v", tt.path, node.kind, node.tag, node.value, node.sources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCompositionDocumentRejectsAmbiguousOrMalformedYAML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want string
|
||||
}{
|
||||
{name: "duplicate top-level key", yaml: "value: 1\nvalue: 2\n", want: "duplicate YAML key"},
|
||||
{name: "duplicate nested key", yaml: "outer:\n value: 1\n value: 2\n", want: "outer.value"},
|
||||
{name: "alias", yaml: "base: &base\n value: 1\ncopy: *base\n", want: "aliases are not supported"},
|
||||
{name: "trailing document", yaml: "value: 1\n---\nvalue: 2\n", want: "exactly one YAML document"},
|
||||
{name: "top-level sequence", yaml: "- value\n", want: "top-level document must be a mapping"},
|
||||
{name: "non-string key", yaml: "1: value\n", want: "mapping keys must be strings"},
|
||||
{name: "malformed", yaml: "outer: [\n", want: "decode YAML"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := parseCompositionDocument("broken.yml", strings.NewReader(tt.yaml))
|
||||
if err == nil {
|
||||
t.Fatal("parseCompositionDocument() error = nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "broken.yml") || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %q, want source and %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAdditiveCompositionJoinsOnlyDisjointMappings(t *testing.T) {
|
||||
root := mustParseComposition(t, "pipeline.yml", `scriptorium:
|
||||
artifacts:
|
||||
recap:
|
||||
enabled: true
|
||||
zero: 0
|
||||
`)
|
||||
imports := mustParseComposition(t, "conf.d/artifacts.yml", `scriptorium:
|
||||
artifacts:
|
||||
handout:
|
||||
enabled: false
|
||||
empty_list: []
|
||||
`)
|
||||
|
||||
merged, err := mergeAdditiveComposition(root, imports)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeAdditiveComposition() error = %v", err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"empty_list", "scriptorium.artifacts.handout.enabled",
|
||||
"scriptorium.artifacts.recap.enabled", "zero",
|
||||
} {
|
||||
_ = compositionNodeAtPath(t, merged.root, path)
|
||||
}
|
||||
if got := compositionNodeAtPath(t, merged.root, "scriptorium.artifacts.handout.enabled").sources; !reflect.DeepEqual(got, []string{"conf.d/artifacts.yml"}) {
|
||||
t.Fatalf("handout sources = %#v", got)
|
||||
}
|
||||
if got := compositionNodeAtPath(t, merged.root, "scriptorium.artifacts.recap.enabled").sources; !reflect.DeepEqual(got, []string{"pipeline.yml"}) {
|
||||
t.Fatalf("recap sources = %#v", got)
|
||||
}
|
||||
|
||||
// Merge operations return a new document and retain the declared order in
|
||||
// each input for source-aware diagnostics.
|
||||
if len(root.root.fields) != 2 || len(imports.root.fields) != 2 {
|
||||
t.Fatalf("merge mutated inputs: root=%d import=%d", len(root.root.fields), len(imports.root.fields))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAdditiveCompositionRejectsEveryDuplicateClass(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
baseYAML string
|
||||
nextYAML string
|
||||
path string
|
||||
}{
|
||||
{name: "equal scalar", baseYAML: "value: true\n", nextYAML: "value: true\n", path: "value"},
|
||||
{name: "different scalar", baseYAML: "value: true\n", nextYAML: "value: false\n", path: "value"},
|
||||
{name: "atomic list", baseYAML: "values: [one]\n", nextYAML: "values: [two]\n", path: "values"},
|
||||
{name: "keyed entry", baseYAML: "items:\n shared:\n left: 1\n", nextYAML: "items:\n shared:\n left: 2\n", path: "items.shared.left"},
|
||||
{name: "kind conflict", baseYAML: "value:\n nested: true\n", nextYAML: "value: scalar\n", path: "value"},
|
||||
{name: "duplicate empty map", baseYAML: "value: {}\n", nextYAML: "value: {}\n", path: "value"},
|
||||
{name: "empty map then populated map", baseYAML: "value: {}\n", nextYAML: "value: {nested: true}\n", path: "value"},
|
||||
{name: "populated map then empty map", baseYAML: "value: {nested: true}\n", nextYAML: "value: {}\n", path: "value"},
|
||||
{name: "duplicate empty list", baseYAML: "value: []\n", nextYAML: "value: []\n", path: "value"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
base := mustParseComposition(t, "base.yml", tt.baseYAML)
|
||||
next := mustParseComposition(t, "next.yml", tt.nextYAML)
|
||||
_, err := mergeAdditiveComposition(base, next)
|
||||
if err == nil {
|
||||
t.Fatal("mergeAdditiveComposition() error = nil")
|
||||
}
|
||||
for _, want := range []string{tt.path, "base.yml", "next.yml"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q, want %q", err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAdditiveCompositionReportsAllClaimingSources(t *testing.T) {
|
||||
left := mustParseComposition(t, "left.yml", "group:\n left: 1\n")
|
||||
right := mustParseComposition(t, "right.yml", "group:\n right: 2\n")
|
||||
base, err := mergeAdditiveComposition(left, right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overlap := mustParseComposition(t, "overlap.yml", "group: scalar\n")
|
||||
_, err = mergeAdditiveComposition(base, overlap)
|
||||
if err == nil {
|
||||
t.Fatal("mergeAdditiveComposition() error = nil")
|
||||
}
|
||||
for _, want := range []string{"group", "left.yml", "right.yml", "overlap.yml"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q, want %q", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = mergeAdditiveCompositions(
|
||||
mustParseComposition(t, "first.yml", "value: 1\n"),
|
||||
mustParseComposition(t, "second.yml", "value: 2\n"),
|
||||
mustParseComposition(t, "third.yml", "value: 3\n"),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("mergeAdditiveCompositions() error = nil")
|
||||
}
|
||||
for _, want := range []string{"value", "first.yml", "second.yml", "third.yml"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error = %q, want %q", err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeOverlayCompositionRecursesMapsAndReplacesAtomicValues(t *testing.T) {
|
||||
base := mustParseComposition(t, "base.yml", `feature:
|
||||
enabled: true
|
||||
retries: 3
|
||||
values: [one, two]
|
||||
inherited: kept
|
||||
artifacts:
|
||||
recap:
|
||||
enabled: true
|
||||
`)
|
||||
overlay := mustParseComposition(t, "testing.yml", `feature:
|
||||
enabled: false
|
||||
retries: 0
|
||||
values: []
|
||||
added: present
|
||||
artifacts:
|
||||
handout:
|
||||
enabled: false
|
||||
`)
|
||||
|
||||
merged, err := mergeOverlayComposition(base, overlay)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeOverlayComposition() error = %v", err)
|
||||
}
|
||||
assertCompositionScalar(t, merged.root, "feature.enabled", "!!bool", "false", "testing.yml")
|
||||
assertCompositionScalar(t, merged.root, "feature.retries", "!!int", "0", "testing.yml")
|
||||
assertCompositionScalar(t, merged.root, "feature.inherited", "!!str", "kept", "base.yml")
|
||||
assertCompositionScalar(t, merged.root, "feature.added", "!!str", "present", "testing.yml")
|
||||
if values := compositionNodeAtPath(t, merged.root, "feature.values"); values.kind != yaml.SequenceNode || len(values.items) != 0 || !reflect.DeepEqual(values.sources, []string{"testing.yml"}) {
|
||||
t.Fatalf("replaced list = %#v", values)
|
||||
}
|
||||
_ = compositionNodeAtPath(t, merged.root, "artifacts.recap.enabled")
|
||||
_ = compositionNodeAtPath(t, merged.root, "artifacts.handout.enabled")
|
||||
}
|
||||
|
||||
func TestMergeOverlayCompositionRejectsKindChangesAndNullDeletion(t *testing.T) {
|
||||
kindTests := []struct {
|
||||
name string
|
||||
baseYAML string
|
||||
overlay string
|
||||
}{
|
||||
{name: "map to scalar", baseYAML: "value: {nested: true}\n", overlay: "value: replacement\n"},
|
||||
{name: "scalar to map", baseYAML: "value: original\n", overlay: "value: {nested: true}\n"},
|
||||
{name: "list to scalar", baseYAML: "value: [one]\n", overlay: "value: replacement\n"},
|
||||
{name: "scalar to list", baseYAML: "value: original\n", overlay: "value: [one]\n"},
|
||||
}
|
||||
for _, tt := range kindTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := mergeOverlayComposition(
|
||||
mustParseComposition(t, "base.yml", tt.baseYAML),
|
||||
mustParseComposition(t, "overlay.yml", tt.overlay),
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "value") || !strings.Contains(err.Error(), "kind change") {
|
||||
t.Fatalf("error = %v, want value kind change", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, overlayYAML := range []string{"value: null\n", "value: ~\n", "nested:\n value:\n"} {
|
||||
_, err := mergeOverlayComposition(
|
||||
mustParseComposition(t, "base.yml", "value: original\nnested:\n value: original\n"),
|
||||
mustParseComposition(t, "overlay.yml", overlayYAML),
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "null cannot delete") || !strings.Contains(err.Error(), "overlay.yml") {
|
||||
t.Fatalf("error = %v, want source-qualified null rejection", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositionCanonicalOutputsAreDeterministic(t *testing.T) {
|
||||
first := mustParseComposition(t, "first.yml", `zeta: 01
|
||||
alpha:
|
||||
list: [true, false]
|
||||
empty: {}
|
||||
`)
|
||||
second := mustParseComposition(t, "second.yml", `alpha:
|
||||
empty: {}
|
||||
list:
|
||||
- true
|
||||
- false
|
||||
zeta: 1
|
||||
`)
|
||||
|
||||
firstYAML, err := first.canonicalYAML()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondYAML, err := second.canonicalYAML()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(firstYAML, secondYAML) {
|
||||
t.Fatalf("canonical YAML differs:\n%s\n---\n%s", firstYAML, secondYAML)
|
||||
}
|
||||
firstDigest, err := first.canonicalDigestInput()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondDigest, err := second.canonicalDigestInput()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(firstDigest, secondDigest) {
|
||||
t.Fatalf("digest input differs:\n%s\n---\n%s", firstDigest, secondDigest)
|
||||
}
|
||||
|
||||
left := mustParseComposition(t, "left.yml", "zeta: 1\n")
|
||||
right := mustParseComposition(t, "right.yml", "alpha: 2\n")
|
||||
leftRight, err := mergeAdditiveComposition(left, right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rightLeft, err := mergeAdditiveComposition(right, left)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, _ := leftRight.canonicalDigestInput()
|
||||
got, _ := rightLeft.canonicalDigestInput()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("source traversal changed semantic digest input: got %s want %s", got, want)
|
||||
}
|
||||
wantYAML, _ := leftRight.canonicalYAML()
|
||||
gotYAML, _ := rightLeft.canonicalYAML()
|
||||
if !reflect.DeepEqual(gotYAML, wantYAML) {
|
||||
t.Fatalf("source traversal changed canonical YAML: got %s want %s", gotYAML, wantYAML)
|
||||
}
|
||||
|
||||
records, err := first.semanticRecords()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := make([]string, 0, len(records))
|
||||
for _, record := range records {
|
||||
paths = append(paths, record.Path)
|
||||
}
|
||||
if wantPaths := []string{"alpha.empty", "alpha.list", "zeta"}; !reflect.DeepEqual(paths, wantPaths) {
|
||||
t.Fatalf("semantic record paths = %#v, want %#v", paths, wantPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseComposition(t *testing.T, source, input string) *compositionDocument {
|
||||
t.Helper()
|
||||
document, err := parseCompositionBytes(source, []byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parseCompositionBytes(%q) error = %v", source, err)
|
||||
}
|
||||
return document
|
||||
}
|
||||
|
||||
func compositionNodeAtPath(t *testing.T, root *compositionNode, path string) *compositionNode {
|
||||
t.Helper()
|
||||
node := root
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
if node == nil || node.kind != yaml.MappingNode {
|
||||
t.Fatalf("path %q reached non-mapping at %q", path, segment)
|
||||
}
|
||||
index := compositionFieldIndex(node.fields, segment)
|
||||
if index < 0 {
|
||||
t.Fatalf("path %q missing segment %q", path, segment)
|
||||
}
|
||||
node = node.fields[index].value
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
func assertCompositionScalar(t *testing.T, root *compositionNode, path, tag, value, source string) {
|
||||
t.Helper()
|
||||
node := compositionNodeAtPath(t, root, path)
|
||||
if node.kind != yaml.ScalarNode || node.tag != tag || node.value != value || !reflect.DeepEqual(node.sources, []string{source}) {
|
||||
t.Fatalf("%s = kind=%s tag=%q value=%q sources=%#v", path, yamlKindName(node.kind), node.tag, node.value, node.sources)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type Config struct {
|
||||
|
||||
StableInputs ResolvedStableInputs
|
||||
SessionSource SessionSource
|
||||
Party ResolvedParty
|
||||
}
|
||||
|
||||
// PipelineConfig contains durable pipeline-level settings.
|
||||
@@ -32,6 +33,17 @@ type PipelineConfig struct {
|
||||
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
||||
Notarius *NotariusConfig `yaml:"notarius"`
|
||||
Notification NotificationConfig `yaml:"notification"`
|
||||
|
||||
resolution *pipelineResolutionMetadata `yaml:"-"`
|
||||
familyCatalog ArtifactFamilyCatalog `yaml:"-"`
|
||||
}
|
||||
|
||||
// ArtifactFamilies returns a copy of runtime-only expansion provenance.
|
||||
func ArtifactFamilies(cfg *PipelineConfig) ArtifactFamilyCatalog {
|
||||
if cfg == nil {
|
||||
return ArtifactFamilyCatalog{}
|
||||
}
|
||||
return cloneArtifactFamilyCatalog(cfg.familyCatalog)
|
||||
}
|
||||
|
||||
// CampaignsConfig configures the local campaign registry.
|
||||
@@ -238,6 +250,7 @@ type ScriptoriumConfig struct {
|
||||
Timeout string `yaml:"timeout"`
|
||||
RenderDebug bool `yaml:"render_debug"`
|
||||
Artifacts map[string]ScriptoriumArtifactConfig `yaml:"artifacts"`
|
||||
ArtifactFamilies map[string]ScriptoriumArtifactFamilyConfig `yaml:"artifact_families"`
|
||||
}
|
||||
|
||||
// ScriptoriumArtifactConfig configures one named output artifact workflow.
|
||||
@@ -253,6 +266,57 @@ type ScriptoriumArtifactConfig struct {
|
||||
Vars map[string]any `yaml:"vars"`
|
||||
}
|
||||
|
||||
// ScriptoriumArtifactFamilyConfig declares the shared configuration expanded
|
||||
// into one ordinary artifact for each canonical party character.
|
||||
type ScriptoriumArtifactFamilyConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
ForEach string `yaml:"for_each"`
|
||||
DependsOn []string `yaml:"depends_on"`
|
||||
RenderDebug *bool `yaml:"render_debug"`
|
||||
PromptID string `yaml:"prompt_id"`
|
||||
ProfileID string `yaml:"profile_id"`
|
||||
OutputPathPattern string `yaml:"output_path_pattern"`
|
||||
Timeout string `yaml:"timeout"`
|
||||
Inputs map[string]ScriptoriumInputConfig `yaml:"inputs"`
|
||||
Vars map[string]any `yaml:"vars"`
|
||||
MemberVars map[string]string `yaml:"member_vars"`
|
||||
MemberDependencies []string `yaml:"member_dependencies"`
|
||||
Publish *ScriptoriumArtifactFamilyPublishConfig `yaml:"publish"`
|
||||
}
|
||||
|
||||
// ScriptoriumArtifactFamilyPublishConfig retains the typed declaration for
|
||||
// family publishing. It is resolved by the publish owner.
|
||||
type ScriptoriumArtifactFamilyPublishConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Required bool `yaml:"required"`
|
||||
DestPattern string `yaml:"dest_pattern"`
|
||||
}
|
||||
|
||||
// ArtifactFamilyCatalog records the runtime-only origin of expanded artifacts.
|
||||
// It is intentionally kept outside the Scriptorium adapter configuration.
|
||||
type ArtifactFamilyCatalog struct {
|
||||
Families map[string]ArtifactFamilyOrigin
|
||||
Members map[string]ArtifactFamilyMemberOrigin
|
||||
}
|
||||
|
||||
// ArtifactFamilyOrigin records one declared family and its source ownership.
|
||||
type ArtifactFamilyOrigin struct {
|
||||
Members []string
|
||||
MemberDependencies []string
|
||||
Publish *ScriptoriumArtifactFamilyPublishConfig
|
||||
Source string
|
||||
}
|
||||
|
||||
// ArtifactFamilyMemberOrigin identifies the canonical party member that
|
||||
// produced one ordinary concrete artifact.
|
||||
type ArtifactFamilyMemberOrigin struct {
|
||||
Family string
|
||||
CharacterID string
|
||||
Source string
|
||||
Dependencies []string
|
||||
Inputs map[string]string
|
||||
}
|
||||
|
||||
// ScriptoriumInputConfig configures one named prompt input source.
|
||||
type ScriptoriumInputConfig struct {
|
||||
Source string `yaml:"source"`
|
||||
@@ -321,6 +385,23 @@ type ResolvedInputFile struct {
|
||||
Source string
|
||||
}
|
||||
|
||||
// ResolvedParty records the selected party mode, its non-secret source
|
||||
// provenance, and canonical domain data when available. It is runtime-only and
|
||||
// must never be copied into manifests as raw party content.
|
||||
type ResolvedParty struct {
|
||||
Mode PartyMode
|
||||
Source PartySource
|
||||
Canonical *CanonicalParty
|
||||
}
|
||||
|
||||
// PartySource identifies the selected party input without retaining its raw
|
||||
// contents. Path is the resolved on-disk source path.
|
||||
type PartySource struct {
|
||||
Path string
|
||||
ConfigPath string
|
||||
Source string
|
||||
}
|
||||
|
||||
// SessionSource records where session.yml came from before materialization.
|
||||
type SessionSource struct {
|
||||
Source string
|
||||
|
||||
129
internal/config/effective_digest.go
Normal file
129
internal/config/effective_digest.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const pipelineDefaultOwnershipSource = "default"
|
||||
|
||||
// PipelineProfileProvenance identifies the profile selected while resolving a
|
||||
// pipeline. It contains only non-secret selection metadata.
|
||||
type PipelineProfileProvenance struct {
|
||||
Name string
|
||||
Source string
|
||||
OverlayPath string
|
||||
}
|
||||
|
||||
// SelectedPipelineProfile reports the profile used to resolve cfg, if any.
|
||||
func SelectedPipelineProfile(cfg *PipelineConfig) (*PipelineProfileProvenance, bool) {
|
||||
if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedProfile == nil {
|
||||
return nil, false
|
||||
}
|
||||
selection := cfg.resolution.selectedProfile
|
||||
return &PipelineProfileProvenance{Name: selection.name, Source: selection.source, OverlayPath: selection.overlayPath}, true
|
||||
}
|
||||
|
||||
// EffectivePipelineDigest reports the deterministic secret-free digest for a
|
||||
// resolved pipeline.
|
||||
func EffectivePipelineDigest(cfg *PipelineConfig) string {
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.resolution.effectiveDigest
|
||||
}
|
||||
|
||||
func finalizePipelineResolution(cfg *PipelineConfig) error {
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
return fmt.Errorf("pipeline resolution metadata is required")
|
||||
}
|
||||
declared := make(map[string][]string, len(cfg.resolution.ownership))
|
||||
for _, ownership := range cfg.resolution.ownership {
|
||||
declared[ownership.path] = append([]string(nil), ownership.sources...)
|
||||
}
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize normalized effective pipeline: %w", err)
|
||||
}
|
||||
document, err := parseCompositionBytes("normalized effective pipeline", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records, err := document.semanticRecords()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ownership := make([]pipelineFieldOwnership, 0, len(records))
|
||||
for _, record := range records {
|
||||
sources := declared[record.Path]
|
||||
if len(sources) == 0 {
|
||||
sources = []string{pipelineDefaultOwnershipSource}
|
||||
}
|
||||
ownership = append(ownership, pipelineFieldOwnership{
|
||||
path: record.Path, sources: append([]string(nil), sources...),
|
||||
})
|
||||
}
|
||||
cfg.resolution.ownership = ownership
|
||||
return recomputePipelineEffectiveDigest(cfg)
|
||||
}
|
||||
|
||||
// recomputePipelineEffectiveDigest is the single package-owned hook for
|
||||
// refreshing provenance after later resolution expands concrete pipeline
|
||||
// values. Composition declarations and runtime provenance are not serialized.
|
||||
func recomputePipelineEffectiveDigest(cfg *PipelineConfig) error {
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
return fmt.Errorf("pipeline resolution metadata is required")
|
||||
}
|
||||
digestConfig := *cfg
|
||||
if cfg.Notarius != nil && cfg.resolution.logicalNotariusCaptured {
|
||||
notarius := *cfg.Notarius
|
||||
notarius.ConfigPath = cfg.resolution.logicalNotariusConfig
|
||||
notarius.WorkingDirectory = cfg.resolution.logicalNotariusWorking
|
||||
digestConfig.Notarius = ¬arius
|
||||
}
|
||||
data, err := yaml.Marshal(&digestConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serialize normalized effective pipeline: %w", err)
|
||||
}
|
||||
document, err := parseCompositionBytes("normalized effective pipeline", data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
canonical, err := document.canonicalDigestInput()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
cfg.resolution.effectiveDigest = hex.EncodeToString(digest[:])
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureLogicalNotariusPaths retains normalized user-facing path semantics
|
||||
// before runtime resolution makes relative paths depend on the checkout or
|
||||
// installation directory. Runtime paths remain absolute; provenance does not.
|
||||
func captureLogicalNotariusPaths(cfg *PipelineConfig) {
|
||||
if cfg == nil || cfg.resolution == nil || cfg.Notarius == nil {
|
||||
return
|
||||
}
|
||||
configPath := normalizeLogicalFilesystemPath(cfg.Notarius.ConfigPath)
|
||||
workingDirectory := normalizeLogicalFilesystemPath(cfg.Notarius.WorkingDirectory)
|
||||
if cfg.Notarius.Enabled && workingDirectory == "" && configPath != "" {
|
||||
workingDirectory = normalizeLogicalFilesystemPath(filepath.Dir(configPath))
|
||||
}
|
||||
cfg.resolution.logicalNotariusConfig = configPath
|
||||
cfg.resolution.logicalNotariusWorking = workingDirectory
|
||||
cfg.resolution.logicalNotariusCaptured = true
|
||||
}
|
||||
|
||||
func normalizeLogicalFilesystemPath(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(filepath.Clean(value))
|
||||
}
|
||||
418
internal/config/effective_pipeline.go
Normal file
418
internal/config/effective_pipeline.go
Normal file
@@ -0,0 +1,418 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// EffectivePipelineRootPath reports the absolute root pipeline path retained
|
||||
// while resolving cfg. It is empty for a pipeline not loaded through the
|
||||
// production loader.
|
||||
func EffectivePipelineRootPath(cfg *PipelineConfig) string {
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.resolution.rootPath
|
||||
}
|
||||
|
||||
// EffectivePipelineImports reports the ordered, absolute import paths that
|
||||
// contributed to cfg.
|
||||
func EffectivePipelineImports(cfg *PipelineConfig) []string {
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), cfg.resolution.imports...)
|
||||
}
|
||||
|
||||
// NormalizedConfigurationPath returns a clean absolute path when possible for
|
||||
// display-only configuration provenance.
|
||||
func NormalizedConfigurationPath(path string) string {
|
||||
return normalizedProvenancePath(path)
|
||||
}
|
||||
|
||||
// EffectivePipelineSourceRecord identifies one effective logical field and a
|
||||
// safe source that contributed to it. Generated values intentionally have two
|
||||
// records: their family declaration and the canonical party that supplied the
|
||||
// member-specific value.
|
||||
type EffectivePipelineSourceRecord struct {
|
||||
Path string
|
||||
Role string
|
||||
Source string
|
||||
}
|
||||
|
||||
// EffectivePipelineValueRecord identifies one normalized, secret-free
|
||||
// effective configuration value. Values use deterministic compact JSON
|
||||
// representations so command output can be compared without raw YAML layout.
|
||||
type EffectivePipelineValueRecord struct {
|
||||
Path string
|
||||
Value string
|
||||
}
|
||||
|
||||
// EffectivePipelineValues projects a fully resolved pipeline into sorted
|
||||
// logical configuration values. Mappings are flattened, while sequences remain
|
||||
// atomic values. The projection uses the same normalized effective mapping as
|
||||
// config show and therefore excludes composition and family declarations.
|
||||
func EffectivePipelineValues(cfg *PipelineConfig) ([]EffectivePipelineValueRecord, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
data, err := MarshalEffectivePipeline(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document, err := parseCompositionBytes("effective pipeline", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records, err := document.compactSemanticRecords()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := make([]EffectivePipelineValueRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
values = append(values, EffectivePipelineValueRecord{Path: record.Path, Value: record.Value})
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// EffectivePipelineSources projects pipeline ownership after defaults and
|
||||
// optional family expansion. It never includes effective values or raw secret
|
||||
// material, only logical paths and source identifiers.
|
||||
func EffectivePipelineSources(cfg *PipelineConfig, party ResolvedParty) ([]EffectivePipelineSourceRecord, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
data, err := MarshalEffectivePipeline(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document, err := parseCompositionBytes("effective pipeline", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths := effectivePipelineFieldPaths(document.root)
|
||||
owners := effectivePipelineOwners(cfg)
|
||||
families := ArtifactFamilies(cfg)
|
||||
publishCount := 0
|
||||
if cfg.Publish != nil {
|
||||
publishCount = len(cfg.Publish.Outputs)
|
||||
}
|
||||
records := make([]EffectivePipelineSourceRecord, 0, len(paths)+publishCount)
|
||||
for _, path := range paths {
|
||||
if path == "publish.outputs" {
|
||||
continue
|
||||
}
|
||||
if familyKey, ok := generatedArtifactFamilyForPath(path, families); ok {
|
||||
records = appendGeneratedArtifactSources(records, path, familyKey, families, owners, party)
|
||||
continue
|
||||
}
|
||||
records = appendPipelineOwners(records, path, owners[path], cfg)
|
||||
}
|
||||
records = appendPublishOutputSources(records, cfg, families, party, owners)
|
||||
sort.Slice(records, func(left, right int) bool {
|
||||
if records[left].Path != records[right].Path {
|
||||
return records[left].Path < records[right].Path
|
||||
}
|
||||
if records[left].Role != records[right].Role {
|
||||
return records[left].Role < records[right].Role
|
||||
}
|
||||
return records[left].Source < records[right].Source
|
||||
})
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// EffectiveCampaignSources projects selected campaign and party ownership
|
||||
// without retaining or rendering campaign values. Canonical players are a
|
||||
// derived party value, while a legacy players file remains explicitly marked
|
||||
// as legacy input provenance.
|
||||
func EffectiveCampaignSources(campaignPath string, campaign *CampaignConfig, party ResolvedParty) []EffectivePipelineSourceRecord {
|
||||
if campaign == nil {
|
||||
return nil
|
||||
}
|
||||
campaignSource := normalizedProvenancePath(campaignPath)
|
||||
records := []EffectivePipelineSourceRecord{
|
||||
{Path: "campaign.campaign_id", Role: "campaign", Source: campaignSource},
|
||||
{Path: "campaign.inputs.autocorrect_file", Role: "campaign", Source: campaignSource},
|
||||
{Path: "campaign.inputs.glossary_file", Role: "campaign", Source: campaignSource},
|
||||
{Path: "campaign.inputs.speakers_file", Role: "campaign", Source: campaignSource},
|
||||
}
|
||||
if strings.TrimSpace(campaign.Inputs.SpellCatalogFile) != "" {
|
||||
records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.spell_catalog_file", Role: "campaign", Source: campaignSource})
|
||||
}
|
||||
if party.Mode == PartyModeCanonical {
|
||||
partySource := normalizedProvenancePath(party.Source.Path)
|
||||
records = append(records,
|
||||
EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource},
|
||||
EffectivePipelineSourceRecord{Path: "derived.players", Role: "party", Source: partySource},
|
||||
)
|
||||
} else {
|
||||
partySource := normalizedProvenancePath(party.Source.Path)
|
||||
records = append(records, EffectivePipelineSourceRecord{Path: "campaign.inputs.party_file", Role: "party", Source: partySource})
|
||||
if strings.TrimSpace(campaign.Inputs.PlayersFile) != "" {
|
||||
records = append(records, EffectivePipelineSourceRecord{
|
||||
Path: "campaign.inputs.players_file", Role: "legacy_player", Source: campaignInputSourcePath(campaignPath, campaign.Inputs.PlayersFile),
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(records, func(left, right int) bool {
|
||||
if records[left].Path != records[right].Path {
|
||||
return records[left].Path < records[right].Path
|
||||
}
|
||||
if records[left].Role != records[right].Role {
|
||||
return records[left].Role < records[right].Role
|
||||
}
|
||||
return records[left].Source < records[right].Source
|
||||
})
|
||||
return records
|
||||
}
|
||||
|
||||
// MarshalEffectivePipeline renders the validated, normalized pipeline as one
|
||||
// deterministic YAML document. Composition declarations and runtime-only
|
||||
// resolution data are excluded. Artifact family declarations are also omitted
|
||||
// because a resolved pipeline exposes their concrete artifacts instead.
|
||||
func MarshalEffectivePipeline(cfg *PipelineConfig) ([]byte, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serialize effective pipeline: %w", err)
|
||||
}
|
||||
document, err := parseCompositionBytes("effective pipeline", data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
removeResolutionOnlyPipelineFields(document)
|
||||
return document.canonicalYAML()
|
||||
}
|
||||
|
||||
func removeResolutionOnlyPipelineFields(document *compositionDocument) {
|
||||
if document == nil || document.root == nil {
|
||||
return
|
||||
}
|
||||
scriptoriumIndex := compositionFieldIndex(document.root.fields, "scriptorium")
|
||||
if scriptoriumIndex < 0 {
|
||||
return
|
||||
}
|
||||
scriptorium := document.root.fields[scriptoriumIndex].value
|
||||
if scriptorium == nil || scriptorium.kind != yaml.MappingNode {
|
||||
return
|
||||
}
|
||||
familyIndex := compositionFieldIndex(scriptorium.fields, "artifact_families")
|
||||
if familyIndex < 0 {
|
||||
return
|
||||
}
|
||||
scriptorium.fields = append(scriptorium.fields[:familyIndex], scriptorium.fields[familyIndex+1:]...)
|
||||
for index := range scriptorium.fields {
|
||||
scriptorium.fields[index].order = index
|
||||
}
|
||||
}
|
||||
|
||||
func effectivePipelineFieldPaths(node *compositionNode) []string {
|
||||
var paths []string
|
||||
var visit func(*compositionNode)
|
||||
visit = func(current *compositionNode) {
|
||||
if current == nil {
|
||||
return
|
||||
}
|
||||
if current.kind == yaml.MappingNode && len(current.fields) > 0 {
|
||||
for _, field := range current.fields {
|
||||
visit(field.value)
|
||||
}
|
||||
return
|
||||
}
|
||||
paths = append(paths, current.path)
|
||||
}
|
||||
visit(node)
|
||||
return paths
|
||||
}
|
||||
|
||||
func effectivePipelineOwners(cfg *PipelineConfig) map[string][]string {
|
||||
owners := make(map[string][]string)
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
return owners
|
||||
}
|
||||
for _, ownership := range cfg.resolution.ownership {
|
||||
owners[ownership.path] = append([]string(nil), ownership.sources...)
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
func generatedArtifactFamilyForPath(path string, families ArtifactFamilyCatalog) (string, bool) {
|
||||
const prefix = "scriptorium.artifacts."
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", false
|
||||
}
|
||||
memberPath := strings.TrimPrefix(path, prefix)
|
||||
memberKey, _, _ := strings.Cut(memberPath, ".")
|
||||
member, exists := families.Members[memberKey]
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
_, exists = families.Families[member.Family]
|
||||
return member.Family, exists
|
||||
}
|
||||
|
||||
func appendGeneratedArtifactSources(records []EffectivePipelineSourceRecord, path, familyKey string, families ArtifactFamilyCatalog, owners map[string][]string, party ResolvedParty) []EffectivePipelineSourceRecord {
|
||||
for _, source := range generatedArtifactSources(path, familyKey, families, owners) {
|
||||
role := "family"
|
||||
if source == pipelineDefaultOwnershipSource {
|
||||
role = "default"
|
||||
}
|
||||
records = append(records, EffectivePipelineSourceRecord{Path: path, Role: role, Source: normalizedProvenanceSource(source)})
|
||||
}
|
||||
if source := normalizedProvenancePath(party.Source.Path); source != "" {
|
||||
records = append(records, EffectivePipelineSourceRecord{Path: path, Role: "party", Source: source})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func generatedArtifactSources(path, familyKey string, families ArtifactFamilyCatalog, owners map[string][]string) []string {
|
||||
const artifactPrefix = "scriptorium.artifacts."
|
||||
memberPath := strings.TrimPrefix(path, artifactPrefix)
|
||||
_, suffix, _ := strings.Cut(memberPath, ".")
|
||||
familyPrefix := "scriptorium.artifact_families." + familyKey + "."
|
||||
candidates := []string{familyPrefix + suffix}
|
||||
switch {
|
||||
case suffix == "output_path":
|
||||
candidates = []string{familyPrefix + "output_path_pattern"}
|
||||
case suffix == "depends_on":
|
||||
candidates = []string{familyPrefix + "depends_on", familyPrefix + "member_dependencies"}
|
||||
case strings.HasPrefix(suffix, "vars."):
|
||||
variable := strings.TrimPrefix(suffix, "vars.")
|
||||
candidates = []string{familyPrefix + "vars." + variable, familyPrefix + "member_vars." + variable}
|
||||
}
|
||||
sources := sourcesForPipelinePaths(owners, candidates)
|
||||
if len(sources) != 0 {
|
||||
return sources
|
||||
}
|
||||
if family, ok := families.Families[familyKey]; ok && family.Source != "" {
|
||||
return []string{family.Source}
|
||||
}
|
||||
return []string{pipelineDefaultOwnershipSource}
|
||||
}
|
||||
|
||||
func sourcesForPipelinePaths(owners map[string][]string, paths []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var sources []string
|
||||
for _, path := range paths {
|
||||
for _, source := range owners[path] {
|
||||
if _, exists := seen[source]; exists {
|
||||
continue
|
||||
}
|
||||
seen[source] = struct{}{}
|
||||
sources = append(sources, source)
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func appendPipelineOwners(records []EffectivePipelineSourceRecord, path string, sources []string, cfg *PipelineConfig) []EffectivePipelineSourceRecord {
|
||||
if len(sources) == 0 {
|
||||
sources = []string{pipelineDefaultOwnershipSource}
|
||||
}
|
||||
for _, source := range sources {
|
||||
records = append(records, EffectivePipelineSourceRecord{
|
||||
Path: path, Role: pipelineSourceRole(cfg, source), Source: normalizedProvenanceSource(source),
|
||||
})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func appendPublishOutputSources(records []EffectivePipelineSourceRecord, cfg *PipelineConfig, families ArtifactFamilyCatalog, party ResolvedParty, owners map[string][]string) []EffectivePipelineSourceRecord {
|
||||
if cfg == nil || cfg.Publish == nil {
|
||||
return records
|
||||
}
|
||||
generated := make(map[string]string)
|
||||
for familyKey, family := range families.Families {
|
||||
if family.Publish == nil || !family.Publish.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, key := range family.Members {
|
||||
generated["narratio.artifact."+key] = familyKey
|
||||
}
|
||||
}
|
||||
for index, output := range cfg.Publish.Outputs {
|
||||
path := fmt.Sprintf("publish.outputs[%d]", index)
|
||||
if familyKey, ok := generated[strings.TrimSpace(output.Source)]; ok {
|
||||
records = appendGeneratedPublishSources(records, path, familyKey, families, owners, party)
|
||||
continue
|
||||
}
|
||||
records = appendPipelineOwners(records, path, owners["publish.outputs"], cfg)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func appendGeneratedPublishSources(records []EffectivePipelineSourceRecord, path, familyKey string, families ArtifactFamilyCatalog, owners map[string][]string, party ResolvedParty) []EffectivePipelineSourceRecord {
|
||||
familyPrefix := "scriptorium.artifact_families." + familyKey + ".publish."
|
||||
sources := sourcesForPipelinePaths(owners, []string{familyPrefix + "enabled", familyPrefix + "required", familyPrefix + "dest_pattern"})
|
||||
if len(sources) == 0 {
|
||||
if family, ok := families.Families[familyKey]; ok && family.Source != "" {
|
||||
sources = []string{family.Source}
|
||||
} else {
|
||||
sources = []string{pipelineDefaultOwnershipSource}
|
||||
}
|
||||
}
|
||||
for _, source := range sources {
|
||||
role := "family"
|
||||
if source == pipelineDefaultOwnershipSource {
|
||||
role = "default"
|
||||
}
|
||||
records = append(records, EffectivePipelineSourceRecord{Path: path, Role: role, Source: normalizedProvenanceSource(source)})
|
||||
}
|
||||
if source := normalizedProvenancePath(party.Source.Path); source != "" {
|
||||
records = append(records, EffectivePipelineSourceRecord{Path: path, Role: "party", Source: source})
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func pipelineSourceRole(cfg *PipelineConfig, source string) string {
|
||||
if source == pipelineDefaultOwnershipSource {
|
||||
return "default"
|
||||
}
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
return "pipeline"
|
||||
}
|
||||
if source == cfg.resolution.rootPath {
|
||||
return "root"
|
||||
}
|
||||
if cfg.resolution.selectedProfile != nil && source == cfg.resolution.selectedProfile.overlayPath {
|
||||
return "profile"
|
||||
}
|
||||
for _, imported := range cfg.resolution.imports {
|
||||
if source == imported {
|
||||
return "import"
|
||||
}
|
||||
}
|
||||
return "pipeline"
|
||||
}
|
||||
|
||||
func normalizedProvenanceSource(source string) string {
|
||||
if source == pipelineDefaultOwnershipSource {
|
||||
return source
|
||||
}
|
||||
return normalizedProvenancePath(source)
|
||||
}
|
||||
|
||||
func normalizedProvenancePath(path string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ""
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
return filepath.Clean(abs)
|
||||
}
|
||||
|
||||
func campaignInputSourcePath(campaignPath, configuredPath string) string {
|
||||
if filepath.IsAbs(configuredPath) {
|
||||
return normalizedProvenancePath(configuredPath)
|
||||
}
|
||||
return normalizedProvenancePath(filepath.Join(filepath.Dir(campaignPath), configuredPath))
|
||||
}
|
||||
@@ -14,15 +14,96 @@ import (
|
||||
|
||||
// LoadPipeline loads pipeline configuration from a YAML file with strict field checking.
|
||||
func LoadPipeline(path string) (*PipelineConfig, error) {
|
||||
var cfg PipelineConfig
|
||||
if err := decodeStrictYAML("pipeline", path, &cfg); err != nil {
|
||||
return LoadPipelineWithOptions(path, PipelineLoadOptions{})
|
||||
}
|
||||
|
||||
// PipelineLoadOptions carries an optional explicit profile selection. A nil
|
||||
// Profile means the caller omitted selection; a non-nil empty value is an
|
||||
// explicit invalid selection.
|
||||
type PipelineLoadOptions struct {
|
||||
Profile *string
|
||||
}
|
||||
|
||||
// LoadPipelineWithOptions loads pipeline configuration with strict field
|
||||
// checking and optional named-profile selection.
|
||||
func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineConfig, error) {
|
||||
sources, err := loadPipelineCompositionSources(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load pipeline config: %w", err)
|
||||
}
|
||||
applyPipelineDefaults(&cfg)
|
||||
if err := resolveNotariusPaths(&cfg, path); err != nil {
|
||||
if _, err := selectPipelineProfile(sources.envelope, opts); err != nil {
|
||||
return nil, fmt.Errorf("load pipeline config: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
if err := sources.loadOverlays(); err != nil {
|
||||
return nil, fmt.Errorf("load pipeline config: %w", err)
|
||||
}
|
||||
cfg, err := sources.resolve(opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load pipeline config: %w", err)
|
||||
}
|
||||
return finalizeLoadedPipeline(path, cfg)
|
||||
}
|
||||
|
||||
// LoadPipelineProfilePair resolves two explicit named profiles from one parsed
|
||||
// pipeline root and its declared source set. Each result is independently
|
||||
// decoded, defaulted, and finalized so later resolution can safely mutate one
|
||||
// effective pipeline without affecting the other.
|
||||
func LoadPipelineProfilePair(path, leftProfile, rightProfile string) (*PipelineConfig, *PipelineConfig, error) {
|
||||
if _, err := normalizePipelineProfileName(leftProfile, "left profile selection"); err != nil {
|
||||
return nil, nil, fmt.Errorf("load left pipeline profile: %w", err)
|
||||
}
|
||||
if _, err := normalizePipelineProfileName(rightProfile, "right profile selection"); err != nil {
|
||||
return nil, nil, fmt.Errorf("load right pipeline profile: %w", err)
|
||||
}
|
||||
if leftProfile == rightProfile {
|
||||
return nil, nil, fmt.Errorf("load pipeline profiles: left and right profile selections must differ")
|
||||
}
|
||||
sources, err := loadPipelineCompositionSources(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load pipeline config: %w", err)
|
||||
}
|
||||
leftOptions := PipelineLoadOptions{Profile: &leftProfile}
|
||||
rightOptions := PipelineLoadOptions{Profile: &rightProfile}
|
||||
if _, err := selectPipelineProfile(sources.envelope, leftOptions); err != nil {
|
||||
return nil, nil, fmt.Errorf("load left pipeline profile: %w", err)
|
||||
}
|
||||
if _, err := selectPipelineProfile(sources.envelope, rightOptions); err != nil {
|
||||
return nil, nil, fmt.Errorf("load right pipeline profile: %w", err)
|
||||
}
|
||||
if err := sources.loadOverlays(); err != nil {
|
||||
return nil, nil, fmt.Errorf("load pipeline config: %w", err)
|
||||
}
|
||||
left, err := sources.resolve(leftOptions)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load left pipeline profile: %w", err)
|
||||
}
|
||||
right, err := sources.resolve(rightOptions)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load right pipeline profile: %w", err)
|
||||
}
|
||||
left, err = finalizeLoadedPipeline(path, left)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
right, err = finalizeLoadedPipeline(path, right)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return left, right, nil
|
||||
}
|
||||
|
||||
func finalizeLoadedPipeline(path string, cfg *PipelineConfig) (*PipelineConfig, error) {
|
||||
cfg.resolution.publishDeclared = cfg.Publish != nil
|
||||
applyPipelineDefaults(cfg)
|
||||
captureLogicalNotariusPaths(cfg)
|
||||
if err := resolveNotariusPaths(cfg, path); err != nil {
|
||||
return nil, fmt.Errorf("load pipeline config: %w", err)
|
||||
}
|
||||
if err := finalizePipelineResolution(cfg); err != nil {
|
||||
return nil, fmt.Errorf("load pipeline config: %w", err)
|
||||
}
|
||||
retainArtifactFamilyDeclarations(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// LoadCampaign loads campaign configuration from a YAML file with strict field checking.
|
||||
@@ -43,6 +124,7 @@ func LoadSession(path string) (*SessionConfig, error) {
|
||||
type SessionLoadOptions struct {
|
||||
SessionID string
|
||||
PreviousSessionID string
|
||||
Profile *string
|
||||
}
|
||||
|
||||
// LoadSessionWithOptions loads session configuration from a YAML file with
|
||||
@@ -141,7 +223,7 @@ func Load(pipelinePath string, paths ...string) (*Config, error) {
|
||||
// LoadWithSessionOptions loads and resolves combined pipeline, campaign, and
|
||||
// session configuration with expected session identity checks.
|
||||
func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
|
||||
pipelineCfg, err := LoadPipeline(pipelinePath)
|
||||
pipelineCfg, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Profile: sessionOpts.Profile})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -150,42 +232,168 @@ func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sess
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return LoadSessionWithPipelineCampaignOptions(loaded, sessionPath, sessionOpts)
|
||||
}
|
||||
|
||||
// LoadedPipelineCampaign retains one already loaded pipeline and campaign for
|
||||
// subsequent local or remote session resolution. It prevents a command from
|
||||
// reloading the root pipeline after campaign selection.
|
||||
type LoadedPipelineCampaign struct {
|
||||
PipelinePath string
|
||||
Pipeline *PipelineConfig
|
||||
CampaignPath string
|
||||
Campaign *CampaignConfig
|
||||
Party ResolvedParty
|
||||
}
|
||||
|
||||
// LoadPipelineCampaign combines already loaded pipeline and campaign documents
|
||||
// with their campaign-owned party source. Callers that later resolve a session
|
||||
// retain this context rather than independently reimplementing party loading.
|
||||
func LoadPipelineCampaign(pipelinePath string, pipeline *PipelineConfig, campaignPath string, campaign *CampaignConfig) (LoadedPipelineCampaign, error) {
|
||||
if pipeline == nil {
|
||||
return LoadedPipelineCampaign{}, fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
if campaign == nil {
|
||||
return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required")
|
||||
}
|
||||
party, err := resolveCampaignParty(campaignPath, campaign)
|
||||
if err != nil {
|
||||
return LoadedPipelineCampaign{}, err
|
||||
}
|
||||
return LoadPipelineCampaignWithParty(pipelinePath, pipeline, campaignPath, campaign, party)
|
||||
}
|
||||
|
||||
// LoadPipelineCampaignWithParty combines an already loaded pipeline and
|
||||
// campaign with one already resolved campaign-owned party. It is useful when
|
||||
// more than one independently resolved pipeline must be expanded against the
|
||||
// exact same party document.
|
||||
func LoadPipelineCampaignWithParty(pipelinePath string, pipeline *PipelineConfig, campaignPath string, campaign *CampaignConfig, party ResolvedParty) (LoadedPipelineCampaign, error) {
|
||||
if pipeline == nil {
|
||||
return LoadedPipelineCampaign{}, fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
if campaign == nil {
|
||||
return LoadedPipelineCampaign{}, fmt.Errorf("campaign config is required")
|
||||
}
|
||||
if party.Mode == "" {
|
||||
return LoadedPipelineCampaign{}, fmt.Errorf("resolved campaign party is required")
|
||||
}
|
||||
if party.Mode == PartyModeCanonical {
|
||||
if err := validateCanonicalPartySelection(campaign, nil); err != nil {
|
||||
return LoadedPipelineCampaign{}, err
|
||||
}
|
||||
}
|
||||
if err := expandPipelineArtifactFamilies(pipeline, party); err != nil {
|
||||
return LoadedPipelineCampaign{}, err
|
||||
}
|
||||
return LoadedPipelineCampaign{
|
||||
PipelinePath: pipelinePath,
|
||||
Pipeline: pipeline,
|
||||
CampaignPath: campaignPath,
|
||||
Campaign: campaign,
|
||||
Party: party,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadSessionWithPipelineCampaignOptions loads one local session and combines
|
||||
// it with an already loaded pipeline and campaign.
|
||||
func LoadSessionWithPipelineCampaignOptions(loaded LoadedPipelineCampaign, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
|
||||
sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return Resolve(pipelinePath, pipelineCfg, campaignPath, campaignCfg, sessionPath, sessionCfg, SessionSource{
|
||||
return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, SessionSource{
|
||||
Source: "session_config",
|
||||
LocalPath: sessionPath,
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve builds final stage-facing configuration from already loaded
|
||||
// pipeline, campaign, and session documents.
|
||||
func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath string, campaignCfg *CampaignConfig, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
|
||||
stableInputs, err := mergeCampaignSession(campaignCfg, sessionCfg, campaignPath, sessionPath)
|
||||
// ResolveLoadedPipelineCampaign combines an already loaded pipeline and
|
||||
// campaign with optional already loaded session data. A nil session preserves
|
||||
// the resolved pipeline/campaign context for callers that need to locate or
|
||||
// retrieve a session without rereading the root pipeline.
|
||||
func ResolveLoadedPipelineCampaign(loaded LoadedPipelineCampaign, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
|
||||
if loaded.Pipeline == nil {
|
||||
return nil, fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
if loaded.Campaign == nil {
|
||||
return nil, fmt.Errorf("campaign config is required")
|
||||
}
|
||||
if loaded.Party.Mode == "" {
|
||||
party, err := resolveCampaignParty(loaded.CampaignPath, loaded.Campaign)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(sessionSource.Source) == "" {
|
||||
sessionSource.Source = "session_config"
|
||||
loaded.Party = party
|
||||
}
|
||||
if strings.TrimSpace(sessionSource.LocalPath) == "" {
|
||||
sessionSource.LocalPath = sessionPath
|
||||
if loaded.Party.Mode == PartyModeCanonical {
|
||||
if err := validateCanonicalPartySelection(loaded.Campaign, nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cfg := &Config{
|
||||
Pipeline: loaded.Pipeline,
|
||||
Campaign: loaded.Campaign,
|
||||
Session: sessionCfg,
|
||||
PipelinePath: loaded.PipelinePath,
|
||||
CampaignPath: loaded.CampaignPath,
|
||||
SessionPath: sessionPath,
|
||||
SessionSource: sessionSource,
|
||||
Party: loaded.Party,
|
||||
}
|
||||
if cfg.Party.Mode == PartyModeCanonical && sessionCfg != nil {
|
||||
if err := validateCanonicalPartySelection(loaded.Campaign, sessionCfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if sessionCfg == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
return &Config{
|
||||
Pipeline: pipelineCfg,
|
||||
Campaign: campaignCfg,
|
||||
Session: sessionCfg,
|
||||
PipelinePath: pipelinePath,
|
||||
CampaignPath: campaignPath,
|
||||
SessionPath: sessionPath,
|
||||
StableInputs: stableInputs,
|
||||
SessionSource: sessionSource,
|
||||
}, nil
|
||||
stableInputs, err := mergeCampaignSession(loaded.Campaign, sessionCfg, loaded.CampaignPath, sessionPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.SessionSource.Source) == "" {
|
||||
cfg.SessionSource.Source = "session_config"
|
||||
}
|
||||
if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" {
|
||||
cfg.SessionSource.LocalPath = sessionPath
|
||||
}
|
||||
if cfg.Party.Mode == PartyModeCanonical {
|
||||
stableInputs.PlayersFile = virtualPlayersInput()
|
||||
cfg.Session.Inputs.PlayersFile = ""
|
||||
} else {
|
||||
party, err := resolvePartyInput(stableInputs.PartyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if party.Mode == PartyModeCanonical {
|
||||
return nil, fmt.Errorf("session.inputs.party_file cannot select a canonical party; canonical parties are campaign-owned")
|
||||
}
|
||||
cfg.Party = party
|
||||
if strings.TrimSpace(stableInputs.PlayersFile.Path) == "" {
|
||||
return nil, fmt.Errorf("players_file is required with a legacy party")
|
||||
}
|
||||
}
|
||||
cfg.StableInputs = stableInputs
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Resolve builds final stage-facing configuration from already loaded
|
||||
// pipeline, campaign, and session documents.
|
||||
func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath string, campaignCfg *CampaignConfig, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
|
||||
if sessionCfg == nil {
|
||||
return nil, fmt.Errorf("session config is required")
|
||||
}
|
||||
loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, sessionSource)
|
||||
}
|
||||
|
||||
func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -34,6 +36,53 @@ inputs:
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarshalEffectivePipelineRendersStablePublicConfiguration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "pipeline.yml")
|
||||
if err := os.WriteFile(path, []byte(`scriptorium:
|
||||
artifacts:
|
||||
zeta:
|
||||
enabled: false
|
||||
output_path: artifacts/zeta.md
|
||||
alpha:
|
||||
enabled: false
|
||||
output_path: artifacts/alpha.md
|
||||
workspace:
|
||||
root: /srv/narratio
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first, err := LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := LoadPipeline(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstYAML, err := MarshalEffectivePipeline(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondYAML, err := MarshalEffectivePipeline(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(firstYAML) != string(secondYAML) || !strings.HasSuffix(string(firstYAML), "\n") {
|
||||
t.Fatalf("effective YAML is not stable: first=%q second=%q", firstYAML, secondYAML)
|
||||
}
|
||||
output := string(firstYAML)
|
||||
if strings.Contains(output, "artifact_families") || strings.Contains(output, "resolution") {
|
||||
t.Fatalf("effective YAML leaked runtime fields: %q", output)
|
||||
}
|
||||
if strings.Index(output, "alpha:") > strings.Index(output, "zeta:") {
|
||||
t.Fatalf("configured artifacts are not sorted: %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMissingAudioSource(t *testing.T) {
|
||||
cfg := loadedValidConfig(t)
|
||||
cfg.Session.Inputs.AudioDir = ""
|
||||
@@ -54,6 +103,15 @@ func TestValidateMissingAudioSource(t *testing.T) {
|
||||
|
||||
func TestExamplesLoadAndValidate(t *testing.T) {
|
||||
examplesDir := filepath.Join("..", "..", "examples")
|
||||
if got, want := maintainedPipelineRoots(t, examplesDir), []string{
|
||||
"pipeline.extraction-subset.yml",
|
||||
"pipeline.full.annotated.yml",
|
||||
"pipeline.minimal.yml",
|
||||
"pipeline.production.yml",
|
||||
filepath.Join("production-testing", "pipeline.yml"),
|
||||
}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("maintained pipeline roots = %#v, want %#v", got, want)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
pipelineFile string
|
||||
@@ -96,6 +154,108 @@ func TestExamplesLoadAndValidate(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("split production and testing bundle", func(t *testing.T) {
|
||||
pipelinePath := filepath.Join(examplesDir, "production-testing", "pipeline.yml")
|
||||
campaignPath := filepath.Join(examplesDir, "campaigns", "sample-campaign", "campaign.yml")
|
||||
sessionPath := filepath.Join(examplesDir, "session.local-audio.yml")
|
||||
campaign, err := LoadCampaign(campaignPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load split bundle campaign: %v", err)
|
||||
}
|
||||
for _, profile := range []struct {
|
||||
name string
|
||||
model string
|
||||
}{
|
||||
{name: "production", model: "narratio-production-model-placeholder"},
|
||||
{name: "testing", model: "narratio-testing-model-placeholder"},
|
||||
} {
|
||||
t.Run(profile.name, func(t *testing.T) {
|
||||
selected := profile.name
|
||||
pipeline, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Profile: &selected})
|
||||
if err != nil {
|
||||
t.Fatalf("load split bundle pipeline: %v", err)
|
||||
}
|
||||
loaded, err := LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve split bundle campaign: %v", err)
|
||||
}
|
||||
cfg, err := LoadSessionWithPipelineCampaignOptions(loaded, sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("load split bundle session: %v", err)
|
||||
}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("validate split bundle: %v", err)
|
||||
}
|
||||
if cfg.Pipeline.Audita.Model != profile.model {
|
||||
t.Fatalf("audita model = %q, want %q", cfg.Pipeline.Audita.Model, profile.model)
|
||||
}
|
||||
if cfg.Pipeline.Secrets != nil || cfg.Pipeline.Audita.LLMAPIKeyEnv != "" || cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil {
|
||||
t.Fatalf("split bundle must remain offline and secret-free: secrets=%#v audita=%#v storage=%#v", cfg.Pipeline.Secrets, cfg.Pipeline.Audita, cfg.Pipeline.Storage)
|
||||
}
|
||||
if selectedProfile, ok := SelectedPipelineProfile(cfg.Pipeline); !ok || selectedProfile.Name != profile.name {
|
||||
t.Fatalf("selected profile = %#v, want %q", selectedProfile, profile.name)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"character_meta_arannis",
|
||||
"character_meta_brenna",
|
||||
"character_items_arannis",
|
||||
"character_items_brenna",
|
||||
} {
|
||||
if _, exists := cfg.Pipeline.Scriptorium.Artifacts[key]; !exists {
|
||||
t.Fatalf("expanded artifact %q is absent", key)
|
||||
}
|
||||
}
|
||||
if got := cfg.Pipeline.Scriptorium.Artifacts["character_items_arannis"].Inputs["character_meta"].Source; got != "narratio.artifact.character_meta_arannis" {
|
||||
t.Fatalf("same-member input source = %q", got)
|
||||
}
|
||||
if cfg.Party.Mode != PartyModeCanonical || cfg.Party.Canonical == nil || len(cfg.Party.Canonical.Characters) != 2 {
|
||||
t.Fatalf("canonical party = %#v", cfg.Party)
|
||||
}
|
||||
characters := cfg.Party.Canonical.Characters
|
||||
if characters[0].Player.Name != characters[1].Player.Name || len(characters[0].Character.Aliases) != 2 || len(characters[1].Character.Classes) != 2 {
|
||||
t.Fatalf("canonical party does not preserve repeated player, aliases, and multiclass data: %#v", characters)
|
||||
}
|
||||
if len(cfg.Pipeline.Publish.Outputs) != 3 {
|
||||
t.Fatalf("publish outputs = %#v, want transcript plus two family outputs", cfg.Pipeline.Publish.Outputs)
|
||||
}
|
||||
if profile.name == "testing" {
|
||||
artifact, exists := cfg.Pipeline.Scriptorium.Artifacts["testing_notes"]
|
||||
if !exists || artifact.Enabled {
|
||||
t.Fatalf("testing-only disabled artifact = %#v", artifact)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func maintainedPipelineRoots(t *testing.T, examplesDir string) []string {
|
||||
t.Helper()
|
||||
var roots []string
|
||||
err := filepath.WalkDir(examplesDir, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := entry.Name()
|
||||
if name != "pipeline.yml" && !(strings.HasPrefix(name, "pipeline.") && (strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml"))) {
|
||||
return nil
|
||||
}
|
||||
relative, err := filepath.Rel(examplesDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roots = append(roots, relative)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("discover maintained pipeline roots: %v", err)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
func TestMaintainedExtractionExamplesPreservePublishedContracts(t *testing.T) {
|
||||
@@ -176,6 +336,9 @@ inputs:
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "party.yml"), []byte("legacy: party\n"), 0o644); err != nil {
|
||||
t.Fatalf("write party.yml: %v", err)
|
||||
}
|
||||
|
||||
return pipelinePath, sessionPath
|
||||
}
|
||||
|
||||
@@ -100,6 +100,36 @@ notarius:
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusRelativePathsDoNotMakeEffectiveDigestLocationDependent(t *testing.T) {
|
||||
pipelineYAML := testPipelineBaseYAML + `
|
||||
notarius:
|
||||
enabled: true
|
||||
config_path: notarius/config.yml
|
||||
pipeline_id: dnd-session
|
||||
`
|
||||
paths := make([]string, 2)
|
||||
configs := make([]*PipelineConfig, 2)
|
||||
for index := range paths {
|
||||
dir := t.TempDir()
|
||||
paths[index] = filepath.Join(dir, "pipeline.yml")
|
||||
if err := os.WriteFile(paths[index], []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatalf("write pipeline %d: %v", index, err)
|
||||
}
|
||||
loaded, err := LoadPipeline(paths[index])
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPipeline(%d) error = %v", index, err)
|
||||
}
|
||||
configs[index] = loaded
|
||||
}
|
||||
if configs[0].Notarius.ConfigPath == configs[1].Notarius.ConfigPath ||
|
||||
configs[0].Notarius.WorkingDirectory == configs[1].Notarius.WorkingDirectory {
|
||||
t.Fatalf("runtime Notarius paths should remain location-specific: %#v / %#v", configs[0].Notarius, configs[1].Notarius)
|
||||
}
|
||||
if first, second := EffectivePipelineDigest(configs[0]), EffectivePipelineDigest(configs[1]); first == "" || first != second {
|
||||
t.Fatalf("relocated logical configuration digests = %q / %q, want equal non-empty values", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotariusStrictYAML(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -117,8 +147,13 @@ func TestNotariusStrictYAML(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(testPipelineBaseYAML+"\n"+tt.yaml), 0o644); err != nil {
|
||||
t.Fatalf("write pipeline: %v", err)
|
||||
}
|
||||
if _, err := LoadPipeline(path); err == nil || !strings.Contains(err.Error(), "strict decode failed") {
|
||||
t.Fatalf("LoadPipeline() error = %v, want strict decode failure", err)
|
||||
_, err := LoadPipeline(path)
|
||||
want := "strict decode failed"
|
||||
if tt.name == "duplicate reference selector" {
|
||||
want = "duplicate YAML key"
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("LoadPipeline() error = %v, want containing %q", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
494
internal/config/party.go
Normal file
494
internal/config/party.go
Normal file
@@ -0,0 +1,494 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
// PartySchemaVersion identifies the canonical campaign party document.
|
||||
PartySchemaVersion = "narratio.party.v1"
|
||||
// PlayersSchemaVersion identifies the derived players-only document.
|
||||
PlayersSchemaVersion = "narratio.players.v1"
|
||||
)
|
||||
|
||||
// PartyMode records whether a source document uses the canonical party
|
||||
// contract or the bounded compatibility path for older opaque files.
|
||||
type PartyMode string
|
||||
|
||||
const (
|
||||
PartyModeCanonical PartyMode = "canonical"
|
||||
PartyModeLegacy PartyMode = "legacy"
|
||||
)
|
||||
|
||||
// PartyDocument classifies a party input. Canonical is populated only for a
|
||||
// versioned canonical document; legacy contents intentionally remain opaque.
|
||||
type PartyDocument struct {
|
||||
Mode PartyMode
|
||||
Canonical *CanonicalParty
|
||||
}
|
||||
|
||||
// IsCanonical reports whether the document has validated canonical data.
|
||||
func (d *PartyDocument) IsCanonical() bool {
|
||||
return d != nil && d.Mode == PartyModeCanonical && d.Canonical != nil
|
||||
}
|
||||
|
||||
// PlayersYAML renders the derived players projection for a canonical document.
|
||||
func (d *PartyDocument) PlayersYAML() ([]byte, error) {
|
||||
if !d.IsCanonical() {
|
||||
return nil, fmt.Errorf("canonical party is required")
|
||||
}
|
||||
return d.Canonical.PlayersYAML()
|
||||
}
|
||||
|
||||
// CanonicalParty is the normalized domain value for narratio.party.v1. Raw is
|
||||
// retained independently so later preparation can copy the authored party file
|
||||
// byte-for-byte rather than serializing normalized values.
|
||||
type CanonicalParty struct {
|
||||
Raw []byte
|
||||
Characters []PartyCharacter
|
||||
}
|
||||
|
||||
// PartyCharacter is one ordered character declaration from a canonical party.
|
||||
type PartyCharacter struct {
|
||||
ID string
|
||||
Player PartyPlayer
|
||||
Character PartyCharacterDetails
|
||||
}
|
||||
|
||||
// PartyPlayer identifies the player controlling a character.
|
||||
type PartyPlayer struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// PartyCharacterDetails contains the player-facing character details.
|
||||
type PartyCharacterDetails struct {
|
||||
Name string
|
||||
Aliases []string
|
||||
Classes []PartyClass
|
||||
}
|
||||
|
||||
// PartyClass represents one declared class. A missing Level is distinct from
|
||||
// an explicitly supplied level.
|
||||
type PartyClass struct {
|
||||
Name string
|
||||
Level *int
|
||||
}
|
||||
|
||||
// ParseParty classifies and, when selected by schema_version, strictly parses
|
||||
// a party source. An unversioned source takes the deliberately opaque legacy
|
||||
// compatibility path; see party_legacy.go.
|
||||
func ParseParty(data []byte) (*PartyDocument, error) {
|
||||
root, decoder, err := parsePartyFirstDocument(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !partyDocumentSelectsCanonical(root) {
|
||||
return classifyLegacyPartyDocument(), nil
|
||||
}
|
||||
if err := requireNoTrailingPartyDocuments(decoder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
party, err := parseCanonicalParty(root, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PartyDocument{Mode: PartyModeCanonical, Canonical: party}, nil
|
||||
}
|
||||
|
||||
func parsePartyFirstDocument(data []byte) (*yaml.Node, *yaml.Decoder, error) {
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
||||
var document yaml.Node
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
if err == io.EOF {
|
||||
return nil, nil, fmt.Errorf("party YAML document is empty")
|
||||
}
|
||||
return nil, nil, fmt.Errorf("decode party YAML: %w", err)
|
||||
}
|
||||
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 {
|
||||
return nil, nil, fmt.Errorf("party must contain one YAML document")
|
||||
}
|
||||
return document.Content[0], decoder, nil
|
||||
}
|
||||
|
||||
func partyDocumentSelectsCanonical(root *yaml.Node) bool {
|
||||
if root == nil || root.Kind != yaml.MappingNode {
|
||||
return false
|
||||
}
|
||||
for index := 0; index+1 < len(root.Content); index += 2 {
|
||||
key := root.Content[index]
|
||||
if key.Kind == yaml.ScalarNode && key.Tag == "!!str" && key.Value == "schema_version" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func requireNoTrailingPartyDocuments(decoder *yaml.Decoder) error {
|
||||
var trailing yaml.Node
|
||||
if err := decoder.Decode(&trailing); err == nil {
|
||||
return fmt.Errorf("canonical party must contain exactly one YAML document")
|
||||
} else if err != io.EOF {
|
||||
return fmt.Errorf("decode trailing canonical party YAML: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCanonicalParty(root *yaml.Node, raw []byte) (*CanonicalParty, error) {
|
||||
fields, err := partyMappingFields(root, "party")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := partyKnownFields(fields, "party", "schema_version", "characters"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
version, err := partyRequiredString(fields, "schema_version", "party")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if version != PartySchemaVersion {
|
||||
return nil, fmt.Errorf("party.schema_version %q is unsupported", version)
|
||||
}
|
||||
charactersNode, ok := fields["characters"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("party.characters is required")
|
||||
}
|
||||
charactersFields, err := partyMappingFields(charactersNode, "party.characters")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(charactersFields) == 0 {
|
||||
return nil, fmt.Errorf("party.characters must be non-empty")
|
||||
}
|
||||
|
||||
party := &CanonicalParty{Raw: append([]byte(nil), raw...)}
|
||||
seenNames := make([]string, 0, len(charactersFields))
|
||||
for _, id := range partyMappingOrder(charactersNode) {
|
||||
entry, err := parsePartyCharacter(id, charactersFields[id], seenNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
party.Characters = append(party.Characters, entry)
|
||||
seenNames = append(seenNames, entry.Character.Name)
|
||||
seenNames = append(seenNames, entry.Character.Aliases...)
|
||||
}
|
||||
return party, nil
|
||||
}
|
||||
|
||||
func parsePartyCharacter(id string, node *yaml.Node, seenNames []string) (PartyCharacter, error) {
|
||||
path := "party.characters." + id
|
||||
if id != strings.TrimSpace(id) || !artifactpolicy.IsConfiguredKey(id) {
|
||||
return PartyCharacter{}, fmt.Errorf("%s has invalid character id %q", path, id)
|
||||
}
|
||||
fields, err := partyMappingFields(node, path)
|
||||
if err != nil {
|
||||
return PartyCharacter{}, err
|
||||
}
|
||||
if err := partyKnownFields(fields, path, "player", "character"); err != nil {
|
||||
return PartyCharacter{}, err
|
||||
}
|
||||
player, err := parsePartyPlayer(fields["player"], path+".player")
|
||||
if err != nil {
|
||||
return PartyCharacter{}, err
|
||||
}
|
||||
character, err := parsePartyCharacterDetails(fields["character"], path+".character")
|
||||
if err != nil {
|
||||
return PartyCharacter{}, err
|
||||
}
|
||||
if partyNameAmbiguous(character.Name, seenNames) {
|
||||
return PartyCharacter{}, fmt.Errorf("%s name or alias %q is ambiguous", path, character.Name)
|
||||
}
|
||||
visibleNames := append(append([]string(nil), seenNames...), character.Name)
|
||||
for _, name := range character.Aliases {
|
||||
if partyNameAmbiguous(name, visibleNames) {
|
||||
return PartyCharacter{}, fmt.Errorf("%s name or alias %q is ambiguous", path, name)
|
||||
}
|
||||
visibleNames = append(visibleNames, name)
|
||||
}
|
||||
return PartyCharacter{ID: id, Player: player, Character: character}, nil
|
||||
}
|
||||
|
||||
func parsePartyPlayer(node *yaml.Node, path string) (PartyPlayer, error) {
|
||||
fields, err := partyMappingFields(node, path)
|
||||
if err != nil {
|
||||
return PartyPlayer{}, err
|
||||
}
|
||||
if err := partyKnownFields(fields, path, "name"); err != nil {
|
||||
return PartyPlayer{}, err
|
||||
}
|
||||
name, err := partyRequiredDisplayString(fields, "name", path)
|
||||
if err != nil {
|
||||
return PartyPlayer{}, err
|
||||
}
|
||||
return PartyPlayer{Name: name}, nil
|
||||
}
|
||||
|
||||
func parsePartyCharacterDetails(node *yaml.Node, path string) (PartyCharacterDetails, error) {
|
||||
fields, err := partyMappingFields(node, path)
|
||||
if err != nil {
|
||||
return PartyCharacterDetails{}, err
|
||||
}
|
||||
if err := partyKnownFields(fields, path, "name", "alias", "classes"); err != nil {
|
||||
return PartyCharacterDetails{}, err
|
||||
}
|
||||
name, err := partyRequiredDisplayString(fields, "name", path)
|
||||
if err != nil {
|
||||
return PartyCharacterDetails{}, err
|
||||
}
|
||||
aliases, err := partyAliases(fields["alias"], path+".alias")
|
||||
if err != nil {
|
||||
return PartyCharacterDetails{}, err
|
||||
}
|
||||
classes, err := partyClasses(fields["classes"], path+".classes")
|
||||
if err != nil {
|
||||
return PartyCharacterDetails{}, err
|
||||
}
|
||||
return PartyCharacterDetails{Name: name, Aliases: aliases, Classes: classes}, nil
|
||||
}
|
||||
|
||||
func partyAliases(node *yaml.Node, path string) ([]string, error) {
|
||||
if node == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if node.Kind != yaml.SequenceNode {
|
||||
return nil, fmt.Errorf("%s must be a list", path)
|
||||
}
|
||||
aliases := make([]string, 0, len(node.Content))
|
||||
for index, item := range node.Content {
|
||||
alias, err := partyDisplayString(item, fmt.Sprintf("%s[%d]", path, index))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aliases = append(aliases, alias)
|
||||
}
|
||||
return aliases, nil
|
||||
}
|
||||
|
||||
func partyClasses(node *yaml.Node, path string) ([]PartyClass, error) {
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("%s is required", path)
|
||||
}
|
||||
if node.Kind != yaml.SequenceNode || len(node.Content) == 0 {
|
||||
return nil, fmt.Errorf("%s must be a non-empty list", path)
|
||||
}
|
||||
classes := make([]PartyClass, 0, len(node.Content))
|
||||
classNames := make([]string, 0, len(node.Content))
|
||||
for index, item := range node.Content {
|
||||
classPath := fmt.Sprintf("%s[%d]", path, index)
|
||||
fields, err := partyMappingFields(item, classPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := partyKnownFields(fields, classPath, "name", "level"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name, err := partyRequiredDisplayString(fields, "name", classPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if partyNameAmbiguous(name, classNames) {
|
||||
return nil, fmt.Errorf("%s has duplicate class %q", path, name)
|
||||
}
|
||||
classNames = append(classNames, name)
|
||||
level, err := partyLevel(fields["level"], classPath+".level")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
classes = append(classes, PartyClass{Name: name, Level: level})
|
||||
}
|
||||
return classes, nil
|
||||
}
|
||||
|
||||
func partyLevel(node *yaml.Node, path string) (*int, error) {
|
||||
if node == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if node.Kind != yaml.ScalarNode || node.Tag != "!!int" {
|
||||
return nil, fmt.Errorf("%s must be a positive integer", path)
|
||||
}
|
||||
level, err := strconv.Atoi(node.Value)
|
||||
if err != nil || level <= 0 {
|
||||
return nil, fmt.Errorf("%s must be a positive integer", path)
|
||||
}
|
||||
return &level, nil
|
||||
}
|
||||
|
||||
func partyRequiredDisplayString(fields map[string]*yaml.Node, key, path string) (string, error) {
|
||||
node, ok := fields[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s.%s is required", path, key)
|
||||
}
|
||||
return partyDisplayString(node, path+"."+key)
|
||||
}
|
||||
|
||||
func partyRequiredString(fields map[string]*yaml.Node, key, path string) (string, error) {
|
||||
node, ok := fields[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s.%s is required", path, key)
|
||||
}
|
||||
if node.Kind != yaml.ScalarNode || node.Tag != "!!str" || node.Value == "" {
|
||||
return "", fmt.Errorf("%s.%s must be a non-empty string", path, key)
|
||||
}
|
||||
return node.Value, nil
|
||||
}
|
||||
|
||||
func partyDisplayString(node *yaml.Node, path string) (string, error) {
|
||||
if node == nil || node.Kind != yaml.ScalarNode || node.Tag != "!!str" {
|
||||
return "", fmt.Errorf("%s must be a display string", path)
|
||||
}
|
||||
value := node.Value
|
||||
if value == "" || strings.TrimSpace(value) != value {
|
||||
return "", fmt.Errorf("%s must be non-empty and trimmed", path)
|
||||
}
|
||||
for _, runeValue := range value {
|
||||
if unicode.IsControl(runeValue) {
|
||||
return "", fmt.Errorf("%s must not contain control characters", path)
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func partyNameAmbiguous(name string, existing []string) bool {
|
||||
for _, candidate := range existing {
|
||||
if strings.EqualFold(name, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func partyMappingFields(node *yaml.Node, path string) (map[string]*yaml.Node, error) {
|
||||
if node == nil || node.Kind != yaml.MappingNode {
|
||||
return nil, fmt.Errorf("%s must be a mapping", path)
|
||||
}
|
||||
if len(node.Content)%2 != 0 {
|
||||
return nil, fmt.Errorf("%s has an incomplete mapping", path)
|
||||
}
|
||||
fields := make(map[string]*yaml.Node, len(node.Content)/2)
|
||||
for index := 0; index < len(node.Content); index += 2 {
|
||||
key, value := node.Content[index], node.Content[index+1]
|
||||
if key.Kind != yaml.ScalarNode || key.Tag != "!!str" {
|
||||
return nil, fmt.Errorf("%s has a non-string field name", path)
|
||||
}
|
||||
if key.Value == "" {
|
||||
return nil, fmt.Errorf("%s has an empty field name", path)
|
||||
}
|
||||
if _, duplicate := fields[key.Value]; duplicate {
|
||||
return nil, fmt.Errorf("%s has duplicate field %q", path, key.Value)
|
||||
}
|
||||
if partyContainsAlias(value) {
|
||||
return nil, fmt.Errorf("%s.%s must not use YAML aliases", path, key.Value)
|
||||
}
|
||||
fields[key.Value] = value
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
func partyMappingOrder(node *yaml.Node) []string {
|
||||
keys := make([]string, 0, len(node.Content)/2)
|
||||
for index := 0; index < len(node.Content); index += 2 {
|
||||
keys = append(keys, node.Content[index].Value)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func partyKnownFields(fields map[string]*yaml.Node, path string, allowed ...string) error {
|
||||
for name := range fields {
|
||||
known := false
|
||||
for _, candidate := range allowed {
|
||||
if name == candidate {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
return fmt.Errorf("%s has unknown field %q", path, name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func partyContainsAlias(node *yaml.Node) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
if node.Kind == yaml.AliasNode {
|
||||
return true
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
if partyContainsAlias(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClassSummary preserves the declaration order required by the party contract.
|
||||
func (c PartyCharacter) ClassSummary() string {
|
||||
parts := make([]string, 0, len(c.Character.Classes))
|
||||
for _, class := range c.Character.Classes {
|
||||
entry := class.Name
|
||||
if class.Level != nil {
|
||||
entry += fmt.Sprintf(" %d", *class.Level)
|
||||
}
|
||||
parts = append(parts, entry)
|
||||
}
|
||||
return strings.Join(parts, " / ")
|
||||
}
|
||||
|
||||
// AliasSummary preserves the declaration order required by the party contract.
|
||||
func (c PartyCharacter) AliasSummary() string {
|
||||
return strings.Join(c.Character.Aliases, ", ")
|
||||
}
|
||||
|
||||
// PlayersYAML renders the deterministic players-only projection of a
|
||||
// canonical party. It returns one final newline, as produced by yaml.Marshal.
|
||||
func (p *CanonicalParty) PlayersYAML() ([]byte, error) {
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("canonical party is required")
|
||||
}
|
||||
characters := append([]PartyCharacter(nil), p.Characters...)
|
||||
sort.Slice(characters, func(left, right int) bool {
|
||||
return characters[left].ID < characters[right].ID
|
||||
})
|
||||
type projectionCharacter struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Aliases []string `yaml:"alias,omitempty"`
|
||||
}
|
||||
type projectionPlayer struct {
|
||||
Name string `yaml:"name"`
|
||||
Character projectionCharacter `yaml:"character"`
|
||||
}
|
||||
projection := struct {
|
||||
SchemaVersion string `yaml:"schema_version"`
|
||||
Players []projectionPlayer `yaml:"players"`
|
||||
}{
|
||||
SchemaVersion: PlayersSchemaVersion,
|
||||
Players: make([]projectionPlayer, 0, len(characters)),
|
||||
}
|
||||
for _, character := range characters {
|
||||
projection.Players = append(projection.Players, projectionPlayer{
|
||||
Name: character.Player.Name,
|
||||
Character: projectionCharacter{
|
||||
ID: character.ID,
|
||||
Name: character.Character.Name,
|
||||
Aliases: append([]string(nil), character.Character.Aliases...),
|
||||
},
|
||||
})
|
||||
}
|
||||
encoded, err := yaml.Marshal(projection)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("render players projection: %w", err)
|
||||
}
|
||||
return encoded, nil
|
||||
}
|
||||
17
internal/config/party_legacy.go
Normal file
17
internal/config/party_legacy.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package config
|
||||
|
||||
// classifyLegacyPartyDocument is the bounded compatibility path for an
|
||||
// unversioned party source. It intentionally retains no parsed roster data and
|
||||
// must be removed once legacy campaign inputs are no longer supported.
|
||||
func classifyLegacyPartyDocument() *PartyDocument {
|
||||
return &PartyDocument{Mode: PartyModeLegacy}
|
||||
}
|
||||
|
||||
// resolveLegacyParty preserves the deliberately opaque legacy party mode.
|
||||
// This small compatibility surface is intended for removal once legacy
|
||||
// campaign inputs are no longer supported.
|
||||
func resolveLegacyParty(resolved ResolvedParty) (ResolvedParty, error) {
|
||||
resolved.Mode = PartyModeLegacy
|
||||
resolved.Canonical = nil
|
||||
return resolved, nil
|
||||
}
|
||||
93
internal/config/party_resolution.go
Normal file
93
internal/config/party_resolution.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
)
|
||||
|
||||
const maxPartyDocumentBytes = 8 << 20
|
||||
|
||||
func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) {
|
||||
configuredPath := strings.TrimSpace(input.Path)
|
||||
if configuredPath == "" {
|
||||
return ResolvedParty{}, fmt.Errorf("party input path is required")
|
||||
}
|
||||
configPath := strings.TrimSpace(input.ConfigPath)
|
||||
if configPath == "" {
|
||||
return ResolvedParty{}, fmt.Errorf("party input %q has no declaring configuration path", configuredPath)
|
||||
}
|
||||
path := configuredPath
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(filepath.Dir(configPath), path)
|
||||
}
|
||||
path = filepath.Clean(path)
|
||||
raw, err := fileops.ReadRegularFile(path, maxPartyDocumentBytes)
|
||||
if err != nil {
|
||||
return ResolvedParty{}, fmt.Errorf("read party input %q: %w", path, err)
|
||||
}
|
||||
document, err := ParseParty(raw)
|
||||
if err != nil {
|
||||
return ResolvedParty{}, fmt.Errorf("parse party input %q: %w", path, err)
|
||||
}
|
||||
resolved := ResolvedParty{
|
||||
Mode: document.Mode,
|
||||
Source: PartySource{
|
||||
Path: path,
|
||||
ConfigPath: configPath,
|
||||
Source: input.Source,
|
||||
},
|
||||
Canonical: document.Canonical,
|
||||
}
|
||||
if document.IsCanonical() {
|
||||
return resolved, nil
|
||||
}
|
||||
return resolveLegacyParty(resolved)
|
||||
}
|
||||
|
||||
func campaignPartyInput(campaignPath string, campaign *CampaignConfig) (ResolvedInputFile, error) {
|
||||
if campaign == nil {
|
||||
return ResolvedInputFile{}, fmt.Errorf("campaign config is required")
|
||||
}
|
||||
if strings.TrimSpace(campaign.Inputs.PartyFile) == "" {
|
||||
return ResolvedInputFile{}, fmt.Errorf("campaign.inputs.party_file is required")
|
||||
}
|
||||
return ResolvedInputFile{
|
||||
Path: campaign.Inputs.PartyFile,
|
||||
ConfigPath: campaignPath,
|
||||
Source: "campaign_config",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveCampaignParty(campaignPath string, campaign *CampaignConfig) (ResolvedParty, error) {
|
||||
input, err := campaignPartyInput(campaignPath, campaign)
|
||||
if err != nil {
|
||||
return ResolvedParty{}, err
|
||||
}
|
||||
return resolvePartyInput(input)
|
||||
}
|
||||
|
||||
func validateCanonicalPartySelection(campaign *CampaignConfig, session *SessionConfig) error {
|
||||
if campaign == nil {
|
||||
return fmt.Errorf("campaign config is required")
|
||||
}
|
||||
if strings.TrimSpace(campaign.Inputs.PlayersFile) != "" {
|
||||
return fmt.Errorf("campaign.inputs.players_file is not allowed with a canonical party")
|
||||
}
|
||||
if session == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(session.Inputs.PlayersFile) != "" {
|
||||
return fmt.Errorf("session.inputs.players_file is not allowed with a canonical party")
|
||||
}
|
||||
if strings.TrimSpace(session.Inputs.PartyFile) != "" {
|
||||
return fmt.Errorf("session.inputs.party_file cannot override a canonical campaign party")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func virtualPlayersInput() ResolvedInputFile {
|
||||
return ResolvedInputFile{Source: "derived_from_party"}
|
||||
}
|
||||
240
internal/config/party_resolution_test.go
Normal file
240
internal/config/party_resolution_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanonicalPartyResolvesFromCampaignAndDerivesVirtualPlayers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
partyPath := filepath.Join(dir, "roster", "party.yml")
|
||||
if err := os.Mkdir(filepath.Dir(partyPath), 0o755); err != nil {
|
||||
t.Fatalf("create roster directory: %v", err)
|
||||
}
|
||||
partyYAML := []byte(`schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Arannis
|
||||
classes: [{name: wizard, level: 8}]
|
||||
`)
|
||||
if err := os.WriteFile(partyPath, partyYAML, 0o644); err != nil {
|
||||
t.Fatalf("write party: %v", err)
|
||||
}
|
||||
pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign
|
||||
inputs:
|
||||
speakers_file: speakers.yml
|
||||
autocorrect_file: autocorrect.yml
|
||||
glossary_file: glossary.yml
|
||||
party_file: roster/party.yml
|
||||
`, `session_id: session
|
||||
campaign: campaign
|
||||
inputs:
|
||||
audio_dir: audio
|
||||
`)
|
||||
|
||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||
}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
if cfg.Party.Mode != PartyModeCanonical || cfg.Party.Canonical == nil {
|
||||
t.Fatalf("party = %#v, want canonical party", cfg.Party)
|
||||
}
|
||||
if got, want := cfg.Party.Source.Path, partyPath; got != want {
|
||||
t.Fatalf("party source path = %q, want %q", got, want)
|
||||
}
|
||||
if cfg.Party.Source.Source != "campaign_config" || cfg.Party.Source.ConfigPath != campaignPath {
|
||||
t.Fatalf("party provenance = %#v, want campaign source", cfg.Party.Source)
|
||||
}
|
||||
if !bytes.Equal(cfg.Party.Canonical.Raw, partyYAML) {
|
||||
t.Fatal("canonical party bytes were not retained")
|
||||
}
|
||||
if got := cfg.StableInputs.PlayersFile; got.Source != "derived_from_party" || got.Path != "" || got.ConfigPath != "" {
|
||||
t.Fatalf("derived players input = %#v, want virtual party projection source", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalPartyRejectsSeparatePlayersAndSessionPartyOverrides(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
campaignExtra string
|
||||
sessionExtra string
|
||||
want string
|
||||
}{
|
||||
{name: "campaign players", campaignExtra: " players_file: players.yml\n", want: "campaign.inputs.players_file is not allowed"},
|
||||
{name: "session players", sessionExtra: " players_file: players.yml\n", want: "session.inputs.players_file is not allowed"},
|
||||
{name: "session party", sessionExtra: " party_file: party-override.yml\n", want: "session.inputs.party_file cannot override"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), canonicalPartyFixture)
|
||||
pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n party_file: party.yml\n"+test.campaignExtra, "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n"+test.sessionExtra)
|
||||
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPartyPreservesCampaignAndSessionInputOverrides(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: campaign\n")
|
||||
writePartyResolutionFile(t, filepath.Join(dir, "session-party.yml"), "legacy: session\n")
|
||||
pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign
|
||||
inputs:
|
||||
speakers_file: speakers.yml
|
||||
autocorrect_file: autocorrect.yml
|
||||
glossary_file: glossary.yml
|
||||
players_file: campaign-players.yml
|
||||
party_file: party.yml
|
||||
`, `session_id: session
|
||||
campaign: campaign
|
||||
inputs:
|
||||
audio_dir: audio
|
||||
players_file: session-players.yml
|
||||
party_file: session-party.yml
|
||||
`)
|
||||
|
||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||
}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
if cfg.Party.Mode != PartyModeLegacy || cfg.Party.Canonical != nil {
|
||||
t.Fatalf("party = %#v, want opaque legacy party", cfg.Party)
|
||||
}
|
||||
if cfg.Party.Source.Source != "session_config" || filepath.Base(cfg.Party.Source.Path) != "session-party.yml" {
|
||||
t.Fatalf("party source = %#v, want session override", cfg.Party.Source)
|
||||
}
|
||||
if got := cfg.StableInputs.PlayersFile; got.Path != "session-players.yml" || got.Source != "session_config" {
|
||||
t.Fatalf("players input = %#v, want session override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPartyAcceptsAnEffectiveSessionPlayersOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: campaign\n")
|
||||
pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, `campaign_id: campaign
|
||||
inputs:
|
||||
speakers_file: speakers.yml
|
||||
autocorrect_file: autocorrect.yml
|
||||
glossary_file: glossary.yml
|
||||
party_file: party.yml
|
||||
`, `session_id: session
|
||||
campaign: campaign
|
||||
inputs:
|
||||
audio_dir: audio
|
||||
players_file: session-players.yml
|
||||
`)
|
||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
||||
}
|
||||
if got := cfg.StableInputs.PlayersFile; got.Path != "session-players.yml" || got.Source != "session_config" {
|
||||
t.Fatalf("players input = %#v, want effective session override", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyPartyRequiresPlayersAndPartyMustBeReadableRegularFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
partySetup func(t *testing.T, dir string)
|
||||
partyFile string
|
||||
playersFile string
|
||||
want string
|
||||
}{
|
||||
{name: "missing players", partySetup: func(t *testing.T, dir string) {
|
||||
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
|
||||
}, partyFile: "party.yml", want: "players_file is required with a legacy party"},
|
||||
{name: "missing party file", partySetup: func(t *testing.T, dir string) {}, partyFile: "missing.yml", playersFile: "players.yml", want: "read party input"},
|
||||
{name: "non-regular party file", partySetup: func(t *testing.T, dir string) {
|
||||
if err := os.Mkdir(filepath.Join(dir, "party-dir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, partyFile: "party-dir", playersFile: "players.yml", want: "read party input"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
test.partySetup(t, dir)
|
||||
players := ""
|
||||
if test.playersFile != "" {
|
||||
players = " players_file: " + test.playersFile + "\n"
|
||||
}
|
||||
pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n"+players+" party_file: "+test.partyFile+"\n", "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n")
|
||||
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("LoadWithSessionOptions() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAndLoadedCampaignShareCanonicalPartyResolution(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePartyResolutionFile(t, filepath.Join(dir, "party.yml"), canonicalPartyFixture)
|
||||
pipelinePath, campaignPath, sessionPath := writePartyResolutionConfig(t, dir, "campaign_id: campaign\ninputs:\n speakers_file: speakers.yml\n autocorrect_file: autocorrect.yml\n glossary_file: glossary.yml\n party_file: party.yml\n", "session_id: session\ncampaign: campaign\ninputs:\n audio_dir: audio\n")
|
||||
pipeline, err := LoadPipeline(pipelinePath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPipeline() error = %v", err)
|
||||
}
|
||||
campaign, err := LoadCampaign(campaignPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCampaign() error = %v", err)
|
||||
}
|
||||
session, err := LoadSession(sessionPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession() error = %v", err)
|
||||
}
|
||||
loaded, err := LoadPipelineCampaign(pipelinePath, pipeline, campaignPath, campaign)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPipelineCampaign() error = %v", err)
|
||||
}
|
||||
partial, err := ResolveLoadedPipelineCampaign(loaded, "", nil, SessionSource{})
|
||||
if err != nil || partial.Party.Mode != PartyModeCanonical {
|
||||
t.Fatalf("ResolveLoadedPipelineCampaign() = %#v, %v; want canonical partial config", partial, err)
|
||||
}
|
||||
direct, err := Resolve(pipelinePath, pipeline, campaignPath, campaign, sessionPath, session, SessionSource{Source: "session_config", LocalPath: sessionPath})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if direct.Party.Mode != PartyModeCanonical || direct.StableInputs.PlayersFile.Source != "derived_from_party" {
|
||||
t.Fatalf("Resolve() party = %#v, players = %#v", direct.Party, direct.StableInputs.PlayersFile)
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalPartyFixture = `schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Arannis
|
||||
classes: [{name: wizard}]
|
||||
`
|
||||
|
||||
func writePartyResolutionConfig(t *testing.T, dir, campaign, session string) (string, string, string) {
|
||||
t.Helper()
|
||||
pipelinePath := writePartyResolutionFile(t, filepath.Join(dir, "pipeline.yml"), "workspace:\n root: "+filepath.ToSlash(filepath.Join(dir, "work"))+"\nwhisperx:\n transcribe_url: https://example.test/transcribe\nnotification:\n mode: noop\n")
|
||||
campaignPath := writePartyResolutionFile(t, filepath.Join(dir, "campaign.yml"), campaign)
|
||||
sessionPath := writePartyResolutionFile(t, filepath.Join(dir, "session.yml"), session)
|
||||
return pipelinePath, campaignPath, sessionPath
|
||||
}
|
||||
|
||||
func writePartyResolutionFile(t *testing.T, path, contents string) string {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
286
internal/config/party_test.go
Normal file
286
internal/config/party_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePartyCanonicalContract(t *testing.T) {
|
||||
data := []byte(`schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player:
|
||||
name: Eric
|
||||
character:
|
||||
name: Arannis
|
||||
alias:
|
||||
- Ari
|
||||
- The Grey Owl
|
||||
classes:
|
||||
- name: wizard
|
||||
level: 8
|
||||
- name: fighter
|
||||
`)
|
||||
|
||||
document, err := ParseParty(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseParty() error = %v", err)
|
||||
}
|
||||
if !document.IsCanonical() || document.Mode != PartyModeCanonical {
|
||||
t.Fatalf("document mode = %#v, want canonical", document)
|
||||
}
|
||||
if !bytes.Equal(document.Canonical.Raw, data) {
|
||||
t.Fatal("canonical raw bytes were not preserved")
|
||||
}
|
||||
characters := document.Canonical.Characters
|
||||
if len(characters) != 1 || characters[0].ID != "arannis" {
|
||||
t.Fatalf("characters = %#v, want ordered arannis character", characters)
|
||||
}
|
||||
if got, want := characters[0].ClassSummary(), "wizard 8 / fighter"; got != want {
|
||||
t.Fatalf("ClassSummary() = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := characters[0].AliasSummary(), "Ari, The Grey Owl"; got != want {
|
||||
t.Fatalf("AliasSummary() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePartyCanonicalRejectsInvalidDocuments(t *testing.T) {
|
||||
valid := `schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player:
|
||||
name: Eric
|
||||
character:
|
||||
name: Arannis
|
||||
classes:
|
||||
- name: wizard
|
||||
level: 8
|
||||
`
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want string
|
||||
}{
|
||||
{name: "unsupported version", yaml: strings.Replace(valid, PartySchemaVersion, "narratio.party.v2", 1), want: "unsupported"},
|
||||
{name: "non-string version", yaml: strings.Replace(valid, PartySchemaVersion, "1", 1), want: "must be a non-empty string"},
|
||||
{name: "unknown top field", yaml: valid + "unknown: true\n", want: "unknown field"},
|
||||
{name: "unknown nested field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n role: GM\n", 1), want: "unknown field"},
|
||||
{name: "trailing document", yaml: valid + "---\ncharacters: {}\n", want: "exactly one YAML document"},
|
||||
{name: "empty characters", yaml: "schema_version: narratio.party.v1\ncharacters: {}\n", want: "must be non-empty"},
|
||||
{name: "invalid id", yaml: strings.Replace(valid, " arannis:", " bad-id:", 1), want: "invalid character id"},
|
||||
{name: "whitespace id", yaml: strings.Replace(valid, " arannis:", " ' arannis':", 1), want: "invalid character id"},
|
||||
{name: "missing player name", yaml: strings.Replace(valid, "player:\n name: Eric", "player: {}", 1), want: "player.name is required"},
|
||||
{name: "missing character name", yaml: strings.Replace(valid, " name: Arannis\n", "", 1), want: "character.name is required"},
|
||||
{name: "blank player name", yaml: strings.Replace(valid, "name: Eric", "name: ' Eric'", 1), want: "non-empty and trimmed"},
|
||||
{name: "control character", yaml: strings.Replace(valid, "name: Arannis", "name: \"Ara\\tnnis\"", 1), want: "control characters"},
|
||||
{name: "non-string display", yaml: strings.Replace(valid, "name: Eric", "name: 42", 1), want: "display string"},
|
||||
{name: "missing classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", "", 1), want: "classes is required"},
|
||||
{name: "empty classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", " classes: []\n", 1), want: "non-empty list"},
|
||||
{name: "zero level", yaml: strings.Replace(valid, "level: 8", "level: 0", 1), want: "positive integer"},
|
||||
{name: "negative level", yaml: strings.Replace(valid, "level: 8", "level: -2", 1), want: "positive integer"},
|
||||
{name: "non-integer level", yaml: strings.Replace(valid, "level: 8", "level: eight", 1), want: "positive integer"},
|
||||
{name: "duplicate class", yaml: strings.Replace(valid, " level: 8\n", " level: 8\n - name: WIZARD\n", 1), want: "duplicate class"},
|
||||
{name: "duplicate field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n name: Erin\n", 1), want: "duplicate field"},
|
||||
{name: "yaml alias", yaml: strings.Replace(strings.Replace(valid, " name: Eric", " name: &player Eric", 1), " name: Arannis", " name: *player", 1), want: "must not use YAML aliases"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := ParseParty([]byte(test.yaml))
|
||||
if err == nil {
|
||||
t.Fatal("ParseParty() error = nil, want rejection")
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("ParseParty() error = %q, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePartyRejectsGlobalAliasAmbiguityWithUnicodeCaseFolding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
}{
|
||||
{
|
||||
name: "alias equals primary on same character",
|
||||
yaml: `schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Arannis
|
||||
alias: [arannis]
|
||||
classes: [{name: wizard}]
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "primary collides with prior alias",
|
||||
yaml: `schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Arannis
|
||||
alias: [Ari]
|
||||
classes: [{name: wizard}]
|
||||
brenna:
|
||||
player: {name: Jane}
|
||||
character:
|
||||
name: ari
|
||||
classes: [{name: paladin}]
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "aliases collide on one character",
|
||||
yaml: `schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Arannis
|
||||
alias: [Ari, ari]
|
||||
classes: [{name: wizard}]
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "unicode simple fold collision",
|
||||
yaml: `schema_version: narratio.party.v1
|
||||
characters:
|
||||
kelvin:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Kelvin
|
||||
alias: [Knight]
|
||||
classes: [{name: wizard}]
|
||||
knight:
|
||||
player: {name: Jane}
|
||||
character:
|
||||
name: knight
|
||||
classes: [{name: paladin}]
|
||||
`,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := ParseParty([]byte(test.yaml))
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
||||
t.Fatalf("ParseParty() error = %v, want ambiguity rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePartyAllowsRepeatedPlayersAndEmptyAliasList(t *testing.T) {
|
||||
document, err := ParseParty([]byte(`schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Arannis
|
||||
alias: []
|
||||
classes: [{name: wizard}]
|
||||
brenna:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Brenna
|
||||
classes: [{name: paladin}]
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseParty() error = %v", err)
|
||||
}
|
||||
if got := len(document.Canonical.Characters); got != 2 {
|
||||
t.Fatalf("character count = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePartyRejectsEmptyAlias(t *testing.T) {
|
||||
_, err := ParseParty([]byte(`schema_version: narratio.party.v1
|
||||
characters:
|
||||
arannis:
|
||||
player: {name: Eric}
|
||||
character:
|
||||
name: Arannis
|
||||
alias: [""]
|
||||
classes: [{name: wizard}]
|
||||
`))
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty") {
|
||||
t.Fatalf("ParseParty() error = %v, want empty alias rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePartyClassifiesUnversionedDocumentAsLegacy(t *testing.T) {
|
||||
document, err := ParseParty([]byte("players:\n - not canonical\n---\nlegacy: remains opaque\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseParty() error = %v", err)
|
||||
}
|
||||
if document.Mode != PartyModeLegacy || document.Canonical != nil || document.IsCanonical() {
|
||||
t.Fatalf("document = %#v, want opaque legacy classification", document)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalPartyPlayersYAML(t *testing.T) {
|
||||
document, err := ParseParty([]byte(`schema_version: narratio.party.v1
|
||||
characters:
|
||||
zeta:
|
||||
player: {name: Shared Player}
|
||||
character:
|
||||
name: Zeta
|
||||
alias: [Z, The Last]
|
||||
classes: [{name: wizard, level: 8}]
|
||||
alpha:
|
||||
player: {name: Shared Player}
|
||||
character:
|
||||
name: Alpha
|
||||
classes: [{name: ranger}]
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseParty() error = %v", err)
|
||||
}
|
||||
got, err := document.PlayersYAML()
|
||||
if err != nil {
|
||||
t.Fatalf("PlayersYAML() error = %v", err)
|
||||
}
|
||||
again, err := document.Canonical.PlayersYAML()
|
||||
if err != nil {
|
||||
t.Fatalf("PlayersYAML() repeat error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, again) {
|
||||
t.Fatalf("PlayersYAML() was not deterministic:\nfirst:\n%s\nsecond:\n%s", got, again)
|
||||
}
|
||||
want := `schema_version: narratio.players.v1
|
||||
players:
|
||||
- name: Shared Player
|
||||
character:
|
||||
id: alpha
|
||||
name: Alpha
|
||||
- name: Shared Player
|
||||
character:
|
||||
id: zeta
|
||||
name: Zeta
|
||||
alias:
|
||||
- Z
|
||||
- The Last
|
||||
`
|
||||
if string(got) != want {
|
||||
t.Fatalf("PlayersYAML() =\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
if !strings.HasSuffix(string(got), "\n") || strings.Contains(string(got), "classes") {
|
||||
t.Fatalf("players projection leaked unsupported data: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalPartyPlayersYAMLRequiresCanonicalParty(t *testing.T) {
|
||||
var party *CanonicalParty
|
||||
if _, err := party.PlayersYAML(); err == nil {
|
||||
t.Fatal("PlayersYAML() error = nil, want canonical party requirement")
|
||||
}
|
||||
legacy, err := ParseParty([]byte("legacy: party\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseParty() legacy error = %v", err)
|
||||
}
|
||||
if _, err := legacy.PlayersYAML(); err == nil {
|
||||
t.Fatal("legacy PlayersYAML() error = nil, want canonical party requirement")
|
||||
}
|
||||
}
|
||||
515
internal/config/pipeline_composition.go
Normal file
515
internal/config/pipeline_composition.go
Normal file
@@ -0,0 +1,515 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type pipelineResolutionMetadata struct {
|
||||
rootPath string
|
||||
imports []string
|
||||
sources []string
|
||||
selectedProfile *pipelineProfileSelection
|
||||
effectiveDigest string
|
||||
logicalNotariusConfig string
|
||||
logicalNotariusWorking string
|
||||
logicalNotariusCaptured bool
|
||||
ownership []pipelineFieldOwnership
|
||||
artifactFamilies map[string]ScriptoriumArtifactFamilyConfig
|
||||
artifactFamiliesExpanded bool
|
||||
publishDeclared bool
|
||||
}
|
||||
|
||||
type pipelineProfileSelection struct {
|
||||
name string
|
||||
source string
|
||||
overlayPath string
|
||||
}
|
||||
|
||||
type pipelineFieldOwnership struct {
|
||||
path string
|
||||
sources []string
|
||||
}
|
||||
|
||||
type pipelineCompositionEnvelope struct {
|
||||
imports []string
|
||||
defaultProfile *string
|
||||
profiles []pipelineProfileDeclaration
|
||||
}
|
||||
|
||||
type pipelineProfileDeclaration struct {
|
||||
name string
|
||||
overlay string
|
||||
}
|
||||
|
||||
// pipelineCompositionSources retains one validated root source set. Each
|
||||
// selected profile is resolved from a cloned base document so callers can
|
||||
// safely compare or otherwise resolve multiple profiles without rereading or
|
||||
// mutating the source set.
|
||||
type pipelineCompositionSources struct {
|
||||
rootPath string
|
||||
envelope pipelineCompositionEnvelope
|
||||
imports []loadedPipelineImport
|
||||
overlays []loadedPipelineProfileOverlay
|
||||
overlaysLoaded bool
|
||||
base *compositionDocument
|
||||
}
|
||||
|
||||
func loadPipelineCompositionSources(path string) (*pipelineCompositionSources, error) {
|
||||
rootPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve root pipeline path %q: %w", path, err)
|
||||
}
|
||||
rootFile, err := os.Open(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline file %q: open: %w", path, err)
|
||||
}
|
||||
rootDocument, parseErr := parseCompositionDocument(rootPath, rootFile)
|
||||
closeErr := rootFile.Close()
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("pipeline file %q: close: %w", rootPath, closeErr)
|
||||
}
|
||||
|
||||
baseRoot, envelope, err := splitPipelineCompositionEnvelope(rootDocument)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imports, err := loadPipelineImports(rootPath, envelope.imports)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
documents := make([]*compositionDocument, 0, len(imports)+1)
|
||||
documents = append(documents, baseRoot)
|
||||
for _, imported := range imports {
|
||||
documents = append(documents, imported.document)
|
||||
}
|
||||
merged, err := mergeAdditiveCompositions(documents...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pipelineCompositionSources{
|
||||
rootPath: rootPath,
|
||||
envelope: envelope,
|
||||
imports: imports,
|
||||
base: merged,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (sources *pipelineCompositionSources) loadOverlays() error {
|
||||
if sources == nil {
|
||||
return fmt.Errorf("pipeline composition sources are required")
|
||||
}
|
||||
if sources.overlaysLoaded {
|
||||
return nil
|
||||
}
|
||||
overlays, err := loadPipelineProfileOverlays(sources.rootPath, sources.envelope.profiles, sources.imports)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sources.overlays = overlays
|
||||
sources.overlaysLoaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sources *pipelineCompositionSources) resolve(opts PipelineLoadOptions) (*PipelineConfig, error) {
|
||||
if sources == nil || sources.base == nil {
|
||||
return nil, fmt.Errorf("pipeline composition sources are required")
|
||||
}
|
||||
if !sources.overlaysLoaded {
|
||||
return nil, fmt.Errorf("pipeline profile overlays have not been loaded")
|
||||
}
|
||||
selection, err := selectPipelineProfile(sources.envelope, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged := &compositionDocument{
|
||||
root: cloneCompositionNode(sources.base.root),
|
||||
sources: append([]string(nil), sources.base.sources...),
|
||||
}
|
||||
if selection != nil {
|
||||
overlay, ok := loadedProfileOverlay(sources.overlays, selection.name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("selected profile %q overlay was not loaded", selection.name)
|
||||
}
|
||||
merged, err = mergeOverlayComposition(merged, overlay.document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selection.overlayPath = overlay.path
|
||||
}
|
||||
|
||||
rendered, err := merged.canonicalYAML()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg PipelineConfig
|
||||
if err := decodeStrictYAMLFromReader("pipeline", sources.rootPath, strings.NewReader(string(rendered)), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("assembled pipeline sources %s: %w", formatCompositionSources(merged.sources), err)
|
||||
}
|
||||
records, err := merged.semanticRecords()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metadata := &pipelineResolutionMetadata{
|
||||
rootPath: sources.rootPath,
|
||||
sources: append([]string(nil), merged.sources...),
|
||||
selectedProfile: selection,
|
||||
}
|
||||
for _, imported := range sources.imports {
|
||||
metadata.imports = append(metadata.imports, imported.path)
|
||||
}
|
||||
for _, record := range records {
|
||||
metadata.ownership = append(metadata.ownership, pipelineFieldOwnership{
|
||||
path: record.Path, sources: append([]string(nil), record.Sources...),
|
||||
})
|
||||
}
|
||||
cfg.resolution = metadata
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func splitPipelineCompositionEnvelope(document *compositionDocument) (*compositionDocument, pipelineCompositionEnvelope, error) {
|
||||
if err := validateCompositionDocument(document, "root pipeline"); err != nil {
|
||||
return nil, pipelineCompositionEnvelope{}, err
|
||||
}
|
||||
root := cloneCompositionNode(document.root)
|
||||
index := compositionFieldIndex(root.fields, "composition")
|
||||
if index < 0 {
|
||||
return &compositionDocument{root: root, sources: append([]string(nil), document.sources...)}, pipelineCompositionEnvelope{}, nil
|
||||
}
|
||||
envelopeNode := root.fields[index].value
|
||||
if envelopeNode.kind != yaml.MappingNode {
|
||||
return nil, pipelineCompositionEnvelope{}, fmt.Errorf(
|
||||
"configuration source %s at composition: expected a mapping, got %s",
|
||||
formatCompositionSources(envelopeNode.sources), yamlKindName(envelopeNode.kind),
|
||||
)
|
||||
}
|
||||
|
||||
var envelope pipelineCompositionEnvelope
|
||||
for _, field := range envelopeNode.fields {
|
||||
switch field.key {
|
||||
case "imports":
|
||||
if field.value.kind != yaml.SequenceNode {
|
||||
return nil, pipelineCompositionEnvelope{}, fmt.Errorf(
|
||||
"configuration source %s at composition.imports: expected a list, got %s",
|
||||
formatCompositionSources(field.value.sources), yamlKindName(field.value.kind),
|
||||
)
|
||||
}
|
||||
for itemIndex, item := range field.value.items {
|
||||
if item.kind != yaml.ScalarNode || item.tag != "!!str" {
|
||||
return nil, pipelineCompositionEnvelope{}, fmt.Errorf(
|
||||
"configuration source %s at composition.imports[%d]: expected a string path",
|
||||
formatCompositionSources(item.sources), itemIndex,
|
||||
)
|
||||
}
|
||||
envelope.imports = append(envelope.imports, item.value)
|
||||
}
|
||||
case "default_profile":
|
||||
if field.value.kind != yaml.ScalarNode || field.value.tag != "!!str" {
|
||||
return nil, pipelineCompositionEnvelope{}, fmt.Errorf(
|
||||
"configuration source %s at composition.default_profile: expected a string profile name",
|
||||
formatCompositionSources(field.value.sources),
|
||||
)
|
||||
}
|
||||
name, err := normalizePipelineProfileName(field.value.value, "composition.default_profile")
|
||||
if err != nil {
|
||||
return nil, pipelineCompositionEnvelope{}, err
|
||||
}
|
||||
envelope.defaultProfile = &name
|
||||
case "profiles":
|
||||
if field.value.kind != yaml.MappingNode {
|
||||
return nil, pipelineCompositionEnvelope{}, fmt.Errorf(
|
||||
"configuration source %s at composition.profiles: expected a mapping, got %s",
|
||||
formatCompositionSources(field.value.sources), yamlKindName(field.value.kind),
|
||||
)
|
||||
}
|
||||
if len(field.value.fields) == 0 {
|
||||
return nil, pipelineCompositionEnvelope{}, fmt.Errorf("composition.profiles must declare at least one named profile")
|
||||
}
|
||||
for _, profileField := range field.value.fields {
|
||||
name, err := normalizePipelineProfileName(profileField.key, "composition.profiles profile name")
|
||||
if err != nil {
|
||||
return nil, pipelineCompositionEnvelope{}, err
|
||||
}
|
||||
profile, err := parsePipelineProfileDeclaration(name, profileField.value)
|
||||
if err != nil {
|
||||
return nil, pipelineCompositionEnvelope{}, err
|
||||
}
|
||||
envelope.profiles = append(envelope.profiles, profile)
|
||||
}
|
||||
default:
|
||||
return nil, pipelineCompositionEnvelope{}, fmt.Errorf(
|
||||
"configuration source %s at composition.%s: unknown composition field %q",
|
||||
formatCompositionSources(field.value.sources), field.key, field.key,
|
||||
)
|
||||
}
|
||||
}
|
||||
if envelope.defaultProfile != nil && !pipelineProfileDeclared(envelope.profiles, *envelope.defaultProfile) {
|
||||
return nil, pipelineCompositionEnvelope{}, fmt.Errorf(
|
||||
"composition.default_profile %q does not name a declared profile", *envelope.defaultProfile,
|
||||
)
|
||||
}
|
||||
root.fields = append(root.fields[:index], root.fields[index+1:]...)
|
||||
for fieldIndex := range root.fields {
|
||||
root.fields[fieldIndex].order = fieldIndex
|
||||
}
|
||||
return &compositionDocument{root: root, sources: append([]string(nil), document.sources...)}, envelope, nil
|
||||
}
|
||||
|
||||
func parsePipelineProfileDeclaration(name string, node *compositionNode) (pipelineProfileDeclaration, error) {
|
||||
path := "composition.profiles." + name
|
||||
if node.kind != yaml.MappingNode {
|
||||
return pipelineProfileDeclaration{}, fmt.Errorf(
|
||||
"configuration source %s at %s: expected a mapping, got %s",
|
||||
formatCompositionSources(node.sources), path, yamlKindName(node.kind),
|
||||
)
|
||||
}
|
||||
profile := pipelineProfileDeclaration{name: name}
|
||||
for _, field := range node.fields {
|
||||
if field.key != "overlay" {
|
||||
return pipelineProfileDeclaration{}, fmt.Errorf(
|
||||
"configuration source %s at %s.%s: unknown profile field %q; only overlay is supported",
|
||||
formatCompositionSources(field.value.sources), path, field.key, field.key,
|
||||
)
|
||||
}
|
||||
if field.value.kind != yaml.ScalarNode || field.value.tag != "!!str" {
|
||||
return pipelineProfileDeclaration{}, fmt.Errorf(
|
||||
"configuration source %s at %s.overlay: expected a string path",
|
||||
formatCompositionSources(field.value.sources), path,
|
||||
)
|
||||
}
|
||||
profile.overlay = field.value.value
|
||||
}
|
||||
if profile.overlay == "" {
|
||||
return pipelineProfileDeclaration{}, fmt.Errorf("%s.overlay is required", path)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func normalizePipelineProfileName(value, label string) (string, error) {
|
||||
if value == "" || strings.TrimSpace(value) != value {
|
||||
return "", fmt.Errorf("%s must be non-empty without surrounding whitespace", label)
|
||||
}
|
||||
for _, character := range value {
|
||||
if unicode.IsControl(character) {
|
||||
return "", fmt.Errorf("%s %q must not contain control characters", label, value)
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func pipelineProfileDeclared(profiles []pipelineProfileDeclaration, name string) bool {
|
||||
for _, profile := range profiles {
|
||||
if profile.name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func selectPipelineProfile(envelope pipelineCompositionEnvelope, opts PipelineLoadOptions) (*pipelineProfileSelection, error) {
|
||||
if opts.Profile != nil {
|
||||
name, err := normalizePipelineProfileName(*opts.Profile, "explicit profile selection")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(envelope.profiles) == 0 {
|
||||
return nil, fmt.Errorf("explicit profile %q was selected but the pipeline declares no profiles", name)
|
||||
}
|
||||
if !pipelineProfileDeclared(envelope.profiles, name) {
|
||||
return nil, fmt.Errorf("explicit profile %q is not declared by the pipeline", name)
|
||||
}
|
||||
return &pipelineProfileSelection{name: name, source: "cli"}, nil
|
||||
}
|
||||
if len(envelope.profiles) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if envelope.defaultProfile == nil {
|
||||
return nil, fmt.Errorf("pipeline declares profiles but composition.default_profile is omitted and no profile was explicitly selected")
|
||||
}
|
||||
return &pipelineProfileSelection{name: *envelope.defaultProfile, source: "default"}, nil
|
||||
}
|
||||
|
||||
type loadedPipelineImport struct {
|
||||
path string
|
||||
document *compositionDocument
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
type loadedPipelineProfileOverlay struct {
|
||||
profile string
|
||||
path string
|
||||
document *compositionDocument
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
func loadPipelineProfileOverlays(
|
||||
rootPath string,
|
||||
declared []pipelineProfileDeclaration,
|
||||
imports []loadedPipelineImport,
|
||||
) ([]loadedPipelineProfileOverlay, error) {
|
||||
if len(declared) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rootDir := filepath.Dir(rootPath)
|
||||
rootInfo, err := os.Stat(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect root pipeline file %q: %w", rootPath, err)
|
||||
}
|
||||
seenPaths := make(map[string]string, len(declared))
|
||||
loaded := make([]loadedPipelineProfileOverlay, 0, len(declared))
|
||||
for _, profile := range declared {
|
||||
label := "composition.profiles." + profile.name + ".overlay"
|
||||
raw := profile.overlay
|
||||
if strings.TrimSpace(raw) != raw || raw == "" {
|
||||
return nil, fmt.Errorf("%s must be a non-empty path without surrounding whitespace", label)
|
||||
}
|
||||
normalized, err := pathsafe.NormalizeRelativeDestination(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s path %q is invalid: %w", label, raw, err)
|
||||
}
|
||||
extension := filepath.Ext(filepath.FromSlash(normalized))
|
||||
if extension != ".yml" && extension != ".yaml" {
|
||||
return nil, fmt.Errorf("%s path %q must use .yml or .yaml", label, raw)
|
||||
}
|
||||
if prior, duplicate := seenPaths[normalized]; duplicate {
|
||||
return nil, fmt.Errorf("%s path %q duplicates profile %q overlay after normalization", label, raw, prior)
|
||||
}
|
||||
seenPaths[normalized] = profile.name
|
||||
resolved := filepath.Join(rootDir, filepath.FromSlash(normalized))
|
||||
file, err := fileops.OpenConfinedRegularFile(rootDir, normalized)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s path %q beneath root pipeline directory: %w", label, raw, err)
|
||||
}
|
||||
info, statErr := file.Stat()
|
||||
if statErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("inspect %s path %q: %w", label, raw, statErr)
|
||||
}
|
||||
if os.SameFile(rootInfo, info) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("%s path %q references the root pipeline itself", label, raw)
|
||||
}
|
||||
for _, imported := range imports {
|
||||
if os.SameFile(imported.info, info) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("%s path %q references the same file as imported source %q", label, raw, imported.path)
|
||||
}
|
||||
}
|
||||
for _, prior := range loaded {
|
||||
if os.SameFile(prior.info, info) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("%s path %q references the same file as profile %q overlay", label, raw, prior.profile)
|
||||
}
|
||||
}
|
||||
document, parseErr := parseCompositionDocument(resolved, file)
|
||||
closeErr := file.Close()
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("close %s path %q: %w", label, raw, closeErr)
|
||||
}
|
||||
if compositionFieldIndex(document.root.fields, "composition") >= 0 {
|
||||
return nil, fmt.Errorf("profile overlay source %q declares composition; only the root pipeline may declare composition", resolved)
|
||||
}
|
||||
loaded = append(loaded, loadedPipelineProfileOverlay{
|
||||
profile: profile.name, path: resolved, document: document, info: info,
|
||||
})
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
|
||||
func loadedProfileOverlay(overlays []loadedPipelineProfileOverlay, name string) (loadedPipelineProfileOverlay, bool) {
|
||||
for _, overlay := range overlays {
|
||||
if overlay.profile == name {
|
||||
return overlay, true
|
||||
}
|
||||
}
|
||||
return loadedPipelineProfileOverlay{}, false
|
||||
}
|
||||
|
||||
func loadPipelineImports(rootPath string, declared []string) ([]loadedPipelineImport, error) {
|
||||
if len(declared) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rootDir := filepath.Dir(rootPath)
|
||||
rootInfo, err := os.Stat(rootPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect root pipeline file %q: %w", rootPath, err)
|
||||
}
|
||||
seenPaths := make(map[string]int, len(declared))
|
||||
loaded := make([]loadedPipelineImport, 0, len(declared))
|
||||
for index, raw := range declared {
|
||||
if strings.TrimSpace(raw) != raw || raw == "" {
|
||||
return nil, fmt.Errorf("composition.imports[%d] must be a non-empty path without surrounding whitespace", index)
|
||||
}
|
||||
normalized, err := pathsafe.NormalizeRelativeDestination(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("composition.imports[%d] path %q is invalid: %w", index, raw, err)
|
||||
}
|
||||
extension := filepath.Ext(filepath.FromSlash(normalized))
|
||||
if extension != ".yml" && extension != ".yaml" {
|
||||
return nil, fmt.Errorf("composition.imports[%d] path %q must use .yml or .yaml", index, raw)
|
||||
}
|
||||
if prior, duplicate := seenPaths[normalized]; duplicate {
|
||||
return nil, fmt.Errorf(
|
||||
"composition.imports[%d] path %q duplicates composition.imports[%d] after normalization",
|
||||
index, raw, prior,
|
||||
)
|
||||
}
|
||||
seenPaths[normalized] = index
|
||||
resolved := filepath.Join(rootDir, filepath.FromSlash(normalized))
|
||||
if resolved == rootPath {
|
||||
return nil, fmt.Errorf("composition.imports[%d] path %q imports the root pipeline itself", index, raw)
|
||||
}
|
||||
|
||||
file, err := fileops.OpenConfinedRegularFile(rootDir, normalized)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open composition.imports[%d] path %q beneath root pipeline directory: %w", index, raw, err)
|
||||
}
|
||||
info, statErr := file.Stat()
|
||||
if statErr != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("inspect composition.imports[%d] path %q: %w", index, raw, statErr)
|
||||
}
|
||||
if os.SameFile(rootInfo, info) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("composition.imports[%d] path %q imports the root pipeline itself", index, raw)
|
||||
}
|
||||
for priorIndex, prior := range loaded {
|
||||
if os.SameFile(prior.info, info) {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf(
|
||||
"composition.imports[%d] path %q references the same file as composition.imports[%d] %q",
|
||||
index, raw, priorIndex, declared[priorIndex],
|
||||
)
|
||||
}
|
||||
}
|
||||
document, parseErr := parseCompositionDocument(resolved, file)
|
||||
closeErr := file.Close()
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("close composition.imports[%d] path %q: %w", index, raw, closeErr)
|
||||
}
|
||||
if compositionFieldIndex(document.root.fields, "composition") >= 0 {
|
||||
return nil, fmt.Errorf(
|
||||
"imported configuration source %q declares composition; only the root pipeline may declare composition",
|
||||
resolved,
|
||||
)
|
||||
}
|
||||
loaded = append(loaded, loadedPipelineImport{path: resolved, document: document, info: info})
|
||||
}
|
||||
return loaded, nil
|
||||
}
|
||||
370
internal/config/pipeline_composition_test.go
Normal file
370
internal/config/pipeline_composition_test.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadPipelineCompositionImportsDisjointFieldsAndTracksOwnership(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writePipelineSource(t, dir, "pipeline.yml", `composition:
|
||||
imports:
|
||||
- conf.d/platform.yml
|
||||
- conf.d/artifacts.yml
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
platformPath := writePipelineSource(t, dir, "conf.d/platform.yml", `workspace:
|
||||
root: /srv/narratio/work
|
||||
storage:
|
||||
backend: local
|
||||
`)
|
||||
artifactsPath := writePipelineSource(t, dir, "conf.d/artifacts.yml", `scriptorium:
|
||||
artifacts:
|
||||
player_handout:
|
||||
enabled: false
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
`)
|
||||
|
||||
cfg, err := LoadPipeline(rootPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPipeline() error = %v", err)
|
||||
}
|
||||
if cfg.Workspace.Root != "/srv/narratio/work" || cfg.Storage.Backend != StorageBackendLocal {
|
||||
t.Fatalf("imported platform config = workspace=%q storage=%#v", cfg.Workspace.Root, cfg.Storage)
|
||||
}
|
||||
if cfg.Scriptorium == nil || len(cfg.Scriptorium.Artifacts) != 2 || cfg.Scriptorium.Artifacts["player_handout"].Enabled {
|
||||
t.Fatalf("imported artifacts = %#v", cfg.Scriptorium)
|
||||
}
|
||||
if cfg.WhisperX.TranscribeURL != "https://transcription.example.com/transcribe" {
|
||||
t.Fatalf("root field = %q", cfg.WhisperX.TranscribeURL)
|
||||
}
|
||||
if cfg.resolution == nil {
|
||||
t.Fatal("pipeline resolution metadata = nil")
|
||||
}
|
||||
wantSources := []string{absolutePath(t, rootPath), absolutePath(t, platformPath), absolutePath(t, artifactsPath)}
|
||||
if !reflect.DeepEqual(cfg.resolution.sources, wantSources) || !reflect.DeepEqual(cfg.resolution.imports, wantSources[1:]) {
|
||||
t.Fatalf("resolution sources=%#v imports=%#v, want %#v / %#v", cfg.resolution.sources, cfg.resolution.imports, wantSources, wantSources[1:])
|
||||
}
|
||||
assertPipelineFieldOwner(t, cfg, "whisperx.transcribe_url", absolutePath(t, rootPath))
|
||||
assertPipelineFieldOwner(t, cfg, "workspace.root", absolutePath(t, platformPath))
|
||||
assertPipelineFieldOwner(t, cfg, "scriptorium.artifacts.session_recap.prompt_id", absolutePath(t, artifactsPath))
|
||||
}
|
||||
|
||||
func TestLoadPipelineCompositionMergesDisjointKeyedEntries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writePipelineSource(t, dir, "pipeline.yml", `composition:
|
||||
imports: [first.yml, second.yml]
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
scriptorium:
|
||||
artifacts:
|
||||
root_artifact:
|
||||
enabled: false
|
||||
`)
|
||||
writePipelineSource(t, dir, "first.yml", `scriptorium:
|
||||
artifacts:
|
||||
first_artifact:
|
||||
enabled: false
|
||||
`)
|
||||
writePipelineSource(t, dir, "second.yml", `scriptorium:
|
||||
artifacts:
|
||||
second_artifact:
|
||||
enabled: false
|
||||
`)
|
||||
|
||||
cfg, err := LoadPipeline(rootPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(cfg.Scriptorium.Artifacts); got != 3 {
|
||||
t.Fatalf("artifact count = %d, want 3: %#v", got, cfg.Scriptorium.Artifacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPipelineCompositionRejectsBaseConflictsWithAllSources(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
root string
|
||||
imports map[string]string
|
||||
path string
|
||||
sources []string
|
||||
}{
|
||||
{
|
||||
name: "root and import identical scalar",
|
||||
root: "whisperx:\n language: en\n",
|
||||
imports: map[string]string{"one.yml": "whisperx:\n language: en\n"},
|
||||
path: "whisperx.language", sources: []string{"pipeline.yml", "one.yml"},
|
||||
},
|
||||
{
|
||||
name: "all import claimants",
|
||||
imports: map[string]string{
|
||||
"one.yml": "workspace:\n root: /one\n",
|
||||
"two.yml": "workspace:\n root: /two\n",
|
||||
"three.yml": "workspace:\n root: /three\n",
|
||||
},
|
||||
path: "workspace.root", sources: []string{"one.yml", "two.yml", "three.yml"},
|
||||
},
|
||||
{
|
||||
name: "atomic list",
|
||||
root: "audita:\n modules: [one]\n",
|
||||
imports: map[string]string{"one.yml": "audita:\n modules: [two]\n"},
|
||||
path: "audita.modules", sources: []string{"pipeline.yml", "one.yml"},
|
||||
},
|
||||
{
|
||||
name: "kind conflict",
|
||||
root: "workspace:\n root: /work\n",
|
||||
imports: map[string]string{"one.yml": "workspace: invalid\n"},
|
||||
path: "workspace", sources: []string{"pipeline.yml", "one.yml"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
order := make([]string, 0, len(tt.imports))
|
||||
for _, name := range []string{"one.yml", "two.yml", "three.yml"} {
|
||||
if _, ok := tt.imports[name]; ok {
|
||||
order = append(order, name)
|
||||
}
|
||||
}
|
||||
root := "composition:\n imports:\n"
|
||||
for _, name := range order {
|
||||
root += " - " + name + "\n"
|
||||
}
|
||||
root += tt.root
|
||||
rootPath := writePipelineSource(t, dir, "pipeline.yml", root)
|
||||
for name, content := range tt.imports {
|
||||
writePipelineSource(t, dir, name, content)
|
||||
}
|
||||
|
||||
_, err := LoadPipeline(rootPath)
|
||||
if err == nil {
|
||||
t.Fatal("LoadPipeline() error = nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.path) {
|
||||
t.Fatalf("error = %q, want path %q", err, tt.path)
|
||||
}
|
||||
for _, source := range tt.sources {
|
||||
if !strings.Contains(err.Error(), source) {
|
||||
t.Fatalf("error = %q, want source %q", err, source)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPipelineCompositionRejectsUnsafeOrInvalidImports(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
imports []string
|
||||
setup func(*testing.T, string)
|
||||
want string
|
||||
}{
|
||||
{name: "empty", imports: []string{""}, want: "non-empty path"},
|
||||
{name: "surrounding whitespace", imports: []string{" one.yml "}, want: "surrounding whitespace"},
|
||||
{name: "absolute", imports: []string{"/tmp/one.yml"}, want: "invalid"},
|
||||
{name: "traversal", imports: []string{"../one.yml"}, want: "invalid"},
|
||||
{name: "unsupported extension", imports: []string{"one.json"}, want: ".yml or .yaml"},
|
||||
{name: "missing", imports: []string{"missing.yml"}, want: "open composition.imports"},
|
||||
{name: "duplicate normalized", imports: []string{"one.yml", "./one.yml"}, setup: func(t *testing.T, dir string) {
|
||||
writePipelineSource(t, dir, "one.yml", "workspace:\n root: /work\n")
|
||||
}, want: "duplicates composition.imports"},
|
||||
{name: "root self import", imports: []string{"pipeline.yml"}, want: "root pipeline itself"},
|
||||
{name: "directory", imports: []string{"directory.yml"}, setup: func(t *testing.T, dir string) {
|
||||
if err := os.Mkdir(filepath.Join(dir, "directory.yml"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, want: "regular file"},
|
||||
{name: "symlink file", imports: []string{"link.yml"}, setup: func(t *testing.T, dir string) {
|
||||
writePipelineSource(t, dir, "target.yml", "workspace:\n root: /work\n")
|
||||
if err := os.Symlink("target.yml", filepath.Join(dir, "link.yml")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
}, want: "not a regular file"},
|
||||
{name: "symlink directory", imports: []string{"linked/one.yml"}, setup: func(t *testing.T, dir string) {
|
||||
writePipelineSource(t, dir, "actual/one.yml", "workspace:\n root: /work\n")
|
||||
if err := os.Symlink("actual", filepath.Join(dir, "linked")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
}, want: "not a regular directory"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if tt.setup != nil {
|
||||
tt.setup(t, dir)
|
||||
}
|
||||
rootPath := writeImportRoot(t, dir, tt.imports)
|
||||
_, err := LoadPipeline(rootPath)
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tt.want)) {
|
||||
t.Fatalf("LoadPipeline() error = %v, want containing %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPipelineCompositionRejectsSameFileAliasesAndImportedComposition(t *testing.T) {
|
||||
t.Run("same file through hard link", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("hard-link identity behavior is platform-specific")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
writePipelineSource(t, dir, "one.yml", "workspace:\n root: /work\n")
|
||||
if err := os.Link(filepath.Join(dir, "one.yml"), filepath.Join(dir, "two.yml")); err != nil {
|
||||
t.Skipf("hard links unavailable: %v", err)
|
||||
}
|
||||
rootPath := writeImportRoot(t, dir, []string{"one.yml", "two.yml"})
|
||||
_, err := LoadPipeline(rootPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "same file") {
|
||||
t.Fatalf("LoadPipeline() error = %v, want same-file rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("imported composition", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeImportRoot(t, dir, []string{"nested.yml"})
|
||||
writePipelineSource(t, dir, "nested.yml", "composition:\n imports: []\n")
|
||||
_, err := LoadPipeline(rootPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "only the root pipeline") || !strings.Contains(err.Error(), "nested.yml") {
|
||||
t.Fatalf("LoadPipeline() error = %v, want imported composition rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty profiles", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writePipelineSource(t, dir, "pipeline.yml", "composition:\n profiles: {}\n")
|
||||
_, err := LoadPipeline(rootPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "must declare at least one named profile") {
|
||||
t.Fatalf("LoadPipeline() error = %v, want empty profiles rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadPipelineCompositionReportsImportedParseAndSchemaSources(t *testing.T) {
|
||||
t.Run("malformed imported YAML", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeImportRoot(t, dir, []string{"broken.yml"})
|
||||
brokenPath := writePipelineSource(t, dir, "broken.yml", "workspace: [\n")
|
||||
_, err := LoadPipeline(rootPath)
|
||||
if err == nil || !strings.Contains(err.Error(), absolutePath(t, brokenPath)) || !strings.Contains(err.Error(), "decode YAML") {
|
||||
t.Fatalf("LoadPipeline() error = %v, want imported parse source", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown imported field", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeImportRoot(t, dir, []string{"unknown.yml"})
|
||||
unknownPath := writePipelineSource(t, dir, "unknown.yml", "unknown_field: true\n")
|
||||
_, err := LoadPipeline(rootPath)
|
||||
if err == nil || !strings.Contains(err.Error(), absolutePath(t, unknownPath)) || !strings.Contains(err.Error(), "strict decode failed") {
|
||||
t.Fatalf("LoadPipeline() error = %v, want assembled source-aware strict error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadPipelineCompositionKeepsRelativePathsRootBased(t *testing.T) {
|
||||
rootDir := t.TempDir()
|
||||
monolithicPath := writePipelineSource(t, rootDir, "monolithic.yml", testPipelineBaseYAML+`
|
||||
notarius:
|
||||
enabled: true
|
||||
config_path: tool/notarius.yml
|
||||
pipeline_id: dnd-session
|
||||
outputs:
|
||||
npc_registry:
|
||||
lane_id: npc-registry
|
||||
media_type: application/json
|
||||
schema_id: notarius.dnd.npc_registry
|
||||
schema_version: v1
|
||||
`)
|
||||
composedPath := writePipelineSource(t, rootDir, "pipeline.yml", `composition:
|
||||
imports: [conf.d/extraction.yml]
|
||||
`+testPipelineBaseYAML)
|
||||
writePipelineSource(t, rootDir, "conf.d/extraction.yml", `notarius:
|
||||
enabled: true
|
||||
config_path: tool/notarius.yml
|
||||
pipeline_id: dnd-session
|
||||
outputs:
|
||||
npc_registry:
|
||||
lane_id: npc-registry
|
||||
media_type: application/json
|
||||
schema_id: notarius.dnd.npc_registry
|
||||
schema_version: v1
|
||||
`)
|
||||
|
||||
monolithic, err := LoadPipeline(monolithicPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
composed, err := LoadPipeline(composedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(rootDir, "tool", "notarius.yml")
|
||||
if monolithic.Notarius.ConfigPath != want || composed.Notarius.ConfigPath != want {
|
||||
t.Fatalf("config paths = monolithic %q composed %q, want %q", monolithic.Notarius.ConfigPath, composed.Notarius.ConfigPath, want)
|
||||
}
|
||||
if monolithic.Notarius.WorkingDirectory != filepath.Dir(want) || composed.Notarius.WorkingDirectory != filepath.Dir(want) {
|
||||
t.Fatalf("working directories = %q / %q", monolithic.Notarius.WorkingDirectory, composed.Notarius.WorkingDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
func writeImportRoot(t *testing.T, dir string, imports []string) string {
|
||||
t.Helper()
|
||||
var builder strings.Builder
|
||||
builder.WriteString("composition:\n imports:\n")
|
||||
for _, imported := range imports {
|
||||
builder.WriteString(" - ")
|
||||
if imported == "" {
|
||||
builder.WriteString(`""`)
|
||||
} else {
|
||||
builder.WriteString(`"` + imported + `"`)
|
||||
}
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
builder.WriteString("whisperx:\n transcribe_url: https://transcription.example.com/transcribe\n")
|
||||
return writePipelineSource(t, dir, "pipeline.yml", builder.String())
|
||||
}
|
||||
|
||||
func writePipelineSource(t *testing.T, root, relative, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(relative))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func absolutePath(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
func assertPipelineFieldOwner(t *testing.T, cfg *PipelineConfig, path, source string) {
|
||||
t.Helper()
|
||||
if cfg == nil || cfg.resolution == nil {
|
||||
t.Fatal("pipeline resolution metadata is absent")
|
||||
}
|
||||
for _, ownership := range cfg.resolution.ownership {
|
||||
if ownership.path == path {
|
||||
if !reflect.DeepEqual(ownership.sources, []string{source}) {
|
||||
t.Fatalf("owner of %s = %#v, want %q", path, ownership.sources, source)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("ownership path %q not found: %#v", path, cfg.resolution.ownership)
|
||||
}
|
||||
477
internal/config/pipeline_profiles_test.go
Normal file
477
internal/config/pipeline_profiles_test.go
Normal file
@@ -0,0 +1,477 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadPipelineProfilesSelectDefaultAndExplicitOverlay(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeProfilePipeline(t, dir, `composition:
|
||||
imports: [conf.d/base.yml]
|
||||
default_profile: production
|
||||
profiles:
|
||||
production:
|
||||
overlay: profiles/production.yml
|
||||
testing:
|
||||
overlay: profiles/testing.yml
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
importPath := writePipelineSource(t, dir, "conf.d/base.yml", `workspace:
|
||||
root: /srv/base
|
||||
audita:
|
||||
modules: [base, shared]
|
||||
`)
|
||||
productionPath := writePipelineSource(t, dir, "profiles/production.yml", `workspace:
|
||||
root: /srv/production
|
||||
whisperx:
|
||||
language: en
|
||||
`)
|
||||
testingPath := writePipelineSource(t, dir, "profiles/testing.yml", `workspace:
|
||||
root: /srv/testing
|
||||
whisperx:
|
||||
language: fr
|
||||
`)
|
||||
|
||||
production, err := LoadPipeline(rootPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if production.Workspace.Root != "/srv/production" || production.WhisperX.Language != "en" {
|
||||
t.Fatalf("default profile result = workspace=%q language=%q", production.Workspace.Root, production.WhisperX.Language)
|
||||
}
|
||||
assertSelectedPipelineProfile(t, production, "production", "default", productionPath)
|
||||
if want := []string{absolutePath(t, rootPath), absolutePath(t, importPath), absolutePath(t, productionPath)}; !reflect.DeepEqual(production.resolution.sources, want) {
|
||||
t.Fatalf("default sources = %#v, want %#v", production.resolution.sources, want)
|
||||
}
|
||||
assertPipelineFieldOwner(t, production, "workspace.root", absolutePath(t, productionPath))
|
||||
assertPipelineFieldOwner(t, production, "audita.modules", absolutePath(t, importPath))
|
||||
assertPipelineFieldOwner(t, production, "storage.backend", pipelineDefaultOwnershipSource)
|
||||
|
||||
profile := "testing"
|
||||
testingCfg, err := LoadPipelineWithOptions(rootPath, PipelineLoadOptions{Profile: &profile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if testingCfg.Workspace.Root != "/srv/testing" || testingCfg.WhisperX.Language != "fr" {
|
||||
t.Fatalf("explicit profile result = workspace=%q language=%q", testingCfg.Workspace.Root, testingCfg.WhisperX.Language)
|
||||
}
|
||||
assertSelectedPipelineProfile(t, testingCfg, "testing", "cli", testingPath)
|
||||
}
|
||||
|
||||
func TestLoadPipelineProfileSelectionErrors(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
root string
|
||||
profile *string
|
||||
want string
|
||||
}{
|
||||
{name: "profiles require selection", root: profileComposition("", "production"), want: "default_profile is omitted"},
|
||||
{name: "unknown explicit", root: profileComposition("production", "production"), profile: profilePointer("unknown"), want: "not declared"},
|
||||
{name: "stacked explicit", root: profileComposition("production", "production", "testing"), profile: profilePointer("production,testing"), want: "not declared"},
|
||||
{name: "explicit empty", root: profileComposition("production", "production"), profile: profilePointer(""), want: "must be non-empty"},
|
||||
{name: "explicit whitespace", root: profileComposition("production", "production"), profile: profilePointer(" production "), want: "surrounding whitespace"},
|
||||
{name: "invalid default", root: profileComposition("unknown", "production"), want: "does not name a declared profile"},
|
||||
{name: "empty default", root: profileComposition("EMPTY", "production"), want: "must be non-empty"},
|
||||
{name: "empty profile name", root: `composition:
|
||||
default_profile: production
|
||||
profiles:
|
||||
"":
|
||||
overlay: production.yml
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`, want: "profile name must be non-empty"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := test.root
|
||||
if test.name == "empty default" {
|
||||
root = strings.ReplaceAll(root, "default_profile: EMPTY", `default_profile: ""`)
|
||||
}
|
||||
rootPath := writeProfilePipeline(t, dir, root)
|
||||
writePipelineSource(t, dir, "production.yml", "whisperx:\n language: en\n")
|
||||
_, err := LoadPipelineWithOptions(rootPath, PipelineLoadOptions{Profile: test.profile})
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(test.want)) {
|
||||
t.Fatalf("LoadPipelineWithOptions() error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("explicit against profile-free pipeline", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeProfilePipeline(t, dir, testPipelineBaseYAML)
|
||||
selected := "testing"
|
||||
_, err := LoadPipelineWithOptions(rootPath, PipelineLoadOptions{Profile: &selected})
|
||||
if err == nil || !strings.Contains(err.Error(), "declares no profiles") {
|
||||
t.Fatalf("error = %v, want profile-free rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPipelineProfileNamesRejectControlCharacters(t *testing.T) {
|
||||
if _, err := normalizePipelineProfileName("production\x00testing", "profile"); err == nil || !strings.Contains(err.Error(), "control characters") {
|
||||
t.Fatalf("normalizePipelineProfileName() error = %v, want control-character rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPipelineProfilesValidateEveryDeclaredOverlay(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path string
|
||||
content string
|
||||
setup func(*testing.T, string)
|
||||
want string
|
||||
}{
|
||||
{name: "missing", path: "profiles/missing.yml", want: "open composition.profiles.testing.overlay"},
|
||||
{name: "malformed", path: "profiles/testing.yml", content: "workspace: [\n", want: "decode yaml"},
|
||||
{name: "duplicate keys", path: "profiles/testing.yml", content: "workspace:\n root: /one\n root: /two\n", want: "duplicate yaml key"},
|
||||
{name: "trailing document", path: "profiles/testing.yml", content: "workspace:\n root: /one\n---\nworkspace:\n root: /two\n", want: "exactly one yaml document"},
|
||||
{name: "nested composition", path: "profiles/testing.yml", content: "composition:\n imports: [nested.yml]\n", want: "only the root pipeline"},
|
||||
{name: "inheritance", path: "profiles/testing.yml", content: "workspace:\n root: /testing\n", setup: func(t *testing.T, dir string) {
|
||||
rootPath := filepath.Join(dir, "pipeline.yml")
|
||||
data, err := os.ReadFile(rootPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated := strings.Replace(string(data), "testing:\n overlay:", "testing:\n extends: production\n overlay:", 1)
|
||||
if err := os.WriteFile(rootPath, []byte(updated), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, want: "only overlay is supported"},
|
||||
{name: "traversal", path: "../testing.yml", want: "invalid"},
|
||||
{name: "extension", path: "profiles/testing.json", content: "{}\n", want: ".yml or .yaml"},
|
||||
{name: "directory", path: "profiles/testing.yml", setup: func(t *testing.T, dir string) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, "profiles/testing.yml"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, want: "regular file"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeProfilePipeline(t, dir, `composition:
|
||||
default_profile: production
|
||||
profiles:
|
||||
production:
|
||||
overlay: profiles/production.yml
|
||||
testing:
|
||||
overlay: `+test.path+`
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
writePipelineSource(t, dir, "profiles/production.yml", "whisperx:\n language: en\n")
|
||||
if test.content != "" {
|
||||
writePipelineSource(t, dir, test.path, test.content)
|
||||
}
|
||||
if test.setup != nil {
|
||||
test.setup(t, dir)
|
||||
}
|
||||
_, err := LoadPipeline(rootPath)
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(test.want)) {
|
||||
t.Fatalf("LoadPipeline() error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPipelineProfileOverlaySemantics(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeProfilePipeline(t, dir, `composition:
|
||||
default_profile: testing
|
||||
profiles:
|
||||
testing:
|
||||
overlay: testing.yml
|
||||
workspace:
|
||||
root: /base
|
||||
cleanup_after_publish: true
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
retries: 3
|
||||
audita:
|
||||
modules: [base, shared]
|
||||
scriptorium:
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: true
|
||||
prompt_id: dnd.session_recap
|
||||
output_path: artifacts/session_recap.md
|
||||
`)
|
||||
writePipelineSource(t, dir, "testing.yml", `workspace:
|
||||
cleanup_after_publish: false
|
||||
whisperx:
|
||||
retries: 0
|
||||
audita:
|
||||
modules: []
|
||||
scriptorium:
|
||||
artifacts:
|
||||
session_recap:
|
||||
enabled: false
|
||||
experimental:
|
||||
enabled: false
|
||||
`)
|
||||
|
||||
cfg, err := LoadPipeline(rootPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Workspace.Root != "/base" || cfg.Workspace.CleanupAfterPublish || cfg.WhisperX.Retries == nil || *cfg.WhisperX.Retries != 0 {
|
||||
t.Fatalf("recursive/zero overlay = workspace=%#v retries=%#v", cfg.Workspace, cfg.WhisperX.Retries)
|
||||
}
|
||||
if cfg.Audita.Modules == nil || len(cfg.Audita.Modules) != 0 {
|
||||
t.Fatalf("list replacement = %#v, want explicit empty list", cfg.Audita.Modules)
|
||||
}
|
||||
if cfg.Scriptorium == nil || cfg.Scriptorium.Artifacts["session_recap"].Enabled || len(cfg.Scriptorium.Artifacts) != 2 {
|
||||
t.Fatalf("keyed artifact overlay = %#v", cfg.Scriptorium)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPipelineProfileRejectsNullAndKindChanges(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
overlay string
|
||||
want string
|
||||
}{
|
||||
{name: "null deletion", overlay: "workspace:\n root: null\n", want: "null cannot delete"},
|
||||
{name: "mapping scalar", overlay: "workspace: /other\n", want: "kind change"},
|
||||
{name: "list mapping", overlay: "audita:\n modules:\n testing: true\n", want: "kind change"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeProfilePipeline(t, dir, `composition:
|
||||
default_profile: testing
|
||||
profiles:
|
||||
testing:
|
||||
overlay: testing.yml
|
||||
workspace:
|
||||
root: /base
|
||||
audita:
|
||||
modules: [base]
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
writePipelineSource(t, dir, "testing.yml", test.overlay)
|
||||
_, err := LoadPipeline(rootPath)
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(test.want)) {
|
||||
t.Fatalf("LoadPipeline() error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineEffectiveDigestTracksNormalizedMeaning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
monolithicPath := writePipelineSource(t, dir, "monolithic.yml", `workspace:
|
||||
root: /srv/narratio
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
language: en
|
||||
`)
|
||||
composedPath := writePipelineSource(t, dir, "pipeline.yml", `composition:
|
||||
imports: [workspace.yml]
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
language: en
|
||||
`)
|
||||
writePipelineSource(t, dir, "workspace.yml", "workspace:\n root: /srv/narratio\n")
|
||||
|
||||
monolithic, err := LoadPipeline(monolithicPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
composed, err := LoadPipeline(composedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if monolithic.resolution.effectiveDigest == "" || monolithic.resolution.effectiveDigest != composed.resolution.effectiveDigest {
|
||||
t.Fatalf("equal effective results have digests %q and %q", monolithic.resolution.effectiveDigest, composed.resolution.effectiveDigest)
|
||||
}
|
||||
|
||||
changedPath := writePipelineSource(t, dir, "changed.yml", `workspace:
|
||||
root: /srv/narratio
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
language: fr
|
||||
`)
|
||||
changed, err := LoadPipeline(changedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if changed.resolution.effectiveDigest == monolithic.resolution.effectiveDigest {
|
||||
t.Fatal("semantic value change retained effective digest")
|
||||
}
|
||||
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", "secret-one")
|
||||
first, err := LoadPipeline(monolithicPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("AWS_SECRET_ACCESS_KEY", "secret-two")
|
||||
second, err := LoadPipeline(monolithicPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.resolution.effectiveDigest != second.resolution.effectiveDigest || strings.Contains(first.resolution.effectiveDigest, "secret") {
|
||||
t.Fatalf("environment secret affected or appeared in digest: %q / %q", first.resolution.effectiveDigest, second.resolution.effectiveDigest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePipelineValuesIgnoreEquivalentSourceLayout(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
monolithicPath := writePipelineSource(t, dir, "monolithic.yml", `workspace:
|
||||
root: /srv/narratio
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
language: en
|
||||
`)
|
||||
composedPath := writePipelineSource(t, dir, "pipeline.yml", `composition:
|
||||
imports: [workspace.yml]
|
||||
whisperx:
|
||||
language: en
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
writePipelineSource(t, dir, "workspace.yml", "workspace:\n root: /srv/narratio\n")
|
||||
|
||||
monolithic, err := LoadPipeline(monolithicPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
composed, err := LoadPipeline(composedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
monolithicValues, err := EffectivePipelineValues(monolithic)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
composedValues, err := EffectivePipelineValues(composed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(monolithicValues, composedValues) {
|
||||
t.Fatalf("equivalent effective values differ:\nmonolithic=%#v\ncomposed=%#v", monolithicValues, composedValues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineEffectiveDigestExcludesProfileIdentity(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing"))
|
||||
overlay := "workspace:\n root: /srv/narratio\n"
|
||||
writePipelineSource(t, dir, "production.yml", overlay)
|
||||
writePipelineSource(t, dir, "testing.yml", overlay)
|
||||
|
||||
production := "production"
|
||||
productionCfg, err := LoadPipelineWithOptions(rootPath, PipelineLoadOptions{Profile: &production})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testing := "testing"
|
||||
testingCfg, err := LoadPipelineWithOptions(rootPath, PipelineLoadOptions{Profile: &testing})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if productionCfg.resolution.effectiveDigest != testingCfg.resolution.effectiveDigest {
|
||||
t.Fatalf("profile identity changed equal effective digests: %q / %q", productionCfg.resolution.effectiveDigest, testingCfg.resolution.effectiveDigest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPipelineProfilePairResolvesIndependentEffectivePipelines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeProfilePipeline(t, dir, `composition:
|
||||
imports: [base.yml]
|
||||
default_profile: production
|
||||
profiles:
|
||||
production:
|
||||
overlay: production.yml
|
||||
testing:
|
||||
overlay: testing.yml
|
||||
whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
writePipelineSource(t, dir, "base.yml", "workspace:\n root: /srv/base\n")
|
||||
writePipelineSource(t, dir, "production.yml", "workspace:\n root: /srv/production\nwhisperx:\n language: en\n")
|
||||
writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /srv/testing\nwhisperx:\n language: fr\n")
|
||||
|
||||
production, testing, err := LoadPipelineProfilePair(rootPath, "production", "testing")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if production.Workspace.Root != "/srv/production" || production.WhisperX.Language != "en" {
|
||||
t.Fatalf("production pair result = %#v", production)
|
||||
}
|
||||
if testing.Workspace.Root != "/srv/testing" || testing.WhisperX.Language != "fr" {
|
||||
t.Fatalf("testing pair result = %#v", testing)
|
||||
}
|
||||
production.Workspace.Root = "/mutated-left"
|
||||
if testing.Workspace.Root != "/srv/testing" {
|
||||
t.Fatalf("right profile shared mutable state with left: %q", testing.Workspace.Root)
|
||||
}
|
||||
testingFirst, productionSecond, err := LoadPipelineProfilePair(rootPath, "testing", "production")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if testingFirst.Workspace.Root != "/srv/testing" || productionSecond.Workspace.Root != "/srv/production" {
|
||||
t.Fatalf("reversed profile pair = testing=%q production=%q", testingFirst.Workspace.Root, productionSecond.Workspace.Root)
|
||||
}
|
||||
|
||||
if _, _, err := LoadPipelineProfilePair(rootPath, "production", "production"); err == nil || !strings.Contains(err.Error(), "must differ") {
|
||||
t.Fatalf("equal profile pair error = %v, want selection rejection", err)
|
||||
}
|
||||
if _, _, err := LoadPipelineProfilePair(rootPath, "unknown", "testing"); err == nil || !strings.Contains(err.Error(), "not declared") {
|
||||
t.Fatalf("unknown profile pair error = %v, want selection rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithSessionOptionsCarriesExplicitProfilePresence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := writeProfilePipeline(t, dir, profileComposition("production", "production", "testing"))
|
||||
writePipelineSource(t, dir, "production.yml", "workspace:\n root: /production\n")
|
||||
writePipelineSource(t, dir, "testing.yml", "workspace:\n root: /testing\n")
|
||||
campaignPath := writePipelineSource(t, dir, "campaign.yml", "campaign_id: 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")
|
||||
writePipelineSource(t, dir, "party.yml", "legacy: party\n")
|
||||
sessionPath := writePipelineSource(t, dir, "session.yml", "session_id: session\ncampaign: campaign\n")
|
||||
selected := "testing"
|
||||
cfg, err := LoadWithSessionOptions(rootPath, campaignPath, sessionPath, SessionLoadOptions{Profile: &selected})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Pipeline.Workspace.Root != "/testing" || cfg.Pipeline.resolution.selectedProfile.source != "cli" {
|
||||
t.Fatalf("combined profile result = workspace=%q metadata=%#v", cfg.Pipeline.Workspace.Root, cfg.Pipeline.resolution.selectedProfile)
|
||||
}
|
||||
}
|
||||
|
||||
func writeProfilePipeline(t *testing.T, dir, content string) string {
|
||||
t.Helper()
|
||||
return writePipelineSource(t, dir, "pipeline.yml", content)
|
||||
}
|
||||
|
||||
func profileComposition(defaultProfile string, profiles ...string) string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString("composition:\n")
|
||||
if defaultProfile != "" {
|
||||
builder.WriteString(" default_profile: " + defaultProfile + "\n")
|
||||
}
|
||||
builder.WriteString(" profiles:\n")
|
||||
for _, profile := range profiles {
|
||||
builder.WriteString(" " + profile + ":\n overlay: " + profile + ".yml\n")
|
||||
}
|
||||
builder.WriteString("whisperx:\n transcribe_url: https://transcription.example.com/transcribe\n")
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func profilePointer(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func assertSelectedPipelineProfile(t *testing.T, cfg *PipelineConfig, name, source, overlayPath string) {
|
||||
t.Helper()
|
||||
if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedProfile == nil {
|
||||
t.Fatal("selected profile provenance is absent")
|
||||
}
|
||||
selection := cfg.resolution.selectedProfile
|
||||
if selection.name != name || selection.source != source || selection.overlayPath != absolutePath(t, overlayPath) {
|
||||
t.Fatalf("selected profile = %#v, want name=%q source=%q overlay=%q", selection, name, source, absolutePath(t, overlayPath))
|
||||
}
|
||||
}
|
||||
@@ -31,15 +31,18 @@ func Validate(cfg *Config) error {
|
||||
return fmt.Errorf("session config is required")
|
||||
}
|
||||
|
||||
if err := validatePipeline(cfg.Pipeline); err != nil {
|
||||
if err := ValidatePipelineConfig(cfg.Pipeline); err != nil {
|
||||
return fmt.Errorf("pipeline config %q invalid: %w", shortName(cfg.PipelinePath, "pipeline.yml"), err)
|
||||
}
|
||||
if err := validateCampaign(cfg.Campaign); err != nil {
|
||||
if err := ValidateCampaignConfig(cfg.Campaign); err != nil {
|
||||
return fmt.Errorf("campaign config %q invalid: %w", shortName(cfg.CampaignPath, "campaign.yml"), err)
|
||||
}
|
||||
if err := validateSession(cfg.Session); err != nil {
|
||||
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
|
||||
}
|
||||
if err := validateResolvedPartyInputs(cfg); err != nil {
|
||||
return fmt.Errorf("campaign/session config invalid: %w", err)
|
||||
}
|
||||
if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
|
||||
return fmt.Errorf("pipeline/session config invalid: %w", err)
|
||||
}
|
||||
@@ -47,6 +50,23 @@ func Validate(cfg *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatePipelineConfig validates a loaded, defaulted pipeline without
|
||||
// requiring session configuration. Callers that require party-driven artifact
|
||||
// expansion must resolve a campaign first through LoadPipelineCampaign.
|
||||
func ValidatePipelineConfig(cfg *PipelineConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("pipeline config is required")
|
||||
}
|
||||
return validatePipeline(cfg)
|
||||
}
|
||||
|
||||
// ValidateCampaignConfig validates a loaded campaign without requiring a
|
||||
// session. Party parsing and canonical-party checks remain owned by
|
||||
// LoadPipelineCampaign, which has the campaign source path available.
|
||||
func ValidateCampaignConfig(cfg *CampaignConfig) error {
|
||||
return validateCampaign(cfg)
|
||||
}
|
||||
|
||||
func validateCampaign(cfg *CampaignConfig) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("campaign config is required")
|
||||
@@ -66,9 +86,6 @@ func validateCampaign(cfg *CampaignConfig) error {
|
||||
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
||||
return fmt.Errorf("campaign.inputs.glossary_file is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
|
||||
return fmt.Errorf("campaign.inputs.players_file is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
||||
return fmt.Errorf("campaign.inputs.party_file is required")
|
||||
}
|
||||
@@ -671,6 +688,7 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error
|
||||
|
||||
configuredArtifacts := make(map[string]struct{}, len(cfg.Artifacts))
|
||||
referencedArtifacts := make(map[string]struct{})
|
||||
outputOwners := make(map[string]string, len(cfg.Artifacts))
|
||||
for artifactName := range cfg.Artifacts {
|
||||
if !artifactpolicy.IsConfiguredKey(artifactName) {
|
||||
return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$")
|
||||
@@ -693,6 +711,10 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error
|
||||
if err := validatePathWithinRoot(pathField, artifactCfg.OutputPath, DefaultScriptoriumArtifactOutputRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if previous, exists := outputOwners[artifactCfg.OutputPath]; exists {
|
||||
return fmt.Errorf("%s.output_path %q duplicates artifact %q", pathField, artifactCfg.OutputPath, previous)
|
||||
}
|
||||
outputOwners[artifactCfg.OutputPath] = artifactName
|
||||
}
|
||||
if err := validateDuration("pipeline.scriptorium.artifacts."+artifactName+".timeout", artifactCfg.Timeout); err != nil {
|
||||
return err
|
||||
@@ -794,12 +816,6 @@ func validateSession(cfg *SessionConfig) error {
|
||||
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
||||
return fmt.Errorf("session.inputs.glossary_file is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Inputs.PlayersFile) == "" {
|
||||
return fmt.Errorf("session.inputs.players_file is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
||||
return fmt.Errorf("session.inputs.party_file is required")
|
||||
}
|
||||
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
|
||||
return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
|
||||
}
|
||||
@@ -825,6 +841,31 @@ func validateSession(cfg *SessionConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateResolvedPartyInputs(cfg *Config) error {
|
||||
if cfg == nil || cfg.Session == nil {
|
||||
return fmt.Errorf("session config is required")
|
||||
}
|
||||
if cfg.Party.Mode == PartyModeCanonical {
|
||||
if cfg.Party.Canonical == nil {
|
||||
return fmt.Errorf("canonical party data is required")
|
||||
}
|
||||
if cfg.Party.Source.Source != "campaign_config" {
|
||||
return fmt.Errorf("canonical party must be sourced by campaign_config")
|
||||
}
|
||||
if strings.TrimSpace(cfg.StableInputs.PlayersFile.Source) != "derived_from_party" || strings.TrimSpace(cfg.StableInputs.PlayersFile.Path) != "" {
|
||||
return fmt.Errorf("canonical party requires derived players input")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.Session.Inputs.PlayersFile) == "" || strings.TrimSpace(cfg.StableInputs.PlayersFile.Path) == "" {
|
||||
return fmt.Errorf("players input is required with a legacy party")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Session.Inputs.PartyFile) == "" || strings.TrimSpace(cfg.StableInputs.PartyFile.Path) == "" {
|
||||
return fmt.Errorf("party input is required with a legacy party")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSessionIdentifier(fieldName, value string, required bool) error {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
if required {
|
||||
|
||||
@@ -60,9 +60,58 @@ func TestReleaseWorkflowRequiresValidation(t *testing.T) {
|
||||
if err := yaml.Unmarshal(data, &workflow); err != nil {
|
||||
t.Fatalf("parse release workflow: %v", err)
|
||||
}
|
||||
if !workflowDependsOn(workflow.Steps, "publish-release", "validate", map[string]bool{}) {
|
||||
t.Fatal("publish-release must depend on validate so validation failures block releases")
|
||||
validate, ok := workflow.Steps["validate-release"]
|
||||
if !ok {
|
||||
t.Fatal("release workflow must define validate-release")
|
||||
}
|
||||
if validate.Image != "golang:1.25.5" || !containsWorkflowCommand(validate.Commands, `./scripts/check-release-candidate.sh "$CI_COMMIT_TAG"`) {
|
||||
t.Fatalf("validate-release = %#v, want the shared candidate checker in golang:1.25.5", validate)
|
||||
}
|
||||
assets, ok := workflow.Steps["build-release-assets"]
|
||||
if !ok {
|
||||
t.Fatal("release workflow must define build-release-assets")
|
||||
}
|
||||
if assets.Image != "golang:1.25.5" || !workflowDependsOn(workflow.Steps, "build-release-assets", "validate-release", map[string]bool{}) {
|
||||
t.Fatalf("build-release-assets = %#v, want validated golang:1.25.5 asset construction", assets)
|
||||
}
|
||||
assetCommands := strings.Join(assets.Commands, "\n")
|
||||
if !strings.Contains(assetCommands, `./scripts/build-release-assets.sh "$CI_COMMIT_TAG" "$PWD/dist"`) || strings.Contains(assetCommands, "GOOS=") || strings.Contains(assetCommands, "go build") {
|
||||
t.Fatalf("build-release-assets commands must delegate target construction: %q", assetCommands)
|
||||
}
|
||||
publish, ok := workflow.Steps["publish-release"]
|
||||
if !ok {
|
||||
t.Fatal("release workflow must define publish-release")
|
||||
}
|
||||
if publish.Image != "woodpeckerci/plugin-release:0.3.1" || !workflowDependsOn(workflow.Steps, "publish-release", "validate-release", map[string]bool{}) || !workflowDependsOn(workflow.Steps, "publish-release", "build-release-assets", map[string]bool{}) {
|
||||
t.Fatalf("publish-release = %#v, want transitive validation and asset dependencies", publish)
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
"title": "Narratio ${CI_COMMIT_TAG}",
|
||||
"note": "docs/releases/${CI_COMMIT_TAG}.md",
|
||||
"checksum": "sha256",
|
||||
"checksum-file": "SHA256SUMS",
|
||||
"checksum-flatten": true,
|
||||
"file-exists": "skip",
|
||||
"overwrite": false,
|
||||
"prerelease": false,
|
||||
} {
|
||||
if got := publish.Settings[key]; got != want {
|
||||
t.Fatalf("publish-release setting %q = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
files, ok := publish.Settings["files"].([]any)
|
||||
if !ok || len(files) != 1 || files[0] != "dist/narratio-*" {
|
||||
t.Fatalf("publish-release files = %#v", publish.Settings["files"])
|
||||
}
|
||||
}
|
||||
|
||||
func containsWorkflowCommand(commands []string, want string) bool {
|
||||
for _, command := range commands {
|
||||
if strings.Contains(command, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func repositoryRoot(t *testing.T) string {
|
||||
@@ -130,6 +179,9 @@ type woodpeckerWorkflow struct {
|
||||
|
||||
type woodpeckerStep struct {
|
||||
DependsOn woodpeckerDependencies `yaml:"depends_on"`
|
||||
Image string `yaml:"image"`
|
||||
Commands []string `yaml:"commands"`
|
||||
Settings map[string]any `yaml:"settings"`
|
||||
}
|
||||
|
||||
type woodpeckerDependencies []string
|
||||
|
||||
@@ -53,6 +53,8 @@ type AnalyzeArtifactRecord struct {
|
||||
Status AnalyzeArtifactStatus `json:"status"`
|
||||
FingerprintVersion int `json:"fingerprint_version,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Family string `json:"family,omitempty"`
|
||||
CharacterID string `json:"character_id,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
Output *ArtifactRecord `json:"output,omitempty"`
|
||||
OutputSize int64 `json:"output_size,omitempty"`
|
||||
@@ -134,6 +136,14 @@ func validateAnalyzeArtifactRecord(mapKey string, record AnalyzeArtifactRecord)
|
||||
if err := validateAnalyzeDependencies(record.Dependencies); err != nil {
|
||||
return err
|
||||
}
|
||||
if (record.Family == "") != (record.CharacterID == "") {
|
||||
return fmt.Errorf("family and character_id must be present together")
|
||||
}
|
||||
if record.Family != "" {
|
||||
if !artifactpolicy.IsConfiguredKey(record.Family) || !artifactpolicy.IsConfiguredKey(record.CharacterID) {
|
||||
return fmt.Errorf("family and character_id must match ^[a-z][a-z0-9_]*$")
|
||||
}
|
||||
}
|
||||
if err := validateAnalyzeFingerprint(record.FingerprintVersion, record.Fingerprint, record.Status == AnalyzeArtifactCurrent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T
|
||||
records["session_recap"] = func() AnalyzeArtifactRecord {
|
||||
record := records["session_recap"]
|
||||
record.Dependencies = []string{"quest_log", "gm_notes"}
|
||||
record.Family = "character_meta"
|
||||
record.CharacterID = "arannis"
|
||||
return record
|
||||
}()
|
||||
|
||||
@@ -74,6 +76,9 @@ func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T
|
||||
if got := analyze.AnalyzeArtifacts["session_recap"].Dependencies; !reflect.DeepEqual(got, []string{"gm_notes", "quest_log"}) {
|
||||
t.Fatalf("canonical dependencies = %#v", got)
|
||||
}
|
||||
if got := analyze.AnalyzeArtifacts["session_recap"]; got.Family != "character_meta" || got.CharacterID != "arannis" {
|
||||
t.Fatalf("family provenance = %#v", got)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -55,6 +55,7 @@ type StageRecord struct {
|
||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||
Error *ErrorRecord `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
SemanticConfig *SemanticConfigFingerprint `json:"semantic_config,omitempty"`
|
||||
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||
}
|
||||
@@ -78,6 +79,20 @@ type PostPublishCleanup struct {
|
||||
Targets []CleanupTarget `json:"targets"`
|
||||
}
|
||||
|
||||
// EffectiveConfigProvenance records bounded non-secret configuration identity
|
||||
// for one resolved invocation.
|
||||
type EffectiveConfigProvenance struct {
|
||||
SelectedProfile *SelectedProfileProvenance `json:"selected_profile,omitempty"`
|
||||
EffectiveConfigDigest string `json:"effective_config_digest,omitempty"`
|
||||
}
|
||||
|
||||
// SelectedProfileProvenance identifies an explicitly or default-selected
|
||||
// pipeline profile without recording profile content.
|
||||
type SelectedProfileProvenance struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// Manifest is the durable run-state record for a session execution.
|
||||
type Manifest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
@@ -89,6 +104,7 @@ type Manifest struct {
|
||||
S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
|
||||
S3RunPrefix string `json:"s3_run_prefix,omitempty"`
|
||||
PipelineVersion string `json:"pipeline_version,omitempty"`
|
||||
EffectiveConfig *EffectiveConfigProvenance `json:"effective_config,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastError *ErrorRecord `json:"last_error,omitempty"`
|
||||
@@ -177,6 +193,7 @@ func (s *StageRecord) clearResultDetails() {
|
||||
s.Logs = nil
|
||||
s.GeneratedConfigs = nil
|
||||
s.Metadata = nil
|
||||
s.SemanticConfig = nil
|
||||
}
|
||||
|
||||
func (m *Manifest) ensureStage(name string, at time.Time) *StageRecord {
|
||||
|
||||
@@ -34,6 +34,7 @@ type RunStageRecord struct {
|
||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||
Error *ErrorRecord `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
SemanticConfig *SemanticConfigFingerprint `json:"semantic_config,omitempty"`
|
||||
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||
}
|
||||
@@ -51,6 +52,7 @@ type RunManifest struct {
|
||||
S3Bucket string `json:"s3_bucket,omitempty"`
|
||||
S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
|
||||
S3RunPrefix string `json:"s3_run_prefix,omitempty"`
|
||||
EffectiveConfig *EffectiveConfigProvenance `json:"effective_config,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
|
||||
32
internal/manifest/semantic_fingerprint.go
Normal file
32
internal/manifest/semantic_fingerprint.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var lowercaseSHA256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// SemanticConfigFingerprint identifies the versioned, result-affecting
|
||||
// configuration observed by one pipeline stage.
|
||||
type SemanticConfigFingerprint struct {
|
||||
Version int `json:"version"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// Validate checks the durable fingerprint contract.
|
||||
func (f SemanticConfigFingerprint) Validate() error {
|
||||
if f.Version <= 0 {
|
||||
return fmt.Errorf("version must be positive")
|
||||
}
|
||||
if !lowercaseSHA256Pattern.MatchString(f.Digest) {
|
||||
return fmt.Errorf("digest must be a lowercase SHA-256 value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Equal reports whether two valid fingerprint records identify the same
|
||||
// semantic configuration contract and payload.
|
||||
func (f SemanticConfigFingerprint) Equal(other SemanticConfigFingerprint) bool {
|
||||
return f.Version == other.Version && f.Digest == other.Digest
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user