31 Commits

Author SHA1 Message Date
545aa6893b Prepare the v1.6.0 release
All checks were successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
2026-08-30 20:40:04 +00:00
98139f7e8b Harden release validation 2026-08-30 20:36:23 +00:00
f8fa0a2623 Document release procedure and complete release audit 2026-08-30 19:27:36 +00:00
9af773491b Use shared release scripts in Woodpecker 2026-08-30 19:22:26 +00:00
2a656f0f11 Add guarded release tag command 2026-08-30 19:20:12 +00:00
aec35a2d9b Add release candidate checker 2026-08-30 19:16:02 +00:00
23dc4e2078 Add release asset builder 2026-08-30 19:10:26 +00:00
c812fe3655 Harden pipeline state and plan release upgrades 2026-08-30 18:51:20 +00:00
3da97ca50c Add assembled pipeline configuration regression coverage 2026-08-30 15:26:23 +00:00
4c203d8588 Document pipeline configuration migration workflow 2026-08-30 15:19:30 +00:00
fcb5f825e1 Add split pipeline configuration example bundle 2026-08-30 15:17:46 +00:00
4c57ace2f6 Add semantic configuration profile comparison 2026-08-30 15:10:38 +00:00
dde7f76ecb Add configuration source reporting 2026-08-30 14:56:22 +00:00
a102db36af Add read-only configuration inspection commands 2026-08-30 14:43:25 +00:00
b5b1d22011 Expand family publish policies into concrete rules 2026-08-30 14:30:07 +00:00
c91599ef36 Add family artifact selection and provenance 2026-08-30 14:27:42 +00:00
257f10c9fb Resolve corresponding artifact family dependencies 2026-08-30 14:20:03 +00:00
fb9a4d14f4 Expand canonical party artifact families 2026-08-30 14:15:57 +00:00
c4435b76c4 Prepare canonical party and derived players inputs 2026-08-30 14:08:35 +00:00
61000a9466 Resolve canonical parties with campaign configuration 2026-08-30 14:01:51 +00:00
7e4ceb3d48 Add canonical party domain and players projection 2026-08-30 13:51:12 +00:00
4e991fa21d Expose pipeline profile selection and provenance 2026-08-30 13:41:26 +00:00
f86b17045d Unify application configuration loading 2026-08-30 13:32:45 +00:00
f302488075 Add named pipeline profile composition 2026-08-30 13:25:02 +00:00
8c1171478d Complete downstream semantic resume coverage 2026-08-30 13:14:33 +00:00
7ee637803d Protect transcript refinement resume semantics 2026-08-30 13:03:13 +00:00
4d6086fefb Protect initial pipeline stage resume semantics 2026-08-30 12:54:38 +00:00
82cb53e107 Add semantic configuration resume evidence 2026-08-30 12:44:26 +00:00
b97b12da7f Add explicit pipeline configuration imports 2026-08-30 12:33:43 +00:00
49ea747b17 Add presence-aware configuration composition 2026-08-30 12:21:27 +00:00
3b5e9db41f Plan pipeline configuration improvements 2026-08-30 03:41:56 +00:00
132 changed files with 12981 additions and 386 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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 |

View File

@@ -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

View File

@@ -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`).

View File

@@ -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

View 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`.

View 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;

View 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.

View File

@@ -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.

View File

@@ -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,

View File

@@ -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`,

View File

@@ -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`,

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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`

View File

@@ -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:

View File

@@ -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

View File

@@ -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
View 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.

View File

@@ -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
View 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.

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -1,2 +0,0 @@
- name: Example Player
role: player

View 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

View File

@@ -0,0 +1,12 @@
workspace:
root: ./workspace
campaigns:
root: ../../campaigns
default_campaign_id: sample-campaign
cache:
root: ./cache
spool:
root: ./spool

View File

@@ -0,0 +1,7 @@
publish:
enabled: true
upload_run: false
outputs:
- source: narratio.transcript.final_markdown
dest: transcripts/final.md
required: true

View File

@@ -0,0 +1,2 @@
storage:
backend: local

View 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

View 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

View 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

View 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

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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

View File

@@ -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())

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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)

View File

@@ -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
} }

View 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
})
}

View 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
View 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
}

View File

@@ -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) {

View 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)
}
}

View 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)
}

View File

@@ -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) != "" {

View File

@@ -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(),
} }
} }

View File

@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -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, ", ")
} }

View File

@@ -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)
} }

View 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)
}
}

View File

@@ -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)

View File

@@ -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)
} }

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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)
} }

View File

@@ -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,19 +261,24 @@ 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( 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("validate resume for stage %q: %w", s.Name(), err),
) )
} }
validation = validation.Normalized() if validation != nil && !validation.Resumable {
if !validation.Resumable {
staleAt := nowUTC() staleAt := nowUTC()
m.MarkStageStale(s.Name(), staleAt, validation.Reason) m.MarkStageStale(s.Name(), staleAt, validation.Reason)
if _, err := invalidateDependentSucceededStagesWithReason( if _, err := invalidateDependentSucceededStagesWithReason(
@@ -292,13 +299,13 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
action = stageActionRun action = stageActionRun
} }
} }
}
if action == stageActionSkip { if action == stageActionSkip {
skipped = append(skipped, s.Name()) skipped = append(skipped, s.Name())
skipAt := nowUTC() skipAt := nowUTC()
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt) runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
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(

View File

@@ -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 {

View File

@@ -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
}

View 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 &copy
}

View 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: &current, 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, &current)
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 = &current
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
}

View File

@@ -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 {

View 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 &copy
}
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
}

View 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
}

View File

@@ -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
} }

View 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)
}
}

View 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)
}
}

View File

@@ -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.
@@ -238,6 +250,7 @@ type ScriptoriumConfig struct {
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

View 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 = &notarius
}
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))
}

View 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))
}

View File

@@ -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 { if err != nil {
return nil, err return nil, err
} }
if strings.TrimSpace(sessionSource.Source) == "" { loaded.Party = party
sessionSource.Source = "session_config"
} }
if strings.TrimSpace(sessionSource.LocalPath) == "" { if loaded.Party.Mode == PartyModeCanonical {
sessionSource.LocalPath = sessionPath if err := validateCanonicalPartySelection(loaded.Campaign, nil); err != nil {
return nil, err
}
}
cfg := &Config{
Pipeline: loaded.Pipeline,
Campaign: loaded.Campaign,
Session: sessionCfg,
PipelinePath: loaded.PipelinePath,
CampaignPath: loaded.CampaignPath,
SessionPath: sessionPath,
SessionSource: sessionSource,
Party: loaded.Party,
}
if cfg.Party.Mode == PartyModeCanonical && sessionCfg != nil {
if err := validateCanonicalPartySelection(loaded.Campaign, sessionCfg); err != nil {
return nil, err
}
}
if sessionCfg == nil {
return cfg, nil
} }
return &Config{ stableInputs, err := mergeCampaignSession(loaded.Campaign, sessionCfg, loaded.CampaignPath, sessionPath)
Pipeline: pipelineCfg, if err != nil {
Campaign: campaignCfg, return nil, err
Session: sessionCfg, }
PipelinePath: pipelinePath, if strings.TrimSpace(cfg.SessionSource.Source) == "" {
CampaignPath: campaignPath, cfg.SessionSource.Source = "session_config"
SessionPath: sessionPath, }
StableInputs: stableInputs, if strings.TrimSpace(cfg.SessionSource.LocalPath) == "" {
SessionSource: sessionSource, cfg.SessionSource.LocalPath = sessionPath
}, nil }
if cfg.Party.Mode == PartyModeCanonical {
stableInputs.PlayersFile = virtualPlayersInput()
cfg.Session.Inputs.PlayersFile = ""
} else {
party, err := resolvePartyInput(stableInputs.PartyFile)
if err != nil {
return nil, err
}
if party.Mode == PartyModeCanonical {
return nil, fmt.Errorf("session.inputs.party_file cannot select a canonical party; canonical parties are campaign-owned")
}
cfg.Party = party
if strings.TrimSpace(stableInputs.PlayersFile.Path) == "" {
return nil, fmt.Errorf("players_file is required with a legacy party")
}
}
cfg.StableInputs = stableInputs
return cfg, nil
}
// Resolve builds final stage-facing configuration from already loaded
// pipeline, campaign, and session documents.
func Resolve(pipelinePath string, pipelineCfg *PipelineConfig, campaignPath string, campaignCfg *CampaignConfig, sessionPath string, sessionCfg *SessionConfig, sessionSource SessionSource) (*Config, error) {
if sessionCfg == nil {
return nil, fmt.Errorf("session config is required")
}
loaded, err := LoadPipelineCampaign(pipelinePath, pipelineCfg, campaignPath, campaignCfg)
if err != nil {
return nil, err
}
return ResolveLoadedPipelineCampaign(loaded, sessionPath, sessionCfg, sessionSource)
} }
func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) { func campaignSessionPaths(paths ...string) (campaignPath, sessionPath string, err error) {

View File

@@ -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
} }

View File

@@ -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
View 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
}

View 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
}

View 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"}
}

View 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
}

View 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: [night]
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")
}
}

View 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
}

View 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)
}

View 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))
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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 {

View File

@@ -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,6 +79,20 @@ 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"`
@@ -89,6 +104,7 @@ type Manifest struct {
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"`
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"`
LastError *ErrorRecord `json:"last_error,omitempty"` LastError *ErrorRecord `json:"last_error,omitempty"`
@@ -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 {

View File

@@ -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"`

View 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