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
|
- event: tag
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
validate:
|
validate-release:
|
||||||
image: golang:1.25
|
image: golang:1.25.5
|
||||||
commands:
|
commands:
|
||||||
- go test ./...
|
- ./scripts/check-release-candidate.sh "$CI_COMMIT_TAG"
|
||||||
- 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
|
|
||||||
|
|
||||||
build-release-assets:
|
build-release-assets:
|
||||||
image: golang:1.25
|
image: golang:1.25.5
|
||||||
depends_on: [validate, cross-build]
|
depends_on:
|
||||||
|
- validate-release
|
||||||
commands:
|
commands:
|
||||||
- |
|
- |
|
||||||
set -eu
|
set -eu
|
||||||
|
case "$PWD" in
|
||||||
version="$CI_COMMIT_TAG"
|
/*) ;;
|
||||||
dist="dist"
|
*) echo "release workspace must have an absolute path" >&2; exit 1 ;;
|
||||||
pkg="gitea.maximumdirect.net/eric/narratio/cmd/narratio"
|
esac
|
||||||
|
./scripts/build-release-assets.sh "$CI_COMMIT_TAG" "$PWD/dist"
|
||||||
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
|
|
||||||
|
|
||||||
publish-release:
|
publish-release:
|
||||||
image: woodpeckerci/plugin-release
|
image: woodpeckerci/plugin-release:0.3.1
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-release-assets
|
- build-release-assets
|
||||||
settings:
|
settings:
|
||||||
@@ -77,6 +29,8 @@ steps:
|
|||||||
from_secret: GITEA_RELEASE_TOKEN
|
from_secret: GITEA_RELEASE_TOKEN
|
||||||
files:
|
files:
|
||||||
- dist/narratio-*
|
- dist/narratio-*
|
||||||
|
title: Narratio ${CI_COMMIT_TAG}
|
||||||
|
note: docs/releases/${CI_COMMIT_TAG}.md
|
||||||
checksum: sha256
|
checksum: sha256
|
||||||
checksum-file: SHA256SUMS
|
checksum-file: SHA256SUMS
|
||||||
checksum-flatten: true
|
checksum-flatten: true
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ This requires resolvable `pipeline.yml`, `campaign.yml`, and concrete
|
|||||||
- [Integration contracts](docs/integrations/) — external tools, formats, and
|
- [Integration contracts](docs/integrations/) — external tools, formats, and
|
||||||
compatibility expectations.
|
compatibility expectations.
|
||||||
- [Maintained examples](examples/README.md) — complete copyable configuration
|
- [Maintained examples](examples/README.md) — complete copyable configuration
|
||||||
and input files.
|
and input files, including the production/testing split bundle.
|
||||||
|
|
||||||
## Maintainer Documentation
|
## Maintainer Documentation
|
||||||
|
|
||||||
|
|||||||
71
docs/cli.md
71
docs/cli.md
@@ -20,6 +20,7 @@ Top-level commands:
|
|||||||
- `publish <session_id>`: force-run publish.
|
- `publish <session_id>`: force-run publish.
|
||||||
- `clean <session_id>` or `clean --all`: remove local work/spool state.
|
- `clean <session_id>` or `clean --all`: remove local work/spool state.
|
||||||
- `session <subcommand>`: session helper commands.
|
- `session <subcommand>`: session helper commands.
|
||||||
|
- `config <subcommand>`: validate, display, source-trace, or compare resolved pipeline configuration.
|
||||||
|
|
||||||
Session subcommands:
|
Session subcommands:
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ Most session-aware commands accept:
|
|||||||
- `--session <session.yml>`
|
- `--session <session.yml>`
|
||||||
- `--session-id <session_id>`
|
- `--session-id <session_id>`
|
||||||
- `--previous-session-id <session_id>`
|
- `--previous-session-id <session_id>`
|
||||||
|
- `--profile <name>`
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
@@ -51,6 +53,10 @@ Rules:
|
|||||||
- if both positional `<session_id>` and `--session-id` are provided, values must match.
|
- if both positional `<session_id>` and `--session-id` are provided, values must match.
|
||||||
- `--previous-session-id` is a strict expectation: the selected session file
|
- `--previous-session-id` is a strict expectation: the selected session file
|
||||||
must contain the same `previous_session_id`.
|
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.
|
- `clean --all` cannot be combined with campaign/session selectors.
|
||||||
- notification delivery is currently limited to the configured `noop` mode; see
|
- notification delivery is currently limited to the configured `noop` mode; see
|
||||||
the [configuration reference](./config.md#notifications).
|
the [configuration reference](./config.md#notifications).
|
||||||
@@ -72,6 +78,62 @@ Commands with additional positionals keep their command-specific order:
|
|||||||
|
|
||||||
## Command Reference
|
## 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`
|
### `version`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -102,6 +164,7 @@ Behavior:
|
|||||||
`--name=value` spellings;
|
`--name=value` spellings;
|
||||||
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- writes session and run manifests.
|
- writes session and run manifests.
|
||||||
|
- reports the resolved profile (or `none`) and effective configuration digest.
|
||||||
|
|
||||||
When `--artifacts` is present, the selected range must contain `analyze` or
|
When `--artifacts` is present, the selected range must contain `analyze` or
|
||||||
`publish`. Either consumer is sufficient, including a one-stage range.
|
`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
|
selection contract as `run`. It validates config and prints run/skip decisions
|
||||||
for selected stages only without creating the local workdir or changing the
|
for selected stages only without creating the local workdir or changing the
|
||||||
manifest. Resume-capable selected stages are checked against durable evidence.
|
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,
|
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
|
||||||
execution order, and reusable current artifacts with concise reasons. These
|
execution order, and reusable current artifacts with concise reasons. These
|
||||||
artifact decisions come from the same reconciliation and work planner used by
|
artifact decisions come from the same reconciliation and work planner used by
|
||||||
@@ -302,6 +367,12 @@ and precedence.
|
|||||||
|
|
||||||
## `--artifacts` Selection Rules
|
## `--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`;
|
- accepted on `run`, `session plan`, `run-stage`, `analyze`, and `publish`;
|
||||||
- repeatable and comma-separated values are combined, surrounding whitespace
|
- repeatable and comma-separated values are combined, surrounding whitespace
|
||||||
is removed, and duplicate names are collapsed;
|
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 command finishes and records only the remote object provenance alongside
|
||||||
the durable copied session input.
|
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
|
### Identity segments
|
||||||
|
|
||||||
Campaign IDs (`campaign_id` and `default_campaign_id`), session IDs, previous
|
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:
|
- YAML decode is strict (`KnownFields(true)`) and accepts exactly one document:
|
||||||
unknown fields or trailing documents fail load.
|
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
|
- Configured timeout and retry-delay durations must be positive. An omitted
|
||||||
artifact timeout continues to inherit its configured Scriptorium timeout.
|
artifact timeout continues to inherit its configured Scriptorium timeout.
|
||||||
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
|
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
|
||||||
- Pipeline defaults are applied before validation.
|
- Pipeline defaults are applied before validation.
|
||||||
- Campaign and session identities must agree.
|
- Campaign and session identities must agree.
|
||||||
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,
|
||||||
`players_file`, `party_file`) and the optional `spell_catalog_file` resolve
|
`party_file`) and the optional `spell_catalog_file` resolve from session
|
||||||
from session overrides when provided, otherwise from campaign defaults. An
|
overrides when provided, otherwise from campaign defaults. An empty or
|
||||||
empty or omitted session spell-catalog value inherits the campaign value.
|
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:
|
- Exactly one audio mode must be configured in session input:
|
||||||
- local (`audio_dir` or `audio_files`), or
|
- local (`audio_dir` or `audio_files`), or
|
||||||
- S3 (`audio_s3.prefix`).
|
- S3 (`audio_s3.prefix`).
|
||||||
|
|
||||||
|
### 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
|
## Minimal Working Configuration
|
||||||
|
|
||||||
`pipeline.yml`
|
`pipeline.yml`
|
||||||
@@ -155,6 +280,9 @@ Rules:
|
|||||||
|
|
||||||
| Field | Type | Required | Default / Rule |
|
| 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.root` | string | No | `/var/lib/narratio` |
|
||||||
| `pipeline.workspace.cleanup_after_publish` | bool | No | `false` |
|
| `pipeline.workspace.cleanup_after_publish` | bool | No | `false` |
|
||||||
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
|
| `pipeline.campaigns.root` | string | No | `/usr/local/share/narratio/campaigns` |
|
||||||
@@ -243,6 +371,7 @@ Rules:
|
|||||||
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
|
| `pipeline.scriptorium.timeout` | duration | No | `10m` |
|
||||||
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
|
| `pipeline.scriptorium.render_debug` | bool | No | `false` |
|
||||||
| `pipeline.scriptorium.artifacts` | map | No | empty |
|
| `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 |
|
| `pipeline.notification.mode` | string | No | `noop`; the only supported notification mode until a provider is implemented |
|
||||||
|
|
||||||
### Notarius Reference Bindings
|
### 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
|
loading. Use the canonical `source` identifier to select the input; Narratio
|
||||||
does not provide adapter-specific input passthrough fields.
|
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
|
### Notifications
|
||||||
|
|
||||||
Narratio currently supports only `notification.mode: noop`, which is also the
|
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.speakers_file` | string | Yes | stable input default |
|
||||||
| `inputs.autocorrect_file` | string | Yes | stable input default |
|
| `inputs.autocorrect_file` | string | Yes | stable input default |
|
||||||
| `inputs.glossary_file` | string | Yes | stable input default |
|
| `inputs.glossary_file` | string | Yes | stable input default |
|
||||||
| `inputs.players_file` | string | Yes | stable input default |
|
| `inputs.players_file` | string | Conditional | required only with an unversioned legacy `party_file`; forbidden for a canonical party |
|
||||||
| `inputs.party_file` | string | Yes | stable input default |
|
| `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` |
|
| `inputs.spell_catalog_file` | string | No | optional spell-catalog overlay default; required when a Notarius reference selects `narratio.input.spell_catalog` |
|
||||||
|
|
||||||
### Session
|
### Session
|
||||||
@@ -375,8 +540,8 @@ integration.
|
|||||||
| `inputs.speakers_file` | string | No | overrides campaign stable input |
|
| `inputs.speakers_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.autocorrect_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.glossary_file` | string | No | overrides campaign stable input |
|
||||||
| `inputs.players_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 | overrides campaign stable input |
|
| `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.spell_catalog_file` | string | No | overrides the optional campaign spell catalog; empty or omitted inherits the campaign value |
|
||||||
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
| `inputs.audio_dir` | string | Conditional | local audio mode |
|
||||||
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
| `inputs.audio_files[]` | list[string] | Conditional | local audio mode |
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
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
|
The documentation check verifies local Markdown links and the dependency graph
|
||||||
of the Woodpecker workflows. The configuration check loads every maintained
|
of the Woodpecker workflows. The configuration check loads every maintained
|
||||||
pipeline and session example. Release automation repeats these checks and
|
pipeline and session example. Tag CI reuses this validation path before its
|
||||||
cross-compiles the CLI before it builds release assets; publishing depends on
|
asynchronous asset publication; the maintainer release boundary is documented
|
||||||
that validation path, so a failure cannot publish a release.
|
in the [Release Procedure](release.md).
|
||||||
|
|
||||||
Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled
|
Woodpecker also runs `go test -race -shuffle=on -count=3 ./...` on its scheduled
|
||||||
job to expose ordering and repeatability defects. Current runners cross-compile
|
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`).
|
- [Audita](./audita.md): transcript polishing (`audita process`).
|
||||||
- [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle
|
- [Notarius](./notarius.md): complete pipeline execution and safe JSON bundle
|
||||||
discovery (`notarius run`).
|
discovery (`notarius run`).
|
||||||
|
- [Party](./party.md): canonical campaign roster input.
|
||||||
- [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
|
- [Seriatim](./seriatim.md): merge, normalize, trim, and render operations.
|
||||||
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering
|
- [Scriptorium](./scriptorium.md): artifact generation and debug rendering
|
||||||
(`scriptorium run|render`).
|
(`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
|
binding takes precedence over a matching external path in Notarius
|
||||||
configuration. Narratio never emits `--without-reference`.
|
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:
|
The maintained D&D boundary binds only the four campaign-owned external slots:
|
||||||
|
|
||||||
```text
|
```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`,
|
Prepared stable source IDs are `narratio.input.players`,
|
||||||
`narratio.input.party`, `narratio.input.glossary`, and
|
`narratio.input.party`, `narratio.input.glossary`, and
|
||||||
`narratio.input.spell_catalog`. Artifact policy owns their canonical manifest
|
`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
|
## 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
|
regardless of their `enabled` value. The effective-set resolver itself does not
|
||||||
expand dependencies; the analyze work planner closes those targets over their
|
expand dependencies; the analyze work planner closes those targets over their
|
||||||
configured prerequisite graph. Availability is separate from executability.
|
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
|
Configured outputs, including non-executable prerequisites, become available
|
||||||
only when the versioned analyze state identifies a current result whose source,
|
only when the versioned analyze state identifies a current result whose source,
|
||||||
contract, canonical configured path, size, and checksum match a confined
|
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
|
rewriting manifest state. Catalog construction iterates current
|
||||||
configuration, so removed or renamed records are not advertised.
|
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.*`):
|
Prepared stable sources (`narratio.input.*`):
|
||||||
|
|
||||||
- resolve only from the current manifest's exact prepared-input record;
|
- 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
|
guidance rather than being normalized into a different workspace or remote
|
||||||
namespace.
|
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:
|
The model admits these stage states:
|
||||||
|
|
||||||
- `pending`
|
- `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
|
so an older file is not advertised through stale, missing, failed, or
|
||||||
unselected state.
|
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 session-stage collection is the reconciled authority across invocations.
|
||||||
The corresponding collection on an invocation's `analyze` stage record is an
|
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
|
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:
|
`manifest.RunManifest` is created for each invocation and records:
|
||||||
|
|
||||||
- invocation identity and `force` flag
|
- invocation identity and `force` flag
|
||||||
|
- the selected profile (when any) and secret-free effective configuration digest
|
||||||
- requested stages
|
- requested stages
|
||||||
- per-stage action (`run` or `skip`)
|
- per-stage action (`run` or `skip`)
|
||||||
- per-stage status
|
- 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
|
The application runner marks an executing stage running and then succeeded or
|
||||||
failed in both manifests, persisting each transition. On success it records
|
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
|
records may include optional contract and external provenance objects; old
|
||||||
manifests remain compatible when those fields are absent. A successful forced
|
manifests remain compatible when those fields are absent. A successful forced
|
||||||
rerun marks only succeeded transitive dependent session-stage records stale.
|
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.
|
stale one another, while either can stale analyze, publish, and notify.
|
||||||
|
|
||||||
Starting an execution clears the current session-stage record's prior outputs,
|
Starting an execution clears the current session-stage record's prior outputs,
|
||||||
logs, generated configuration references, and metadata. Failed and skipped
|
logs, generated configuration references, metadata, and semantic fingerprint.
|
||||||
|
Failed and skipped
|
||||||
transitions enforce the same clearing rule directly, while success repopulates
|
transitions enforce the same clearing rule directly, while success repopulates
|
||||||
only fields returned by the new result. Marking a record stale does not clear
|
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
|
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
|
and metadata, then applies any bounded details from the current skip and
|
||||||
continues. This self-skip is distinct from deciding not to execute an
|
continues. This self-skip is distinct from deciding not to execute an
|
||||||
already-succeeded stage and is reconsidered on later runs. Skipped results
|
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
|
When an already-succeeded stage is skipped, the invocation run manifest records
|
||||||
the `skip` action and reason. The session manifest deliberately retains its
|
the `skip` action and reason. The session manifest deliberately retains its
|
||||||
existing succeeded record because it remains the cross-invocation progress
|
existing succeeded record because it remains the cross-invocation progress
|
||||||
authority. 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
|
otherwise eligible skip when their selected durable evidence is obsolete; the
|
||||||
runner marks the aggregate record stale and executes it. Analyze's validator
|
runner marks the aggregate record stale and executes it. Analyze's validator
|
||||||
can still accept a partial selection when only unrelated artifact records are
|
can still accept a partial selection when only unrelated artifact records are
|
||||||
stale.
|
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.
|
Session manifest is the authoritative stage-progress ledger across invocations.
|
||||||
Run manifest is invocation-scoped audit state.
|
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
|
Before an explicitly bounded execution starts after `prepare`, the application
|
||||||
reads the session manifest and accepts only `succeeded` or `skipped` for every
|
reads the session manifest and accepts only `succeeded` or `skipped` for every
|
||||||
excluded canonical prefix stage. The first other status or absent record fails
|
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
|
## Invariants
|
||||||
|
|
||||||
- stage resume/skip decisions are session-manifest driven.
|
- 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
|
- running, failed, and self-skipped stages do not retain result payloads from
|
||||||
an earlier success.
|
an earlier success.
|
||||||
- stale stages retain prior details until replacement execution starts.
|
- stale stages retain prior details until replacement execution starts.
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
model. Planning clones session state and applies selected-stage transitions and
|
||||||
resume validation in memory; it does not create invocation state or initialize
|
resume validation in memory; it does not create invocation state or initialize
|
||||||
stage-execution adapters. Command configuration loading can still retrieve a
|
stage-execution adapters. Command configuration loading can still retrieve a
|
||||||
missing session file through configured remote storage. Analyze planning
|
missing session file through configured remote storage. It retains the initially
|
||||||
additionally exposes the artifact closure's targets, prerequisite rebuilds,
|
composed pipeline and selected campaign while resolving either a local or
|
||||||
execution order, and current reuse.
|
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
|
## Pipeline Stage Set
|
||||||
|
|
||||||
@@ -68,8 +70,9 @@ The implemented canonical order is:
|
|||||||
11. `notify` (no-op)
|
11. `notify` (no-op)
|
||||||
|
|
||||||
`notify` currently has no persisted pipeline outputs and uses the explicit
|
`notify` currently has no persisted pipeline outputs and uses the explicit
|
||||||
`noop` notification mode. The focused stage documents own implementation
|
`noop` notification mode. Its versioned semantic evidence records that delivery
|
||||||
mechanics. The
|
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
|
[CLI](../cli.md) and [Operations](../operations.md) own user-visible invocation
|
||||||
and execution semantics.
|
and execution semantics.
|
||||||
|
|
||||||
@@ -82,6 +85,8 @@ trimmed transcript state: neither invalidates the other, while either can stale
|
|||||||
|
|
||||||
## Focused Documentation
|
## 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,
|
- [Adapter Internals](adapters.md): external adapter boundaries, composition,
|
||||||
failure behavior, and test surfaces.
|
failure behavior, and test surfaces.
|
||||||
- [Artifact Internals](artifacts.md): source identities, runtime catalog,
|
- [Artifact Internals](artifacts.md): source identities, runtime catalog,
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ dependency order, and safely materialize validated outputs.
|
|||||||
|
|
||||||
## Inputs
|
## 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
|
- optional selected artifact keys supplied through the stage environment
|
||||||
- built-in, configured, extraction, and previous-session source references in
|
- built-in, configured, extraction, and previous-session source references in
|
||||||
artifact inputs
|
artifact inputs
|
||||||
@@ -165,7 +168,8 @@ Supported source families:
|
|||||||
## Related Contracts And Tests
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Configuration](../config.md#scriptorium-artifact-entries) owns artifact
|
- [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.
|
- [CLI](../cli.md) owns user-visible artifact selection.
|
||||||
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
|
- [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract.
|
||||||
- Implementation and tests: `internal/stage/analyze.go`,
|
- Implementation and tests: `internal/stage/analyze.go`,
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ procedures belong in [Operations](../operations.md).
|
|||||||
manifest-authoritative identity resolver before creating run-local output;
|
manifest-authoritative identity resolver before creating run-local output;
|
||||||
3. streams each verified reference into an invocation-local snapshot and
|
3. streams each verified reference into an invocation-local snapshot and
|
||||||
rejects any source change observed while copying;
|
rejects any source change observed while copying;
|
||||||
4. fingerprints the Notarius invocation contract, including sorted reference
|
4. fingerprints the byte- and provenance-bearing Notarius invocation evidence,
|
||||||
identities;
|
including sorted reference identities;
|
||||||
5. creates a run-local staging directory and invokes the injected
|
5. creates a run-local staging directory and invokes the injected
|
||||||
`notarius.Runner`;
|
`notarius.Runner`;
|
||||||
6. revalidates the reference snapshots, then validates the v2 successful
|
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
|
## Resume Validation
|
||||||
|
|
||||||
`internal/stage/extract_resume.go` permits a skip only when the existing stage
|
Before the focused validator runs, the application compares extract's versioned
|
||||||
record succeeded and still matches the current invocation fingerprint. The
|
semantic fingerprint. It covers enablement, Notarius pipeline identity, sorted
|
||||||
fingerprint covers the resolved executable and config paths, pipeline ID,
|
reference selector/source mappings, sorted declared output contracts, and each
|
||||||
timeout, working directory, sorted configured output contracts, the current
|
canonical `narratio.extraction.<key>` output identity. It excludes executable,
|
||||||
direct trimmed-transcript identity, and sorted prepared-reference identities.
|
timeout, working directory, config path, and private Notarius config contents.
|
||||||
The same reference helper and transcript identity are resolved again for
|
|
||||||
artifact evidence, so changing the current transcript bytes or producer
|
`internal/stage/extract_resume.go` then permits a skip only when the existing
|
||||||
identity makes the prior extraction obsolete.
|
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,
|
A valid prepared-reference change makes extraction non-resumable. Missing,
|
||||||
unsafe, or checksum-inconsistent prepared evidence is a hard validation error
|
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
|
obsolete results are non-resumable and run again; unsafe filesystem conditions
|
||||||
return an error rather than silently accepting or replacing data.
|
return an error rather than silently accepting or replacing data.
|
||||||
|
|
||||||
The fingerprint cannot observe files imported by Notarius configuration,
|
Neither contract can observe files imported by Notarius configuration, profile
|
||||||
profile contents, prompt/module definitions, or other transitive inputs.
|
contents, prompt/module definitions, or other transitive inputs. Operators must
|
||||||
Operators must force extraction after changing any such input.
|
force extraction after changing any such private input behind a stable
|
||||||
|
identifier.
|
||||||
|
|
||||||
## Failure Behavior
|
## Failure Behavior
|
||||||
|
|
||||||
@@ -101,7 +108,8 @@ available for audit and recovery.
|
|||||||
|
|
||||||
- Stage execution, selection, and resume validation: `internal/stage/extract.go`,
|
- Stage execution, selection, and resume validation: `internal/stage/extract.go`,
|
||||||
`internal/stage/extract_resume.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`,
|
- Subprocess boundary: `internal/adapters/notarius/subprocess.go`,
|
||||||
`internal/adapters/notarius/subprocess_test.go`
|
`internal/adapters/notarius/subprocess_test.go`
|
||||||
- Catalog hydration: `internal/artifacts/extraction_catalog.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.
|
- base transcript must validate before stage success.
|
||||||
- report output is config-gated.
|
- 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
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
||||||
- [Configuration](../config.md#pipeline) owns operator-selected Seriatim values.
|
- [Configuration](../config.md#pipeline) owns operator-selected Seriatim values.
|
||||||
- Implementation and tests: `internal/stage/merge.go`,
|
- 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).
|
- final transcript must validate as processed transcript JSON (`segments` array).
|
||||||
- normalize defaults are applied when `pipeline.normalize` is unset.
|
- 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
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
- [Seriatim](../integrations/seriatim.md) owns subprocess and output semantics.
|
||||||
- [Configuration](../config.md#pipeline) owns normalize fields and defaults.
|
- [Configuration](../config.md#pipeline) owns normalize fields and defaults.
|
||||||
- Implementation and tests: `internal/stage/normalize.go`,
|
- 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.
|
- polished transcript schema validation is mandatory.
|
||||||
- report output is config-gated.
|
- 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
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Audita](../integrations/audita.md) owns subprocess, validation, and failure
|
- [Audita](../integrations/audita.md) owns subprocess, validation, and failure
|
||||||
semantics.
|
semantics.
|
||||||
- [Configuration](../config.md#pipeline) owns operator-selected Audita values.
|
- [Configuration](../config.md#pipeline) owns operator-selected Audita values.
|
||||||
- Implementation and tests: `internal/stage/polish.go`,
|
- 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
|
- materializes a configured spell catalog with checksum and provenance, or
|
||||||
safely removes an obsolete canonical spell catalog and its manifest record
|
safely removes an obsolete canonical spell catalog and its manifest record
|
||||||
when the effective input is omitted.
|
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.
|
- scans enabled configured artifact inputs for `narratio.previous_session.artifact.*` requirements.
|
||||||
- clears managed `previous/` state on every invocation, then, when requirements exist:
|
- clears managed `previous/` state on every invocation, then, when requirements exist:
|
||||||
- resolves the pointer-selected previous source through the shared resolver;
|
- resolves the pointer-selected previous source through the shared resolver;
|
||||||
@@ -57,6 +60,26 @@ mapping, while the isolated legacy reader rejects ambiguous fallback matches.
|
|||||||
- managed `previous/` state represents only the current requirement set.
|
- managed `previous/` state represents only the current requirement set.
|
||||||
- `manifest.inputs` ordering is deterministic (`kind`, `path`).
|
- `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
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Configuration](../config.md) owns audio selection, stable input fields, and
|
- [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
|
- [Storage Internals](storage.md) and [Artifact Internals](artifacts.md) explain
|
||||||
the internal collaborators.
|
the internal collaborators.
|
||||||
- Implementation and tests: `internal/stage/prepare.go`,
|
- 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`
|
`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
|
- when publishing or run upload is disabled, completes successfully with no
|
||||||
outputs and records explanatory metadata. This is not an explicit self-skip:
|
outputs and records explanatory metadata. This is not an explicit self-skip:
|
||||||
both manifests record success, and an ordinary later run reuses that result
|
both manifests record success. Enablement and upload policy are fingerprinted,
|
||||||
until publish is forced.
|
so changing either automatically makes the prior result non-resumable.
|
||||||
- validates prerequisite stage success and object-store availability.
|
- validates prerequisite stage success and object-store availability.
|
||||||
- derives a deterministic run-archive allowlist from the validated run
|
- derives a deterministic run-archive allowlist from the validated run
|
||||||
`manifest.json`: declared run-local outputs, logs, generated configs, and the
|
`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
|
- post-commit local cleanup is authorized by the committed publish metadata and
|
||||||
is durably recorded by the application lifecycle before any local deletion.
|
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
|
The commit boundary and cleanup gate are normative architecture invariants; see
|
||||||
[Architecture](../policy/architecture.md#publish-commit-boundary).
|
[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
|
- [Artifact Internals](artifacts.md) explains source resolution and current-state
|
||||||
helpers.
|
helpers.
|
||||||
- Implementation and tests: `internal/stage/publish.go`,
|
- 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`
|
`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.
|
- records input provenance, output paths, adapter metadata, logs, and generated config refs.
|
||||||
- when `pipeline.render.enabled=false`, completes successfully with no outputs
|
- when `pipeline.render.enabled=false`, completes successfully with no outputs
|
||||||
and records explanatory metadata. This is not an explicit self-skip: both
|
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
|
## Failure Semantics
|
||||||
|
|
||||||
@@ -40,9 +41,20 @@ records that may consume rendered transcripts.
|
|||||||
- only `format: markdown` is supported.
|
- only `format: markdown` is supported.
|
||||||
- render stage owns production of built-in Markdown transcript sources.
|
- 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
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Seriatim](../integrations/seriatim.md) owns render subprocess behavior.
|
- [Seriatim](../integrations/seriatim.md) owns render subprocess behavior.
|
||||||
- [Configuration](../config.md#pipeline) owns render fields and defaults.
|
- [Configuration](../config.md#pipeline) owns render fields and defaults.
|
||||||
- Implementation and tests: `internal/stage/render.go`,
|
- 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
|
- each successful output is validated before stage success, and cancellation or
|
||||||
incomplete dispatch cannot be reported as a successful result.
|
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
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [WhisperX](../integrations/whisperx.md) owns HTTP, retry, timeout, and
|
- [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
|
- [Configuration](../config.md#pipeline) owns concurrency and other
|
||||||
operator-selected values.
|
operator-selected values.
|
||||||
- Implementation and tests: `internal/stage/transcribe.go`,
|
- 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.
|
- bounds output exists only in enabled trim path.
|
||||||
- render-debug output is diagnostic and not a declared stage output.
|
- 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
|
## Related Contracts And Tests
|
||||||
|
|
||||||
- [Scriptorium](../integrations/scriptorium.md) owns bounds generation and
|
- [Scriptorium](../integrations/scriptorium.md) owns bounds generation and
|
||||||
@@ -40,4 +53,5 @@ When `trim.enabled=false`:
|
|||||||
- [Seriatim](../integrations/seriatim.md) owns transcript trimming behavior.
|
- [Seriatim](../integrations/seriatim.md) owns transcript trimming behavior.
|
||||||
- [Configuration](../config.md#pipeline) owns trim fields and defaults.
|
- [Configuration](../config.md#pipeline) owns trim fields and defaults.
|
||||||
- Implementation and tests: `internal/stage/trim.go`,
|
- 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
|
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
|
## Stage Execution and Continuation Behavior
|
||||||
|
|
||||||
Canonical stage order:
|
Canonical stage order:
|
||||||
@@ -88,7 +101,9 @@ Canonical stage order:
|
|||||||
|
|
||||||
Execution rules:
|
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;
|
- `run` continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- forcing a stage marks succeeded transitive dependents as `stale` before the
|
- forcing a stage marks succeeded transitive dependents as `stale` before the
|
||||||
replacement runs; render and extract are independent siblings; and
|
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
|
repeated self-skip with the same reason and no outputs is stable and does not
|
||||||
perpetually rerun dependent work.
|
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
|
An explicit self-skip is a durable `skipped` stage outcome that later runs
|
||||||
reconsider. It differs from successful no-output execution: disabled `render`
|
reconsider. It differs from successful no-output execution: disabled `render`
|
||||||
and `publish`, and absent or no-executable `analyze`, record `succeeded` with
|
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
|
## 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
|
`--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`
|
`publish`. For a bounded run or plan, the selected range must contain `analyze`
|
||||||
or `publish`.
|
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
|
`inputs/spell_catalog.json`. Extraction supplies Notarius with verified copies
|
||||||
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
|
under `runs/<run_id>/extract/references/` so a concurrent refresh of canonical
|
||||||
prepared files cannot change the bytes consumed by an in-flight invocation.
|
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
|
Inspect the effective stable-input inventory and
|
||||||
prepared-file readiness with:
|
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,
|
Narratio automatically reruns extraction when its recorded invocation contract,
|
||||||
prepared Narratio reference identities, or durable output validation changes.
|
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
|
It cannot fingerprint configuration files, profiles, prompts, modules, or
|
||||||
other references loaded transitively by Notarius itself. Force extraction after
|
other references loaded transitively by Notarius itself. Force extraction after
|
||||||
changing any of those inputs, even when the top-level Narratio and Notarius
|
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
|
outcome changes also stale affected downstream stages, while an identical
|
||||||
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
|
repeated `notarius_disabled` self-skip does not repeatedly invalidate them.
|
||||||
|
|
||||||
|
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
|
## Publish Workflow
|
||||||
|
|
||||||
Run publish only:
|
Run publish only:
|
||||||
|
|||||||
@@ -145,6 +145,14 @@ Configuration is strict, explicit, centralized, and operator-oriented.
|
|||||||
- Empty configured values do not silently replace meaningful defaults.
|
- Empty configured values do not silently replace meaningful defaults.
|
||||||
- Validation rejects invalid composition before stage execution where
|
- Validation rejects invalid composition before stage execution where
|
||||||
practical.
|
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
|
- Session templating remains narrow and deterministic rather than becoming a
|
||||||
general configuration language.
|
general configuration language.
|
||||||
- Secret values are supplied indirectly and are not persisted in ordinary
|
- 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
# Release Notes
|
||||||
|
|
||||||
This directory contains the maintained release-note text for Narratio releases.
|
This directory contains immutable historical release notes for Narratio.
|
||||||
The corresponding Gitea release is the canonical source for downloadable
|
Future notes are created with the matching stable version and use this minimum
|
||||||
binaries and checksums.
|
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)
|
- [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).
|
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
|
## Audio mode conflict
|
||||||
|
|
||||||
Symptom:
|
Symptom:
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ in the [configuration reference](../docs/config.md).
|
|||||||
WhisperX URL.
|
WhisperX URL.
|
||||||
- [Production-shaped pipeline](pipeline.production.yml): S3 storage, publish,
|
- [Production-shaped pipeline](pipeline.production.yml): S3 storage, publish,
|
||||||
external tools, and configured Scriptorium artifacts.
|
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
|
- [Full annotated pipeline](pipeline.full.annotated.yml): every implemented
|
||||||
pipeline section with explanatory comments.
|
pipeline section with explanatory comments.
|
||||||
- [Extraction subset pipeline](pipeline.extraction-subset.yml): a focused
|
- [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
|
- The sample campaign references its local
|
||||||
[speakers](campaigns/sample-campaign/speakers.yml),
|
[speakers](campaigns/sample-campaign/speakers.yml),
|
||||||
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
|
[autocorrect](campaigns/sample-campaign/autocorrect.yml),
|
||||||
[glossary](campaigns/sample-campaign/glossary.yml),
|
[glossary](campaigns/sample-campaign/glossary.yml), and canonical
|
||||||
[players](campaigns/sample-campaign/players.yml), and
|
[party](campaigns/sample-campaign/party.yml) fixture, plus an optional
|
||||||
[party](campaigns/sample-campaign/party.yml) fixtures, plus an optional
|
|
||||||
[spell-catalog overlay](campaigns/sample-campaign/spell_catalog.json) that
|
[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
|
- [Sample speaker audio](audio/sample-speaker.flac) is a text placeholder that
|
||||||
reserves the expected filename and directory shape. Replace it with a real
|
reserves the expected filename and directory shape. Replace it with a real
|
||||||
FLAC file before running transcription.
|
FLAC file before running transcription.
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ inputs:
|
|||||||
speakers_file: ./speakers.yml
|
speakers_file: ./speakers.yml
|
||||||
autocorrect_file: ./autocorrect.yml
|
autocorrect_file: ./autocorrect.yml
|
||||||
glossary_file: ./glossary.yml
|
glossary_file: ./glossary.yml
|
||||||
players_file: ./players.yml
|
|
||||||
party_file: ./party.yml
|
party_file: ./party.yml
|
||||||
spell_catalog_file: ./spell_catalog.json
|
spell_catalog_file: ./spell_catalog.json
|
||||||
|
|||||||
@@ -1,2 +1,26 @@
|
|||||||
- name: Example Hero
|
schema_version: narratio.party.v1
|
||||||
type: pc
|
|
||||||
|
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")
|
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires pipeline.scriptorium.artifacts to be configured")
|
||||||
}
|
}
|
||||||
configured := artifacts.ConfiguredArtifactDefinitions(cfg.Pipeline.Scriptorium.Artifacts)
|
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")
|
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 err != nil {
|
||||||
if strings.Contains(err.Error(), "is not configured") {
|
if strings.Contains(err.Error(), "is not configured") {
|
||||||
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts includes unknown artifact %q", selectedArtifactName(err))
|
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 {
|
if err := validateEffectiveArtifactConfiguration(cfg.Pipeline.Scriptorium.Artifacts, effective); err != nil {
|
||||||
return artifacts.EffectiveArtifactSet{}, err
|
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 {
|
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")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
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"} {
|
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.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")
|
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 {
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
t.Fatalf("save manifest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
err := Run(
|
err = Run(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
[]string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--artifacts", "session_recap"},
|
||||||
&out,
|
&out,
|
||||||
|
|||||||
@@ -1,11 +1,71 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"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) {
|
func TestArtifactSelectionFlagNormalize(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package app
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func seedAllStagesSucceeded(t *testing.T, cfg *config.Config) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
m := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ func (f *singletonStringFlag) Set(value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f singletonStringFlag) pointer() *string {
|
||||||
|
if !f.set {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value := f.value
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
type singletonBoolFlag struct {
|
type singletonBoolFlag struct {
|
||||||
name string
|
name string
|
||||||
value bool
|
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: "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 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: "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 {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
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) {
|
func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) {
|
||||||
args := []string{"session", "--from", "publish", "--through", "render"}
|
args := []string{"session", "--from", "publish", "--through", "render"}
|
||||||
runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard)
|
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) != "" {
|
strings.TrimSpace(flags.previousSessionID) != "" {
|
||||||
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
|
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)
|
_, pipelineCfg, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()})
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("clean: %w", err)
|
|
||||||
}
|
|
||||||
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("clean: %w", err)
|
return fmt.Errorf("clean: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"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
|
var runCommandFn = Run
|
||||||
|
|
||||||
@@ -40,6 +40,8 @@ func Execute(args []string, stdout, stderr io.Writer) int {
|
|||||||
err = Session(ctx, cmdArgs, stdout)
|
err = Session(ctx, cmdArgs, stdout)
|
||||||
case "clean":
|
case "clean":
|
||||||
err = Clean(ctx, cmdArgs, stdout)
|
err = Clean(ctx, cmdArgs, stdout)
|
||||||
|
case "config":
|
||||||
|
err = Config(ctx, cmdArgs, stdout)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd)
|
fmt.Fprintf(stderr, "unknown command: %q\n\n", cmd)
|
||||||
printUsage(stderr)
|
printUsage(stderr)
|
||||||
|
|||||||
@@ -549,6 +549,17 @@ inputs:
|
|||||||
return pipelinePath, campaignPath, sessionPath
|
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 {
|
func writeAppTestCampaignConfig(t *testing.T, dir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
campaignPath := filepath.Join(dir, "campaign.yml")
|
campaignPath := filepath.Join(dir, "campaign.yml")
|
||||||
@@ -563,6 +574,7 @@ inputs:
|
|||||||
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
if err := os.WriteFile(campaignPath, []byte(campaignYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write campaign.yml: %v", err)
|
t.Fatalf("write campaign.yml: %v", err)
|
||||||
}
|
}
|
||||||
|
mustWriteTestFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
|
||||||
return campaignPath
|
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"
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
||||||
)
|
)
|
||||||
|
|
||||||
type pipelineCampaignConfig struct {
|
type pipelineCampaignConfig = config.LoadedPipelineCampaign
|
||||||
PipelinePath string
|
|
||||||
CampaignPath string
|
|
||||||
Pipeline *config.PipelineConfig
|
|
||||||
Campaign *config.CampaignConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
var downloadObjectToTempFn = storage.DownloadObjectToTemp
|
var downloadObjectToTempFn = storage.DownloadObjectToTemp
|
||||||
|
var loadPipelineConfigFn = config.LoadPipelineWithOptions
|
||||||
|
|
||||||
type commandConfig struct {
|
type commandConfig struct {
|
||||||
Config *config.Config
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if explicitSession := strings.TrimSpace(sessionFlag); explicitSession != "" {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -70,7 +66,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if discoveredSession.Path != "" {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,11 +84,9 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
}
|
}
|
||||||
sessionPrefix := artifacts.S3SessionPrefix(rootPrefix, config.CampaignID(base.Campaign), sessionID)
|
sessionPrefix := artifacts.S3SessionPrefix(rootPrefix, config.CampaignID(base.Campaign), sessionID)
|
||||||
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
|
remoteKey := artifacts.S3SessionConfigKey(sessionPrefix)
|
||||||
partialCfg := &config.Config{
|
partialCfg, err := config.ResolveLoadedPipelineCampaign(*base, "", nil, config.SessionSource{})
|
||||||
Pipeline: base.Pipeline,
|
if err != nil {
|
||||||
Campaign: base.Campaign,
|
return nil, err
|
||||||
PipelinePath: base.PipelinePath,
|
|
||||||
CampaignPath: base.CampaignPath,
|
|
||||||
}
|
}
|
||||||
store, err := newCommandObjectStore(ctx, partialCfg, nil)
|
store, err := newCommandObjectStore(ctx, partialCfg, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -122,11 +116,8 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Resolve(
|
cfg, err := config.ResolveLoadedPipelineCampaign(
|
||||||
base.PipelinePath,
|
*base,
|
||||||
base.Pipeline,
|
|
||||||
base.CampaignPath,
|
|
||||||
base.Campaign,
|
|
||||||
sessionTempPath,
|
sessionTempPath,
|
||||||
sessionCfg,
|
sessionCfg,
|
||||||
config.SessionSource{
|
config.SessionSource{
|
||||||
@@ -146,12 +137,8 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
return loaded, nil
|
return loaded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) {
|
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string, pipelineOpts config.PipelineLoadOptions) (*pipelineCampaignConfig, error) {
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
loadedPipelinePath, pipelineCfg, err := loadPipelineConfig(pipelineFlag, pipelineOpts)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
pipelineCfg, err := config.LoadPipeline(resolvedPipelinePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 nil, fmt.Errorf("campaign config %q invalid: campaign_id %q does not match selected campaign %q", resolvedCampaignPath, got, selectedID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &pipelineCampaignConfig{
|
loaded, err := config.LoadPipelineCampaign(loadedPipelinePath, pipelineCfg, resolvedCampaignPath, campaignCfg)
|
||||||
PipelinePath: resolvedPipelinePath,
|
if err != nil {
|
||||||
CampaignPath: resolvedCampaignPath,
|
return nil, err
|
||||||
Pipeline: pipelineCfg,
|
}
|
||||||
Campaign: campaignCfg,
|
return &loaded, nil
|
||||||
}, 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) {
|
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)
|
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
|
||||||
fmt.Fprintln(out, "Configured:")
|
fmt.Fprintln(out, "Configured:")
|
||||||
|
origins := config.ArtifactFamilies(cfg.Pipeline).Members
|
||||||
for _, entry := range catalog.ListConfigured() {
|
for _, entry := range catalog.ListConfigured() {
|
||||||
state := "unavailable"
|
state := "unavailable"
|
||||||
if entry.Available {
|
if entry.Available {
|
||||||
state = "available"
|
state = "available"
|
||||||
}
|
}
|
||||||
writeExtractionArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet)
|
writeConfiguredArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet, origins)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(out, "Extraction:")
|
fmt.Fprintln(out, "Extraction:")
|
||||||
for _, entry := range catalog.ListExtraction() {
|
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) {
|
func writeExtractionArtifactLine(out io.Writer, source, state, provenance string, lockSet map[string]config.PublishLockRule) {
|
||||||
parts := []string{source, "planned", state}
|
parts := []string{source, "planned", state}
|
||||||
if strings.TrimSpace(provenance) != "" {
|
if strings.TrimSpace(provenance) != "" {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type commonConfigFlags struct {
|
|||||||
sessionPath string
|
sessionPath string
|
||||||
sessionID string
|
sessionID string
|
||||||
previousSessionID string
|
previousSessionID string
|
||||||
|
profile singletonStringFlag
|
||||||
}
|
}
|
||||||
|
|
||||||
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
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.sessionPath, "session", "", "path to session.yml")
|
||||||
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
|
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
|
||||||
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous 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 {
|
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
||||||
return config.SessionLoadOptions{
|
return config.SessionLoadOptions{
|
||||||
SessionID: f.sessionID,
|
SessionID: f.sessionID,
|
||||||
PreviousSessionID: f.previousSessionID,
|
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 := flag.NewFlagSet("session init", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
||||||
|
var profile singletonStringFlag
|
||||||
var remote, force bool
|
var remote, force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
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(&output, "output", "", "local output session.yml path")
|
||||||
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
|
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
|
||||||
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
|
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(&remote, "remote", false, "write session.yml to S3 session prefix")
|
||||||
fs.BoolVar(&force, "force", false, "overwrite existing target")
|
fs.BoolVar(&force, "force", false, "overwrite existing target")
|
||||||
if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil {
|
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")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
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, "Campaign: %s\n", cfg.Session.Campaign)
|
||||||
fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
|
fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
|
||||||
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
|
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
|
||||||
|
fmt.Fprintf(out, "Configuration (current): %s\n", effectiveConfigSummary(cfg))
|
||||||
writeStatusStableInputs(out, inspectStableInputs(cfg))
|
writeStatusStableInputs(out, inspectStableInputs(cfg))
|
||||||
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
|
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
} else {
|
} else {
|
||||||
localManifest = m
|
localManifest = m
|
||||||
fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath)
|
fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath)
|
||||||
|
fmt.Fprintf(out, "Configuration (last persisted): %s\n", persistedEffectiveConfigSummary(m.EffectiveConfig))
|
||||||
writeStageStatuses(out, m)
|
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 {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
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()
|
stages := request.Plan.Stages()
|
||||||
stageEnv := &stage.Env{
|
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,
|
EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force,
|
||||||
}
|
}
|
||||||
|
|
||||||
runCount := 0
|
runCount := 0
|
||||||
skipCount := 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
|
return err
|
||||||
}
|
}
|
||||||
for _, selectedStage := range stages {
|
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)
|
action := decideStageAction(selectedStage, model, request.Force)
|
||||||
var validation *stage.ResumeValidation
|
var validation *stage.ResumeValidation
|
||||||
if validator, ok := selectedStage.(stage.ResumeValidator); ok &&
|
if action == stageActionSkip {
|
||||||
(action == stageActionSkip || selectedStage.Name() == "analyze") {
|
checked, validationErr := evaluateStageResume(ctx, selectedStage, stageEnv, model, semanticConfig)
|
||||||
checked, validationErr := validator.ValidateResume(ctx, stageEnv, model)
|
|
||||||
if validationErr != nil {
|
if validationErr != nil {
|
||||||
return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr)
|
return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr)
|
||||||
}
|
}
|
||||||
checked = checked.Normalized()
|
validation = checked
|
||||||
validation = &checked
|
if checked != nil && !checked.Resumable {
|
||||||
if action == stageActionSkip && !checked.Resumable {
|
|
||||||
at := time.Now().UTC()
|
at := time.Now().UTC()
|
||||||
model.MarkStageStale(selectedStage.Name(), at, checked.Reason)
|
model.MarkStageStale(selectedStage.Name(), at, checked.Reason)
|
||||||
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(
|
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(
|
||||||
@@ -86,6 +92,16 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
action = stageActionRun
|
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 {
|
if action == stageActionRun {
|
||||||
runCount++
|
runCount++
|
||||||
} else {
|
} else {
|
||||||
@@ -100,7 +116,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if action == stageActionRun {
|
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)
|
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
|
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())
|
prior := capturePriorStageOutcome(model, selectedStage.Name())
|
||||||
at := time.Now().UTC()
|
at := time.Now().UTC()
|
||||||
model.MarkStageRunning(selectedStage.Name(), at)
|
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 != "" {
|
if reason := plannedSelfSkipReason(selectedStage.Name(), cfg); reason != "" {
|
||||||
model.MarkStageSkipped(selectedStage.Name(), at, reason)
|
model.MarkStageSkipped(selectedStage.Name(), at, reason)
|
||||||
|
setSessionStageSemanticConfig(model, selectedStage.Name(), semanticConfig)
|
||||||
if !prior.isSameSelfSkip(reason) {
|
if !prior.isSameSelfSkip(reason) {
|
||||||
_, err := invalidateDependentSucceededStagesWithReason(
|
_, err := invalidateDependentSucceededStagesWithReason(
|
||||||
model, selectedStage.Name(), at, staleReasonSelfSkip,
|
model, selectedStage.Name(), at, staleReasonSelfSkip,
|
||||||
@@ -151,6 +174,7 @@ func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, c
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
model.MarkStageSucceeded(selectedStage.Name(), at, nil)
|
model.MarkStageSucceeded(selectedStage.Name(), at, nil)
|
||||||
|
setSessionStageSemanticConfig(model, selectedStage.Name(), semanticConfig)
|
||||||
if !prior.exists || prior.status != manifest.StatusSucceeded {
|
if !prior.exists || prior.status != manifest.StatusSucceeded {
|
||||||
if _, err := invalidateDependentSucceededStagesWithReason(
|
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||||
model, selectedStage.Name(), at, staleReasonChangedResult,
|
model, selectedStage.Name(), at, staleReasonChangedResult,
|
||||||
@@ -206,7 +230,11 @@ func planArtifactList(values []stage.AnalyzeResumeArtifact) string {
|
|||||||
if value.Forced {
|
if value.Forced {
|
||||||
detail += ":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, ", ")
|
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")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
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.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
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)
|
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 {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
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",
|
"--artifacts=player_handout",
|
||||||
"--config", "pipeline.yml",
|
"--config", "pipeline.yml",
|
||||||
"--campaign", "sample-campaign",
|
"--campaign", "sample-campaign",
|
||||||
|
"--profile", "testing",
|
||||||
}, io.Discard, io.Discard)
|
}, io.Discard, io.Discard)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("Execute() code = %d, want 0", code)
|
t.Fatalf("Execute() code = %d, want 0", code)
|
||||||
@@ -34,6 +35,7 @@ func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
|
|||||||
"--artifacts=player_handout",
|
"--artifacts=player_handout",
|
||||||
"--config", "pipeline.yml",
|
"--config", "pipeline.yml",
|
||||||
"--campaign", "sample-campaign",
|
"--campaign", "sample-campaign",
|
||||||
|
"--profile", "testing",
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(captured, want) {
|
if !reflect.DeepEqual(captured, want) {
|
||||||
t.Fatalf("forwarded args = %#v, want %#v", 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) {
|
func TestRemoteSessionConfigIsRemovedAfterEveryCommandExit(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -166,6 +209,9 @@ inputs:
|
|||||||
if err := loaded.Close(); err != nil {
|
if err := loaded.Close(); err != nil {
|
||||||
t.Fatalf("second Close() error = %v", err)
|
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) {
|
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)
|
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 {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{
|
summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{
|
||||||
Force: request.Force,
|
Force: request.Force,
|
||||||
SelectedArtifacts: request.SelectedArtifacts,
|
SelectedArtifacts: selectedArtifacts,
|
||||||
EffectiveArtifacts: effectiveArtifacts,
|
EffectiveArtifacts: effectiveArtifacts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -44,11 +48,12 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
_, err = fmt.Fprintf(
|
_, err = fmt.Fprintf(
|
||||||
out,
|
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,
|
summary.SessionID,
|
||||||
len(summary.Executed),
|
len(summary.Executed),
|
||||||
len(summary.Skipped),
|
len(summary.Skipped),
|
||||||
summary.ManifestPath,
|
summary.ManifestPath,
|
||||||
|
effectiveConfigSummary(cfg),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,13 +216,17 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
|
|||||||
if err := config.Validate(cfg); err != nil {
|
if err := config.Validate(cfg); err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||||
}
|
}
|
||||||
summary, err := executeStagesFn(ctx, cfg, plan, RunOptions{
|
summary, err := executeStagesFn(ctx, cfg, plan, RunOptions{
|
||||||
Force: req.Force,
|
Force: req.Force,
|
||||||
SelectedArtifacts: req.SelectedArtifacts,
|
SelectedArtifacts: selectedArtifacts,
|
||||||
EffectiveArtifacts: effectiveArtifacts,
|
EffectiveArtifacts: effectiveArtifacts,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,20 +19,21 @@ func TestRunContinuesAfterCompletedStages(t *testing.T) {
|
|||||||
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
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.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil)
|
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)
|
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 {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
t.Fatalf("save manifest: %v", err)
|
||||||
}
|
}
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
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, "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
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
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")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
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"} {
|
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.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")
|
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 {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
t.Fatalf("save manifest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Run() error = %v", err)
|
t.Fatalf("Run() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -172,6 +178,7 @@ func TestRunStageSkipAndForce(t *testing.T) {
|
|||||||
store := &manifest.LocalStore{}
|
store := &manifest.LocalStore{}
|
||||||
m := manifest.New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC))
|
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)
|
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 {
|
if err := store.Save(context.Background(), manifestPath, m); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
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")
|
manifestPath := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03", "manifest.json")
|
||||||
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
workRoot := filepath.Join(workspaceRoot, "work", "sample-campaign", "2026-05-03")
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`)
|
mustWriteTestFile(t, filepath.Join(workRoot, "transcripts", "base.json"), `{"segments":[]}`)
|
||||||
mustWriteTestFile(t, filepath.Join(workRoot, "inputs", "glossary.yml"), "terms: []\n")
|
|
||||||
|
|
||||||
store := &manifest.LocalStore{}
|
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"} {
|
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)
|
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 {
|
if err := store.Save(context.Background(), manifestPath, seed); err != nil {
|
||||||
t.Fatalf("save manifest: %v", err)
|
t.Fatalf("save manifest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("RunStage(force) error = %v", err)
|
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)
|
return nil, fmt.Errorf("validate bounded run prerequisites under session lock: %w", err)
|
||||||
}
|
}
|
||||||
identity.applyToSessionManifest(m)
|
identity.applyToSessionManifest(m)
|
||||||
|
applyEffectiveConfigProvenance(m, cfg)
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
||||||
}
|
}
|
||||||
@@ -172,6 +173,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
|||||||
return nil, fmt.Errorf("create run manifest: %w", err)
|
return nil, fmt.Errorf("create run manifest: %w", err)
|
||||||
}
|
}
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
|
applyEffectiveConfigProvenanceToRun(runManifest, cfg)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
@@ -259,38 +261,42 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
|||||||
for _, s := range stages {
|
for _, s := range stages {
|
||||||
stageEnv.Force = opts.Force
|
stageEnv.Force = opts.Force
|
||||||
runNames = append(runNames, s.Name())
|
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)
|
action := decideStageAction(s, m, opts.Force)
|
||||||
|
|
||||||
if action == stageActionSkip {
|
if action == stageActionSkip {
|
||||||
if validator, ok := s.(stage.ResumeValidator); ok {
|
validation, err := evaluateStageResume(ctx, s, stageEnv, m, semanticConfig)
|
||||||
validation, err := validator.ValidateResume(ctx, stageEnv, m)
|
if err != nil {
|
||||||
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if validation != nil && !validation.Resumable {
|
||||||
|
staleAt := nowUTC()
|
||||||
|
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
|
||||||
|
if _, err := invalidateDependentSucceededStagesWithReason(
|
||||||
|
m, s.Name(), staleAt, staleReasonNotResumable,
|
||||||
|
); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
fmt.Errorf("validate resume for stage %q: %w", s.Name(), err),
|
fmt.Errorf("invalidate dependents after resume validation for stage %q: %w", s.Name(), err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
validation = validation.Normalized()
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
if !validation.Resumable {
|
return nil, persistTerminalFailure(
|
||||||
staleAt := nowUTC()
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
|
fmt.Errorf("save manifest after resume validation for stage %q: %w", s.Name(), err),
|
||||||
if _, err := invalidateDependentSucceededStagesWithReason(
|
)
|
||||||
m, s.Name(), staleAt, staleReasonNotResumable,
|
|
||||||
); err != nil {
|
|
||||||
return nil, persistTerminalFailure(
|
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
|
||||||
fmt.Errorf("invalidate dependents after resume validation for stage %q: %w", s.Name(), err),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
|
||||||
return nil, persistTerminalFailure(
|
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
|
||||||
fmt.Errorf("save manifest after resume validation for stage %q: %w", s.Name(), err),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
env.Logger.Info("stage result is not resumable", "stage", s.Name(), "reason", validation.Reason)
|
|
||||||
action = stageActionRun
|
|
||||||
}
|
}
|
||||||
|
env.Logger.Info("stage result is not resumable", "stage", s.Name(), "reason", validation.Reason)
|
||||||
|
action = stageActionRun
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +305,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
|||||||
skipAt := nowUTC()
|
skipAt := nowUTC()
|
||||||
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
|
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
|
||||||
runManifest.MarkStageSkipped(s.Name(), skipAt, "already_succeeded")
|
runManifest.MarkStageSkipped(s.Name(), skipAt, "already_succeeded")
|
||||||
|
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
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()
|
skippedAt := nowUTC()
|
||||||
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||||
applyStageResultToManifest(m, s.Name(), result)
|
applyStageResultToManifest(m, s.Name(), result)
|
||||||
|
setSessionStageSemanticConfig(m, s.Name(), semanticConfig)
|
||||||
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
|
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
|
||||||
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip); err != nil {
|
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
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)
|
runManifest.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
|
||||||
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
||||||
|
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -418,6 +427,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
|||||||
m.MarkStageSucceeded(s.Name(), succeededAt, sessionOutputs)
|
m.MarkStageSucceeded(s.Name(), succeededAt, sessionOutputs)
|
||||||
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
applyAnalyzeProjection(m, runManifest, analyzeProjection)
|
||||||
applyStageResultToManifest(m, s.Name(), result)
|
applyStageResultToManifest(m, s.Name(), result)
|
||||||
|
setSessionStageSemanticConfig(m, s.Name(), semanticConfig)
|
||||||
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
|
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
|
||||||
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult); err != nil {
|
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
@@ -445,6 +455,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
|||||||
}
|
}
|
||||||
runManifest.MarkStageSucceeded(s.Name(), succeededAt, runOutputs)
|
runManifest.MarkStageSucceeded(s.Name(), succeededAt, runOutputs)
|
||||||
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
applyStageResultToRunManifest(runManifest, s.Name(), result)
|
||||||
|
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
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 := 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)
|
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 {
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -983,6 +984,7 @@ func TestExecuteStagesRunManifestRecordsSkippedStage(t *testing.T) {
|
|||||||
manifestPath := manifestPathFor(cfg)
|
manifestPath := manifestPathFor(cfg)
|
||||||
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
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)
|
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 {
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1232,6 +1234,7 @@ func TestExecuteStagesSkippedStagePreservesExistingOutputsProvenance(t *testing.
|
|||||||
ProducerRunID: "20260501T000000Z-deadbeef",
|
ProducerRunID: "20260501T000000Z-deadbeef",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
seedCurrentSemanticEvidence(t, cfg, existing, "transcribe")
|
||||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
t.Fatalf("MkdirAll() error = %v", err)
|
t.Fatalf("MkdirAll() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -1403,6 +1406,7 @@ inputs:
|
|||||||
mustWriteFile(t, pipelinePath, pipelineYAML)
|
mustWriteFile(t, pipelinePath, pipelineYAML)
|
||||||
mustWriteFile(t, campaignPath, campaignYAML)
|
mustWriteFile(t, campaignPath, campaignYAML)
|
||||||
mustWriteFile(t, sessionPath, sessionYAML)
|
mustWriteFile(t, sessionPath, sessionYAML)
|
||||||
|
mustWriteFile(t, filepath.Join(dir, "party.yml"), "legacy: party\n")
|
||||||
|
|
||||||
cfg, err := config.Load(pipelinePath, sessionPath)
|
cfg, err := config.Load(pipelinePath, sessionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/stage"
|
"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...)}
|
plan := BoundedPlan{stages: append([]stage.Stage(nil), stages...)}
|
||||||
return executePlan(ctx, cfg, plan, opts)
|
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.
|
// analyze invocation will execute.
|
||||||
type EffectiveArtifactSet struct {
|
type EffectiveArtifactSet struct {
|
||||||
keys []string
|
keys []string
|
||||||
|
origins map[string]EffectiveArtifactOrigin
|
||||||
resolved bool
|
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
|
// ResolveEffectiveArtifactSet applies an explicit artifact selection when one
|
||||||
// is supplied; otherwise it selects the configured enabled artifacts.
|
// is supplied; otherwise it selects the configured enabled artifacts.
|
||||||
func ResolveEffectiveArtifactSet(
|
func ResolveEffectiveArtifactSet(
|
||||||
@@ -88,6 +96,27 @@ func (s EffectiveArtifactSet) Resolved() bool {
|
|||||||
return s.resolved
|
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 {
|
func newEffectiveArtifactSet(set map[string]struct{}) EffectiveArtifactSet {
|
||||||
keys := make([]string, 0, len(set))
|
keys := make([]string, 0, len(set))
|
||||||
for key := range 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,
|
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",
|
"session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n",
|
||||||
)
|
)
|
||||||
|
|
||||||
cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
_, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{})
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("LoadWithSessionOptions() error = %v", err)
|
|
||||||
}
|
|
||||||
err = Validate(cfg)
|
|
||||||
if err == nil {
|
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())
|
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 {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
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
|
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
|
StableInputs ResolvedStableInputs
|
||||||
SessionSource SessionSource
|
SessionSource SessionSource
|
||||||
|
Party ResolvedParty
|
||||||
}
|
}
|
||||||
|
|
||||||
// PipelineConfig contains durable pipeline-level settings.
|
// PipelineConfig contains durable pipeline-level settings.
|
||||||
@@ -32,6 +33,17 @@ type PipelineConfig struct {
|
|||||||
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
Scriptorium *ScriptoriumConfig `yaml:"scriptorium"`
|
||||||
Notarius *NotariusConfig `yaml:"notarius"`
|
Notarius *NotariusConfig `yaml:"notarius"`
|
||||||
Notification NotificationConfig `yaml:"notification"`
|
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.
|
// CampaignsConfig configures the local campaign registry.
|
||||||
@@ -233,11 +245,12 @@ type RenderConfig struct {
|
|||||||
|
|
||||||
// ScriptoriumConfig configures Scriptorium-backed artifact generation.
|
// ScriptoriumConfig configures Scriptorium-backed artifact generation.
|
||||||
type ScriptoriumConfig struct {
|
type ScriptoriumConfig struct {
|
||||||
Binary string `yaml:"binary"`
|
Binary string `yaml:"binary"`
|
||||||
ConfigPath string `yaml:"config_path"`
|
ConfigPath string `yaml:"config_path"`
|
||||||
Timeout string `yaml:"timeout"`
|
Timeout string `yaml:"timeout"`
|
||||||
RenderDebug bool `yaml:"render_debug"`
|
RenderDebug bool `yaml:"render_debug"`
|
||||||
Artifacts map[string]ScriptoriumArtifactConfig `yaml:"artifacts"`
|
Artifacts map[string]ScriptoriumArtifactConfig `yaml:"artifacts"`
|
||||||
|
ArtifactFamilies map[string]ScriptoriumArtifactFamilyConfig `yaml:"artifact_families"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScriptoriumArtifactConfig configures one named output artifact workflow.
|
// ScriptoriumArtifactConfig configures one named output artifact workflow.
|
||||||
@@ -253,6 +266,57 @@ type ScriptoriumArtifactConfig struct {
|
|||||||
Vars map[string]any `yaml:"vars"`
|
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.
|
// ScriptoriumInputConfig configures one named prompt input source.
|
||||||
type ScriptoriumInputConfig struct {
|
type ScriptoriumInputConfig struct {
|
||||||
Source string `yaml:"source"`
|
Source string `yaml:"source"`
|
||||||
@@ -321,6 +385,23 @@ type ResolvedInputFile struct {
|
|||||||
Source string
|
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.
|
// SessionSource records where session.yml came from before materialization.
|
||||||
type SessionSource struct {
|
type SessionSource struct {
|
||||||
Source string
|
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.
|
// LoadPipeline loads pipeline configuration from a YAML file with strict field checking.
|
||||||
func LoadPipeline(path string) (*PipelineConfig, error) {
|
func LoadPipeline(path string) (*PipelineConfig, error) {
|
||||||
var cfg PipelineConfig
|
return LoadPipelineWithOptions(path, PipelineLoadOptions{})
|
||||||
if err := decodeStrictYAML("pipeline", path, &cfg); err != nil {
|
}
|
||||||
|
|
||||||
|
// 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)
|
return nil, fmt.Errorf("load pipeline config: %w", err)
|
||||||
}
|
}
|
||||||
applyPipelineDefaults(&cfg)
|
if _, err := selectPipelineProfile(sources.envelope, opts); err != nil {
|
||||||
if err := resolveNotariusPaths(&cfg, path); err != nil {
|
|
||||||
return nil, fmt.Errorf("load pipeline config: %w", err)
|
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.
|
// 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 {
|
type SessionLoadOptions struct {
|
||||||
SessionID string
|
SessionID string
|
||||||
PreviousSessionID string
|
PreviousSessionID string
|
||||||
|
Profile *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSessionWithOptions loads session configuration from a YAML file with
|
// 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
|
// LoadWithSessionOptions loads and resolves combined pipeline, campaign, and
|
||||||
// session configuration with expected session identity checks.
|
// session configuration with expected session identity checks.
|
||||||
func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -150,42 +232,168 @@ func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sess
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, SessionSource{
|
||||||
return Resolve(pipelinePath, pipelineCfg, campaignPath, campaignCfg, sessionPath, sessionCfg, SessionSource{
|
|
||||||
Source: "session_config",
|
Source: "session_config",
|
||||||
LocalPath: sessionPath,
|
LocalPath: sessionPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve builds final stage-facing configuration from already loaded
|
// ResolveLoadedPipelineCampaign combines an already loaded pipeline and
|
||||||
// pipeline, campaign, and session documents.
|
// campaign with optional already loaded session data. A nil session preserves
|
||||||
func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath string, campaignCfg *CampaignConfig, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
|
// the resolved pipeline/campaign context for callers that need to locate or
|
||||||
stableInputs, err := mergeCampaignSession(campaignCfg, sessionCfg, campaignPath, sessionPath)
|
// 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
|
||||||
|
}
|
||||||
|
loaded.Party = party
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
stableInputs, err := mergeCampaignSession(loaded.Campaign, sessionCfg, loaded.CampaignPath, sessionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(sessionSource.Source) == "" {
|
if strings.TrimSpace(cfg.SessionSource.Source) == "" {
|
||||||
sessionSource.Source = "session_config"
|
cfg.SessionSource.Source = "session_config"
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(sessionSource.LocalPath) == "" {
|
if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" {
|
||||||
sessionSource.LocalPath = sessionPath
|
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
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
// Resolve builds final stage-facing configuration from already loaded
|
||||||
Pipeline: pipelineCfg,
|
// pipeline, campaign, and session documents.
|
||||||
Campaign: campaignCfg,
|
func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath string, campaignCfg *CampaignConfig, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
|
||||||
Session: sessionCfg,
|
if sessionCfg == nil {
|
||||||
PipelinePath: pipelinePath,
|
return nil, fmt.Errorf("session config is required")
|
||||||
CampaignPath: campaignPath,
|
}
|
||||||
SessionPath: sessionPath,
|
loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg)
|
||||||
StableInputs: stableInputs,
|
if err != nil {
|
||||||
SessionSource: sessionSource,
|
return nil, err
|
||||||
}, nil
|
}
|
||||||
|
return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, sessionSource)
|
||||||
}
|
}
|
||||||
|
|
||||||
func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) {
|
func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestValidateMissingAudioSource(t *testing.T) {
|
||||||
cfg := loadedValidConfig(t)
|
cfg := loadedValidConfig(t)
|
||||||
cfg.Session.Inputs.AudioDir = ""
|
cfg.Session.Inputs.AudioDir = ""
|
||||||
@@ -54,6 +103,15 @@ func TestValidateMissingAudioSource(t *testing.T) {
|
|||||||
|
|
||||||
func TestExamplesLoadAndValidate(t *testing.T) {
|
func TestExamplesLoadAndValidate(t *testing.T) {
|
||||||
examplesDir := filepath.Join("..", "..", "examples")
|
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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
pipelineFile 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) {
|
func TestMaintainedExtractionExamplesPreservePublishedContracts(t *testing.T) {
|
||||||
@@ -176,6 +336,9 @@ inputs:
|
|||||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||||
t.Fatalf("write session.yml: %v", err)
|
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
|
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) {
|
func TestNotariusStrictYAML(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -117,8 +147,13 @@ func TestNotariusStrictYAML(t *testing.T) {
|
|||||||
if err := os.WriteFile(path, []byte(testPipelineBaseYAML+"\n"+tt.yaml), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(testPipelineBaseYAML+"\n"+tt.yaml), 0o644); err != nil {
|
||||||
t.Fatalf("write pipeline: %v", err)
|
t.Fatalf("write pipeline: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := LoadPipeline(path); err == nil || !strings.Contains(err.Error(), "strict decode failed") {
|
_, err := LoadPipeline(path)
|
||||||
t.Fatalf("LoadPipeline() error = %v, want strict decode failure", err)
|
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")
|
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)
|
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)
|
return fmt.Errorf("campaign config %q invalid: %w", shortName(cfg.CampaignPath, "campaign.yml"), err)
|
||||||
}
|
}
|
||||||
if err := validateSession(cfg.Session); err != nil {
|
if err := validateSession(cfg.Session); err != nil {
|
||||||
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
|
return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err)
|
||||||
}
|
}
|
||||||
|
if err := validateResolvedPartyInputs(cfg); err != nil {
|
||||||
|
return fmt.Errorf("campaign/session config invalid: %w", err)
|
||||||
|
}
|
||||||
if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
|
if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil {
|
||||||
return fmt.Errorf("pipeline/session config invalid: %w", err)
|
return fmt.Errorf("pipeline/session config invalid: %w", err)
|
||||||
}
|
}
|
||||||
@@ -47,6 +50,23 @@ func Validate(cfg *Config) error {
|
|||||||
return nil
|
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 {
|
func validateCampaign(cfg *CampaignConfig) error {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return fmt.Errorf("campaign config is required")
|
return fmt.Errorf("campaign config is required")
|
||||||
@@ -66,9 +86,6 @@ func validateCampaign(cfg *CampaignConfig) error {
|
|||||||
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
||||||
return fmt.Errorf("campaign.inputs.glossary_file is required")
|
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) == "" {
|
if strings.TrimSpace(cfg.Inputs.PartyFile) == "" {
|
||||||
return fmt.Errorf("campaign.inputs.party_file is required")
|
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))
|
configuredArtifacts := make(map[string]struct{}, len(cfg.Artifacts))
|
||||||
referencedArtifacts := make(map[string]struct{})
|
referencedArtifacts := make(map[string]struct{})
|
||||||
|
outputOwners := make(map[string]string, len(cfg.Artifacts))
|
||||||
for artifactName := range cfg.Artifacts {
|
for artifactName := range cfg.Artifacts {
|
||||||
if !artifactpolicy.IsConfiguredKey(artifactName) {
|
if !artifactpolicy.IsConfiguredKey(artifactName) {
|
||||||
return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$")
|
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 {
|
if err := validatePathWithinRoot(pathField, artifactCfg.OutputPath, DefaultScriptoriumArtifactOutputRoot); err != nil {
|
||||||
return err
|
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 {
|
if err := validateDuration("pipeline.scriptorium.artifacts."+artifactName+".timeout", artifactCfg.Timeout); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -794,12 +816,6 @@ func validateSession(cfg *SessionConfig) error {
|
|||||||
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
if strings.TrimSpace(cfg.Inputs.GlossaryFile) == "" {
|
||||||
return fmt.Errorf("session.inputs.glossary_file is required")
|
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) == "" {
|
if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" {
|
||||||
return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided")
|
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
|
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 {
|
func validateSessionIdentifier(fieldName, value string, required bool) error {
|
||||||
if strings.TrimSpace(value) == "" {
|
if strings.TrimSpace(value) == "" {
|
||||||
if required {
|
if required {
|
||||||
|
|||||||
@@ -60,9 +60,58 @@ func TestReleaseWorkflowRequiresValidation(t *testing.T) {
|
|||||||
if err := yaml.Unmarshal(data, &workflow); err != nil {
|
if err := yaml.Unmarshal(data, &workflow); err != nil {
|
||||||
t.Fatalf("parse release workflow: %v", err)
|
t.Fatalf("parse release workflow: %v", err)
|
||||||
}
|
}
|
||||||
if !workflowDependsOn(workflow.Steps, "publish-release", "validate", map[string]bool{}) {
|
validate, ok := workflow.Steps["validate-release"]
|
||||||
t.Fatal("publish-release must depend on validate so validation failures block releases")
|
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 {
|
func repositoryRoot(t *testing.T) string {
|
||||||
@@ -130,6 +179,9 @@ type woodpeckerWorkflow struct {
|
|||||||
|
|
||||||
type woodpeckerStep struct {
|
type woodpeckerStep struct {
|
||||||
DependsOn woodpeckerDependencies `yaml:"depends_on"`
|
DependsOn woodpeckerDependencies `yaml:"depends_on"`
|
||||||
|
Image string `yaml:"image"`
|
||||||
|
Commands []string `yaml:"commands"`
|
||||||
|
Settings map[string]any `yaml:"settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type woodpeckerDependencies []string
|
type woodpeckerDependencies []string
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ type AnalyzeArtifactRecord struct {
|
|||||||
Status AnalyzeArtifactStatus `json:"status"`
|
Status AnalyzeArtifactStatus `json:"status"`
|
||||||
FingerprintVersion int `json:"fingerprint_version,omitempty"`
|
FingerprintVersion int `json:"fingerprint_version,omitempty"`
|
||||||
Fingerprint string `json:"fingerprint,omitempty"`
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Family string `json:"family,omitempty"`
|
||||||
|
CharacterID string `json:"character_id,omitempty"`
|
||||||
Dependencies []string `json:"dependencies,omitempty"`
|
Dependencies []string `json:"dependencies,omitempty"`
|
||||||
Output *ArtifactRecord `json:"output,omitempty"`
|
Output *ArtifactRecord `json:"output,omitempty"`
|
||||||
OutputSize int64 `json:"output_size,omitempty"`
|
OutputSize int64 `json:"output_size,omitempty"`
|
||||||
@@ -134,6 +136,14 @@ func validateAnalyzeArtifactRecord(mapKey string, record AnalyzeArtifactRecord)
|
|||||||
if err := validateAnalyzeDependencies(record.Dependencies); err != nil {
|
if err := validateAnalyzeDependencies(record.Dependencies); err != nil {
|
||||||
return err
|
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 {
|
if err := validateAnalyzeFingerprint(record.FingerprintVersion, record.Fingerprint, record.Status == AnalyzeArtifactCurrent); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T
|
|||||||
records["session_recap"] = func() AnalyzeArtifactRecord {
|
records["session_recap"] = func() AnalyzeArtifactRecord {
|
||||||
record := records["session_recap"]
|
record := records["session_recap"]
|
||||||
record.Dependencies = []string{"quest_log", "gm_notes"}
|
record.Dependencies = []string{"quest_log", "gm_notes"}
|
||||||
|
record.Family = "character_meta"
|
||||||
|
record.CharacterID = "arannis"
|
||||||
return record
|
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"}) {
|
if got := analyze.AnalyzeArtifacts["session_recap"].Dependencies; !reflect.DeepEqual(got, []string{"gm_notes", "quest_log"}) {
|
||||||
t.Fatalf("canonical dependencies = %#v", got)
|
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)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type StageRecord struct {
|
|||||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||||
Error *ErrorRecord `json:"error,omitempty"`
|
Error *ErrorRecord `json:"error,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
SemanticConfig *SemanticConfigFingerprint `json:"semantic_config,omitempty"`
|
||||||
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||||
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -78,24 +79,39 @@ type PostPublishCleanup struct {
|
|||||||
Targets []CleanupTarget `json:"targets"`
|
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.
|
// Manifest is the durable run-state record for a session execution.
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
Campaign string `json:"campaign,omitempty"`
|
Campaign string `json:"campaign,omitempty"`
|
||||||
RunID string `json:"run_id,omitempty"`
|
RunID string `json:"run_id,omitempty"`
|
||||||
LocalWorkDir string `json:"local_workdir,omitempty"`
|
LocalWorkDir string `json:"local_workdir,omitempty"`
|
||||||
LocalSpoolDir string `json:"local_spool_dir,omitempty"`
|
LocalSpoolDir string `json:"local_spool_dir,omitempty"`
|
||||||
S3Bucket string `json:"s3_bucket,omitempty"`
|
S3Bucket string `json:"s3_bucket,omitempty"`
|
||||||
S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
|
S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
|
||||||
S3RunPrefix string `json:"s3_run_prefix,omitempty"`
|
S3RunPrefix string `json:"s3_run_prefix,omitempty"`
|
||||||
PipelineVersion string `json:"pipeline_version,omitempty"`
|
PipelineVersion string `json:"pipeline_version,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
EffectiveConfig *EffectiveConfigProvenance `json:"effective_config,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
LastError *ErrorRecord `json:"last_error,omitempty"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
Inputs []InputRecord `json:"inputs,omitempty"`
|
LastError *ErrorRecord `json:"last_error,omitempty"`
|
||||||
Artifacts []ArtifactRecord `json:"artifacts,omitempty"`
|
Inputs []InputRecord `json:"inputs,omitempty"`
|
||||||
Stages map[string]*StageRecord `json:"stages"`
|
Artifacts []ArtifactRecord `json:"artifacts,omitempty"`
|
||||||
PostPublishCleanup *PostPublishCleanup `json:"post_publish_cleanup,omitempty"`
|
Stages map[string]*StageRecord `json:"stages"`
|
||||||
|
PostPublishCleanup *PostPublishCleanup `json:"post_publish_cleanup,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a new manifest with deterministic timestamps.
|
// New constructs a new manifest with deterministic timestamps.
|
||||||
@@ -177,6 +193,7 @@ func (s *StageRecord) clearResultDetails() {
|
|||||||
s.Logs = nil
|
s.Logs = nil
|
||||||
s.GeneratedConfigs = nil
|
s.GeneratedConfigs = nil
|
||||||
s.Metadata = nil
|
s.Metadata = nil
|
||||||
|
s.SemanticConfig = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manifest) ensureStage(name string, at time.Time) *StageRecord {
|
func (m *Manifest) ensureStage(name string, at time.Time) *StageRecord {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type RunStageRecord struct {
|
|||||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||||
Error *ErrorRecord `json:"error,omitempty"`
|
Error *ErrorRecord `json:"error,omitempty"`
|
||||||
Metadata map[string]any `json:"metadata,omitempty"`
|
Metadata map[string]any `json:"metadata,omitempty"`
|
||||||
|
SemanticConfig *SemanticConfigFingerprint `json:"semantic_config,omitempty"`
|
||||||
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||||
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -51,6 +52,7 @@ type RunManifest struct {
|
|||||||
S3Bucket string `json:"s3_bucket,omitempty"`
|
S3Bucket string `json:"s3_bucket,omitempty"`
|
||||||
S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
|
S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
|
||||||
S3RunPrefix string `json:"s3_run_prefix,omitempty"`
|
S3RunPrefix string `json:"s3_run_prefix,omitempty"`
|
||||||
|
EffectiveConfig *EffectiveConfigProvenance `json:"effective_config,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
|||||||
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