Adopt ConfigKit overlays for pipeline selection
This commit is contained in:
40
docs/cli.md
40
docs/cli.md
@@ -44,7 +44,7 @@ Most session-aware commands accept:
|
||||
- `--session <session.yml>`
|
||||
- `--session-id <session_id>`
|
||||
- `--previous-session-id <session_id>`
|
||||
- `--profile <name>`
|
||||
- `--overlay <name>`
|
||||
- `--secrets-dir <directory>`
|
||||
|
||||
Rules:
|
||||
@@ -54,9 +54,9 @@ Rules:
|
||||
- if both positional `<session_id>` and `--session-id` are provided, values must match.
|
||||
- `--previous-session-id` is a strict expectation: the selected session file
|
||||
must contain the same `previous_session_id`.
|
||||
- `--profile` selects `profiles/<name>.yml` relative to the pipeline root. It
|
||||
- `--overlay` selects `overlays/<name>.yml` relative to the pipeline root. It
|
||||
may be supplied once; omitted, empty, and the exact name `default` select the
|
||||
base configuration, while an unknown named profile fails. The same selection
|
||||
base configuration, while an unknown named overlay fails. The same selection
|
||||
applies to all common-flag commands, including `regenerate-artifacts`.
|
||||
- `--secrets-dir` selects ConfigKit credential-file bootstrap explicitly. A
|
||||
relative value uses caller CWD. Without the flag, a present `secrets/`
|
||||
@@ -85,14 +85,14 @@ Commands with additional positionals keep their command-specific order:
|
||||
### `config validate`, `config show`, `config sources`, `config artifacts`, and `config diff`
|
||||
|
||||
```bash
|
||||
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||
narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||
narratio config artifacts [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <directory>]
|
||||
narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <directory>]
|
||||
narratio config validate [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
|
||||
narratio config show [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
|
||||
narratio config sources [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
|
||||
narratio config artifacts [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <directory>]
|
||||
narratio config diff <left-overlay> <right-overlay> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <directory>]
|
||||
```
|
||||
|
||||
These commands resolve the selected profile, defaults, ordinary paths, and—if
|
||||
These commands resolve the selected overlay, 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, or remote
|
||||
connection. Like every pipeline-loading command, they bootstrap the selected or
|
||||
@@ -107,7 +107,7 @@ 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
|
||||
`config validate` writes a concise root-path, selected-overlay (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 ConfigKit directives, artifact
|
||||
@@ -115,9 +115,9 @@ 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 source, and digest. The
|
||||
overlay and its file, selected campaign, party source, and digest. The
|
||||
remaining tab-separated records are sorted as `path`, `role`, and `source`.
|
||||
Roles distinguish root, import, profile, centralized default, campaign, party,
|
||||
Roles distinguish root, import, overlay, centralized default, campaign, party,
|
||||
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.
|
||||
@@ -133,14 +133,14 @@ party-driven families retain the campaign requirement. See
|
||||
[Scriptorium Artifact Entries](./config.md#scriptorium-artifact-entries) for the
|
||||
configuration layout.
|
||||
|
||||
`config diff` resolves both supplied profile names independently and compares
|
||||
`config diff` resolves both supplied overlay names independently 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
|
||||
not accept `--overlay`; the two positional names must be distinct, valid
|
||||
overlays. When party-driven families are present, both overlays must resolve to
|
||||
the same selected campaign and party. Use `--campaign-file` if overlay-specific
|
||||
campaign configuration would otherwise select different files.
|
||||
|
||||
Equal profiles print `no differences`. Otherwise, sorted tab-separated records
|
||||
Equal overlays print `no differences`. Otherwise, sorted tab-separated records
|
||||
use one of these forms, with compact JSON values:
|
||||
|
||||
```text
|
||||
@@ -151,7 +151,7 @@ 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
|
||||
overlay names, source-file layout, or formatting. It succeeds when differences
|
||||
are found, making it suitable for review and migration checks.
|
||||
|
||||
### `version`
|
||||
@@ -189,7 +189,7 @@ Behavior:
|
||||
- writes the session manifest as the sole durable progress ledger. Each
|
||||
invocation also creates its run-local directory and returns an immediate
|
||||
command summary; neither creates another durable progress record.
|
||||
- reports the resolved profile (or `none`) and effective configuration digest.
|
||||
- reports the resolved overlay (or `none`) and effective configuration digest.
|
||||
|
||||
When `--artifacts` is present, the selected range must contain `analyze` or
|
||||
`publish`. Either consumer is sufficient, including a one-stage range.
|
||||
@@ -304,7 +304,7 @@ Uses the same inclusive bounds, endpoint validation, force scope, and artifact
|
||||
selection contract as `run`. It validates config and prints run/skip decisions
|
||||
for selected stages only without creating the local workdir or changing the
|
||||
manifest. Resume-capable selected stages are checked against durable evidence.
|
||||
The output includes the resolved profile (or `none`) and effective configuration
|
||||
The output includes the resolved overlay (or `none`) and effective configuration
|
||||
digest without writing provenance or any manifest state.
|
||||
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
|
||||
execution order, and reusable current artifacts with concise reasons. These
|
||||
|
||||
@@ -47,24 +47,24 @@ the durable copied session input.
|
||||
`narratio config validate`, `narratio config show`, `narratio config sources`,
|
||||
and `narratio config artifacts` use the same `--config`, `--campaign`,
|
||||
`--campaign-file`, and
|
||||
`--profile` selection rules as pipeline commands, but do not select, discover,
|
||||
`--overlay` selection rules as pipeline commands, but do not select, discover,
|
||||
or load a session. They resolve configured typed credential references as part
|
||||
of the ordinary ConfigKit load but expose only their environment-variable names;
|
||||
they do not create runtime state.
|
||||
`narratio config diff <left-profile> <right-profile>` uses the same pipeline and
|
||||
campaign selectors, resolves each named profile with an independent ConfigKit
|
||||
load, and does not accept a separate `--profile` flag.
|
||||
`narratio config diff <left-overlay> <right-overlay>` uses the same pipeline and
|
||||
campaign selectors, resolves each named overlay with an independent ConfigKit
|
||||
load, and does not accept a separate `--overlay` 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
|
||||
root, overlay, and effective digest. `config show` emits the normalized
|
||||
effective pipeline YAML, with defaults and concrete expansion included but
|
||||
ConfigKit directives and family declarations omitted. This review projection is
|
||||
not promised to be a reloadable authoring document. `config sources` prints a stable
|
||||
source projection instead of effective values: root/import/profile/default
|
||||
source projection instead of effective values: root/import/overlay/default
|
||||
ownership plus campaign/party and generated-family records. Canonical derived
|
||||
players trace to the party. `config artifacts` lists the concrete artifact map,
|
||||
effective dependencies, and defining sources. The [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
|
||||
@@ -83,7 +83,7 @@ difference.
|
||||
Use the [production/testing bundle](../examples/production-testing/pipeline.yml)
|
||||
as the complete copyable migration reference. Keep production settings in the
|
||||
base, split them into explicit additive imports, and place testing differences
|
||||
in the fixed `profiles/testing.yml` overlay. Put ordinary artifacts and families
|
||||
in the fixed `overlays/testing.yml` overlay. Put ordinary artifacts and families
|
||||
in the recommended one-definition files shown by that bundle. Convert campaign
|
||||
rosters to [canonical party input](integrations/party.md), then express character
|
||||
work as families. Inspect the result with `config validate`, `config show`,
|
||||
@@ -108,11 +108,14 @@ remote state with an unsafe legacy identity must be migrated before use.
|
||||
root-only `configkit.imports` list. Imported files contribute fields to one
|
||||
logical pipeline document; they do not override fields supplied by the root
|
||||
or another import.
|
||||
- An omitted profile, an empty profile option, and the exact name `default`
|
||||
- An omitted overlay, an empty overlay option, and the exact name `default`
|
||||
select the base configuration. Any other name selects
|
||||
`profiles/<name>.yml` relative to the pipeline root.
|
||||
`overlays/<name>.yml` relative to the pipeline root. Move previously selected
|
||||
`profiles/<name>.yml` files there; the old directory is not searched, and
|
||||
`--profile` is no longer accepted for Narratio pipeline selection. Explicit
|
||||
imports from a directory named `profiles` remain valid.
|
||||
- Campaign, session, and canonical-party documents are strict single-source
|
||||
ConfigKit schemas. They do not enable imports, profiles, or `configkit`
|
||||
ConfigKit schemas. They do not enable imports, overlays, or `configkit`
|
||||
directives.
|
||||
- Configured timeout and retry-delay durations must be positive. An omitted
|
||||
artifact timeout continues to inherit its configured Scriptorium timeout.
|
||||
@@ -137,10 +140,10 @@ remote state with an unsafe legacy identity must be migrated before use.
|
||||
- local (`audio_dir` or `audio_files`), or
|
||||
- S3 (`audio_s3.prefix`).
|
||||
|
||||
### Pipeline imports and profiles
|
||||
### Pipeline imports and overlays
|
||||
|
||||
Large pipeline configurations may be split into explicitly named fragments and
|
||||
may use fixed-path profile overlays:
|
||||
may use fixed-path selected overlays:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
@@ -170,12 +173,12 @@ name the full field path and every source that claimed it. Narratio additionally
|
||||
rejects an artifact or artifact-family key declared by more than one base file,
|
||||
even when those files contribute disjoint children.
|
||||
|
||||
Profile names are case-sensitive and use ConfigKit's safe fixed-path naming
|
||||
Overlay names are case-sensitive and use ConfigKit's safe fixed-path naming
|
||||
rules. Only the selected overlay is opened; a malformed unselected overlay does
|
||||
not affect a base or different-profile load. Maps merge recursively, overlay
|
||||
not affect a base or different-overlay load. Maps merge recursively, overlay
|
||||
scalars replace base scalars, and overlay lists replace base lists completely.
|
||||
Explicit `false`, zero, empty strings, empty containers, and null values remain
|
||||
explicit inputs and are not refilled by defaults. Profiles cannot inherit,
|
||||
explicit inputs and are not refilled by defaults. Overlays cannot inherit,
|
||||
stack, import files, or declare another ConfigKit control section.
|
||||
|
||||
During ConfigKit loading, Narratio applies fresh typed defaults, recorded
|
||||
@@ -183,7 +186,7 @@ entry-level defaults and normalization, read-only file-local validation, and
|
||||
ordinary path resolution before computing a deterministic effective
|
||||
configuration digest. The digest represents the normalized, secret-free
|
||||
runtime pipeline mapping; it excludes ConfigKit directives, source
|
||||
provenance, profile identity, and raw environment secret values. Equivalent
|
||||
provenance, overlay 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.
|
||||
|
||||
@@ -400,9 +403,9 @@ empty string or `default`, which explicitly select Notarius's composed base
|
||||
configuration. Notarius owns selector interpretation and validation; Narratio
|
||||
does not restrict profile names, resolve profile files, or rewrite the value.
|
||||
|
||||
This selection is independent of Narratio's own `--profile` selection. A
|
||||
This selection is independent of Narratio's own `--overlay` selection. A
|
||||
Narratio overlay may explicitly select a differently named Notarius overlay,
|
||||
but Narratio never forwards its own profile name automatically. It is also
|
||||
but Narratio never forwards its own overlay name automatically. It is also
|
||||
independent of `pipeline.notarius.llm_api_key`: an overlay may change either or
|
||||
both fields according to the deployed Notarius configuration. See the
|
||||
[Notarius integration contract](./integrations/notarius.md) for invocation and
|
||||
|
||||
@@ -43,7 +43,7 @@ field acquire no additional version requirement. Omitted or null configuration
|
||||
adds no flag. Supplied empty, `default`, and named selectors are forwarded
|
||||
without normalization so Notarius can apply its own ConfigKit semantics. This
|
||||
option selects a Notarius configuration overlay, not a PromptKit profile through
|
||||
`--llm-profile`, and Narratio never substitutes its own selected profile name.
|
||||
`--llm-profile`, and Narratio never substitutes its own selected overlay name.
|
||||
A missing or invalid downstream overlay is an ordinary Notarius subprocess
|
||||
failure: Narratio does not retry without the selector or fall back to another
|
||||
profile.
|
||||
|
||||
@@ -9,9 +9,9 @@ pipeline-loading boundary implemented by `internal/config`.
|
||||
`LoadPipeline` assembles and validates a pipeline in this order:
|
||||
|
||||
1. Give ConfigKit a fresh typed `PipelineConfig` default value, the root file,
|
||||
enabled imports, and the caller's profile selection. Omitted, empty, and
|
||||
exact `default` selections use the base; named profiles use the fixed
|
||||
`profiles/<name>.yml` convention.
|
||||
enabled imports, and the caller's overlay selection. Omitted, empty, and
|
||||
exact `default` selections use the base; named overlays use the fixed
|
||||
`overlays/<name>.yml` convention.
|
||||
2. ConfigKit parses the root and declared imports, additively merges base
|
||||
sources, applies only the selected overlay, and strictly decodes the result.
|
||||
3. The definition resolver applies recorded entry-level and inherited defaults,
|
||||
@@ -45,7 +45,7 @@ a temporary downloaded session file therefore cannot invalidate the resolved
|
||||
pipeline or campaign context.
|
||||
|
||||
Campaign and session files use independent ConfigKit definitions with imports
|
||||
and profiles disabled. Campaign files normally load directly from their source;
|
||||
and overlays disabled. Campaign files normally load directly from their source;
|
||||
session files are first read through the 8 MiB bounded-read boundary because the
|
||||
application must reject unresolved template placeholders before passing an exact
|
||||
byte snapshot to ConfigKit. Local and remotely downloaded session bytes then use
|
||||
@@ -53,8 +53,9 @@ the same typed loader and retain the existing post-load identity expectations,
|
||||
stable-input precedence, and source-relative path semantics.
|
||||
|
||||
The application also has a separate read-only inspection resolver for `config
|
||||
validate`, `config show`, `config sources`, and `config artifacts`. It uses the same production root/profile and
|
||||
campaign selection functions, but never routes through session discovery,
|
||||
validate`, `config show`, `config sources`, and `config artifacts`. It uses the
|
||||
same production root, overlay, and campaign selection functions, but never routes
|
||||
through session discovery,
|
||||
remote-session download, adapter composition, workspace
|
||||
initialization, manifest access, or cleanup. A pipeline with retained artifact
|
||||
family declarations must resolve its selected campaign before ordinary pipeline
|
||||
@@ -73,7 +74,7 @@ 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 projected from the schema and classifies
|
||||
each contributor as root, import, profile, or centralized default. The
|
||||
each contributor as root, import, overlay, 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
|
||||
@@ -106,7 +107,7 @@ configured roster authority; player and session-party fields are unsupported.
|
||||
## Canonical Party Domain
|
||||
|
||||
`ParseParty` is the package-owned boundary for parsing a party source. Its
|
||||
single-source ConfigKit definition disables imports and profiles, requires
|
||||
single-source ConfigKit definition disables imports and overlays, requires
|
||||
`schema_version` through `VersionRule`, and strictly decodes the
|
||||
character-keyed typed schema before applying Narratio's party-domain checks.
|
||||
Successful metadata coordinates recover authored character-map order without a
|
||||
@@ -144,14 +145,14 @@ not compatibility contracts.
|
||||
|
||||
## Diagnostics And Runtime Metadata
|
||||
|
||||
Syntax, duplicate-key, merge, profile, and schema failures include the
|
||||
Syntax, duplicate-key, merge, overlay, 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, original ConfigKit metadata and source snapshots, effective digest,
|
||||
path, ordered imports, selected overlay name, source, and path, original
|
||||
ConfigKit metadata and source snapshots, 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
|
||||
@@ -163,7 +164,7 @@ the same typed safe projection used by show and diff. Runtime Notarius paths rem
|
||||
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 ConfigKit and resolution metadata
|
||||
are private, the digest excludes source layout, profile name, and ownership.
|
||||
are private, the digest excludes source layout, overlay name, and ownership.
|
||||
Configuration stores credentials as ConfigKit typed references. A successful
|
||||
load resolves their values inside the opaque typed boundary, while this safe
|
||||
projection serializes only `EnvName()` identities. Credential-value changes are
|
||||
@@ -177,7 +178,7 @@ for later runtime expansion.
|
||||
|
||||
## Test Surfaces
|
||||
|
||||
`pipeline_configkit_test.go` exercises real imports and fixed-path profiles,
|
||||
`pipeline_configkit_test.go` exercises real imports and fixed-path overlays,
|
||||
base selection, unselected-overlay isolation, source attribution, and Narratio's
|
||||
unique artifact/family base-declaration rule. Existing focused configuration
|
||||
tests protect defaults, explicit-value semantics, strict decoding, validation,
|
||||
|
||||
@@ -23,9 +23,11 @@ portable opaque segments. Unsafe legacy identities are rejected with migration
|
||||
guidance rather than being normalized into a different workspace or remote
|
||||
namespace.
|
||||
|
||||
Decoding is strict at every typed manifest field. Unknown fields are rejected;
|
||||
the schema version remains `narratio.session-manifest.v1` and there is no
|
||||
compatibility reader that silently drops retired state. Cleanup progress uses
|
||||
Decoding is strict at typed execution-state fields. Unknown informational fields
|
||||
inside `effective_config` are ignored so older selection labels can be read;
|
||||
unknown fields elsewhere are rejected. The schema version remains
|
||||
`narratio.session-manifest.v1` and there is no compatibility reader that
|
||||
silently drops retired execution state. Cleanup progress uses
|
||||
only the cleanup entry in `stages`.
|
||||
|
||||
Every persisted session manifest has a non-nil `stages` map. Each entry is
|
||||
@@ -306,10 +308,10 @@ after such a private content change.
|
||||
|
||||
The session manifest is the authoritative stage-progress ledger across
|
||||
invocations. It retains 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
|
||||
configuration provenance. It identifies the selected overlay name and source
|
||||
(`explicit` when selected) plus the effective configuration digest, but never a raw
|
||||
secret or overlay content. This provenance is informational: it does not
|
||||
participate in stage resume or cache decisions. An overlay 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.
|
||||
|
||||
@@ -70,19 +70,19 @@ narratio run 2026-04-04
|
||||
narratio session status 2026-04-04
|
||||
```
|
||||
|
||||
Run, plan, and status output identify the resolved pipeline profile (or `none`)
|
||||
Run, plan, and status output identify the resolved pipeline overlay (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.
|
||||
diagnose overlay switches without changing resume authority.
|
||||
|
||||
Before switching an operational profile, compare its effective meaning with the
|
||||
production base through `narratio config diff default <profile>`.
|
||||
Before switching an operational overlay, compare its effective meaning with the
|
||||
production base through `narratio config diff default <overlay>`.
|
||||
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. Use `narratio config artifacts` to review the
|
||||
concrete artifact identities, definitions, outputs, and dependencies. Select the
|
||||
same campaign explicitly when profiles could resolve different campaign paths;
|
||||
same campaign explicitly when overlays could resolve different campaign paths;
|
||||
see the [CLI reference](cli.md#config-validate-config-show-config-sources-config-artifacts-and-config-diff)
|
||||
for syntax and record format.
|
||||
|
||||
@@ -284,17 +284,17 @@ intends to replace otherwise-current artifacts.
|
||||
### Developing With A Notarius Configuration Profile
|
||||
|
||||
A Narratio development overlay can explicitly select a Notarius overlay without
|
||||
requiring the names to match. For example, `profiles/testing.yml` may contain:
|
||||
requiring the names to match. For example, `overlays/testing.yml` may contain:
|
||||
|
||||
```yaml
|
||||
notarius:
|
||||
config_profile: downstream-economy
|
||||
```
|
||||
|
||||
Selecting Narratio's `testing` profile then supplies `downstream-economy` only
|
||||
Selecting Narratio's `testing` overlay then supplies `downstream-economy` only
|
||||
when extraction executes. Switching that explicit selector makes a recorded
|
||||
extraction non-current and reruns it when `extract` is in the selected stage
|
||||
range; the usual dependent invalidation then applies. Selecting a profile does
|
||||
range; the usual dependent invalidation then applies. Selecting an overlay does
|
||||
not create a separate workspace or retain a separate current extraction result.
|
||||
|
||||
Narratio cannot observe edits inside Notarius configuration behind an unchanged
|
||||
|
||||
@@ -152,13 +152,13 @@ Configuration is strict, explicit, centralized, and operator-oriented.
|
||||
- Empty configured values do not silently replace meaningful defaults.
|
||||
- Validation rejects invalid configuration before stage execution where
|
||||
practical.
|
||||
- Root-owned imports and one selected profile resolve deterministically through
|
||||
- Root-owned imports and one selected overlay 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
|
||||
result-affecting configuration; overlay identity and an effective digest are
|
||||
provenance, never blanket cache keys.
|
||||
- Session templating remains narrow and deterministic rather than becoming a
|
||||
general configuration language.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Implementation Plan: Audita v2 and Shared Provider Credentials
|
||||
|
||||
Status: Not started.
|
||||
Status: In progress.
|
||||
|
||||
## Scope and working instructions
|
||||
|
||||
@@ -164,7 +164,12 @@ extraction fingerprints, references, force, resume, or chunk-cache behavior.
|
||||
|
||||
## Stage 1: Adopt ConfigKit v0.4.0 and pipeline overlays
|
||||
|
||||
Status: Not started.
|
||||
Status: Complete (2026-09-25).
|
||||
|
||||
Verification: `go test ./internal/config ./internal/app ./internal/manifest`,
|
||||
`go test ./...`, `go test ./internal/doccheck`,
|
||||
`go test ./internal/config -run '^TestExamplesLoadAndValidate$'`, and
|
||||
`git diff --check` passed.
|
||||
|
||||
Preparation: ConfigKit v0.4.0 release notes and selected-overlay migration guide
|
||||
in `../ConfigKit/docs/`; Narratio `internal/config/pipeline_configkit.go`,
|
||||
|
||||
@@ -102,7 +102,7 @@ Safe fix:
|
||||
|
||||
Relevant reference: [Canonical Party Input](./integrations/party.md).
|
||||
|
||||
## Unexpected imported or profile value
|
||||
## Unexpected imported or overlay value
|
||||
|
||||
Symptom:
|
||||
|
||||
@@ -112,21 +112,21 @@ Symptom:
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
narratio config sources --config /path/pipeline.yml --profile testing
|
||||
narratio config sources --config /path/pipeline.yml --overlay 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
|
||||
import, overlay, 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 selected overlay; or
|
||||
- correct the campaign party/family declaration that owns generated values.
|
||||
|
||||
To review what would actually change before switching profiles, run `config
|
||||
To review what would actually change before switching overlays, run `config
|
||||
diff` with the same pipeline and campaign selectors. It compares normalized
|
||||
effective values rather than YAML formatting or source-file layout.
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@ in the [configuration reference](../docs/config.md).
|
||||
- [Production-base/testing-overlay bundle](production-testing/pipeline.yml):
|
||||
explicit `conf.d` imports, one ordinary artifact per `artifacts/<key>.yml`,
|
||||
one family per `artifact-families/<key>.yml`, and the fixed
|
||||
`profiles/testing.yml` overlay. Production values live in the base. The
|
||||
`overlays/testing.yml` overlay. Production values live in the base. The
|
||||
bundle demonstrates an input-only artifact dependency, two character families
|
||||
sharing the campaign party, and a testing-only disabled artifact. Inspect it
|
||||
offline with:
|
||||
|
||||
```sh
|
||||
narratio config validate --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
|
||||
narratio config show --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing
|
||||
narratio config sources --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing
|
||||
narratio config artifacts --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --profile testing
|
||||
narratio config show --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --overlay testing
|
||||
narratio config sources --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --overlay testing
|
||||
narratio config artifacts --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml --overlay testing
|
||||
narratio config diff default testing --config examples/production-testing/pipeline.yml --campaign-file examples/campaigns/sample-campaign/campaign.yml
|
||||
```
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ notarius:
|
||||
# the selected Notarius/PromptKit profile; never put the credential here.
|
||||
llm_api_key: NOTARIUS_LLM_API_KEY
|
||||
# Optionally select a Notarius ConfigKit overlay when extraction runs. This
|
||||
# is independent of Narratio's selected profile and may use a different name.
|
||||
# is independent of Narratio's selected overlay and may use a different name.
|
||||
# See docs/config.md and docs/integrations/notarius.md for the contracts.
|
||||
# config_profile: downstream-economy
|
||||
config_path: /usr/local/etc/notarius/config.yml
|
||||
|
||||
2
go.mod
2
go.mod
@@ -3,7 +3,7 @@ module gitea.maximumdirect.net/eric/narratio
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.3.1
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.4.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.17
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
|
||||
|
||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.3.1 h1:etfTI/dxO3NnS18La0V6IeUh/Iv+3PjVfEyaN8hTuOM=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.3.1/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.4.0 h1:fo+vKff3zTGeWqJIA38NtT84b6OUP//9ZmBH3lFuKgk=
|
||||
gitea.maximumdirect.net/eric/ConfigKit v0.4.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||
|
||||
@@ -256,12 +256,12 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath}, &validated); err != nil {
|
||||
t.Fatalf("validate production base: %v", err)
|
||||
}
|
||||
if !strings.Contains(validated.String(), "profile=none") {
|
||||
t.Fatalf("validate output = %q, want unprofiled production base", validated.String())
|
||||
if !strings.Contains(validated.String(), "overlay=none") {
|
||||
t.Fatalf("validate output = %q, want production base", 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",
|
||||
"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing",
|
||||
}, io.Discard); err != nil {
|
||||
t.Fatalf("testing inspection command %T: %v", command, err)
|
||||
}
|
||||
@@ -278,13 +278,13 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
|
||||
|
||||
common := []string{
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath,
|
||||
"--session", sessionPath, "--profile", "testing",
|
||||
"--session", sessionPath, "--overlay", "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") {
|
||||
if !strings.Contains(planned.String(), "overlay=testing") {
|
||||
t.Fatalf("plan output = %q, want testing provenance", planned.String())
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ func TestAssembledSplitBundleCommandsUseOneEffectiveFamilyConfiguration(t *testi
|
||||
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" {
|
||||
if profile, ok := config.SelectedPipelineOverlay(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) {
|
||||
@@ -391,7 +391,7 @@ func loadAssembledSplitBundleConfig(t *testing.T, profile string) *config.Config
|
||||
pipelinePath, campaignPath, sessionPath := assembledSplitBundlePaths()
|
||||
options := config.SessionLoadOptions{}
|
||||
if profile != "" {
|
||||
options.Profile = &profile
|
||||
options.Overlay = &profile
|
||||
}
|
||||
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, options)
|
||||
if err != nil {
|
||||
@@ -415,8 +415,8 @@ func assertAssembledProvenance(t *testing.T, store *manifest.LocalStore, summary
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := session.EffectiveConfig
|
||||
profileMatches := value != nil && ((profile == "" && value.SelectedProfile == nil) ||
|
||||
(profile != "" && value.SelectedProfile != nil && value.SelectedProfile.Name == profile))
|
||||
profileMatches := value != nil && ((profile == "" && value.SelectedOverlay == nil) ||
|
||||
(profile != "" && value.SelectedOverlay != nil && value.SelectedOverlay.Name == profile))
|
||||
if !profileMatches || value.EffectiveConfigDigest != digest {
|
||||
t.Fatalf("effective configuration provenance = %#v, want profile=%q digest=%q", value, profile, digest)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
|
||||
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
|
||||
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
|
||||
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
|
||||
{name: "profile", args: []string{"session", "--profile", "production", "--profile=testing"}, want: "--profile may be specified only once"},
|
||||
{name: "overlay", args: []string{"session", "--overlay", "production", "--overlay=testing"}, want: "--overlay may be specified only once"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -69,29 +69,29 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundedRunParsingRetainsExplicitProfilePresence(t *testing.T) {
|
||||
withoutProfile, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard)
|
||||
func TestBoundedRunParsingRetainsExplicitOverlayPresence(t *testing.T) {
|
||||
withoutOverlay, 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)
|
||||
if got := withoutOverlay.Config.sessionOptions().Overlay; got != nil {
|
||||
t.Fatalf("omitted overlay = %#v, want nil", got)
|
||||
}
|
||||
|
||||
withProfile, err := parseBoundedRunRequest("run", []string{"session", "--profile", "testing"}, io.Discard)
|
||||
withOverlay, err := parseBoundedRunRequest("run", []string{"session", "--overlay", "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)
|
||||
if got := withOverlay.Config.sessionOptions().Overlay; got == nil || *got != "testing" {
|
||||
t.Fatalf("explicit overlay = %#v, want testing", got)
|
||||
}
|
||||
|
||||
explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--profile", ""}, io.Discard)
|
||||
explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--overlay", ""}, 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)
|
||||
if got := explicitEmpty.Config.sessionOptions().Overlay; got == nil || *got != "" {
|
||||
t.Fatalf("explicit empty overlay = %#v, want non-nil empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ func cleanAllLocalWithDependencies(flags commonConfigFlags, dryRun, clearCache b
|
||||
strings.TrimSpace(flags.previousSessionID) != "" {
|
||||
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
|
||||
}
|
||||
_, pipelineCfg, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}, flags.secretsDir.pointer(), deps)
|
||||
_, pipelineCfg, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Overlay: flags.overlay.pointer()}, flags.secretsDir.pointer(), deps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clean: %w", err)
|
||||
}
|
||||
|
||||
@@ -80,22 +80,22 @@ func TestConfigArtifactsReportsGeneratedFamilyProvenance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigArtifactsReportsProfiledFamilyContributors(t *testing.T) {
|
||||
func TestConfigArtifactsReportsOverlayFamilyContributors(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
profilePath := filepath.Join(filepath.Dir(pipelinePath), "profiles", "testing.yml")
|
||||
mustWriteTestFile(t, profilePath, `scriptorium:
|
||||
overlayPath := filepath.Join(filepath.Dir(pipelinePath), "overlays", "testing.yml")
|
||||
mustWriteTestFile(t, overlayPath, `scriptorium:
|
||||
artifact_families:
|
||||
character_note:
|
||||
prompt_id: dnd.character_note.testing
|
||||
`)
|
||||
|
||||
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"}
|
||||
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}
|
||||
var catalog bytes.Buffer
|
||||
if err := ConfigArtifacts(context.Background(), args, &catalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records := catalogRecordsByKey(t, catalog.String())
|
||||
if got := strings.Split(records["character_note_arannis"][2], ","); !reflect.DeepEqual(got, []string{pipelinePath, profilePath}) {
|
||||
if got := strings.Split(records["character_note_arannis"][2], ","); !reflect.DeepEqual(got, []string{overlayPath, pipelinePath}) {
|
||||
t.Fatalf("generated family definitions = %#v", got)
|
||||
}
|
||||
|
||||
@@ -103,16 +103,16 @@ func TestConfigArtifactsReportsProfiledFamilyContributors(t *testing.T) {
|
||||
if err := ConfigSources(context.Background(), args, &sources); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "scriptorium.artifacts.character_note_arannis.prompt_id\tfamily\t" + profilePath
|
||||
want := "scriptorium.artifacts.character_note_arannis.prompt_id\tfamily\t" + overlayPath
|
||||
if !strings.Contains(sources.String(), want) {
|
||||
t.Fatalf("sources output missing profiled family contributor %q:\n%s", want, sources.String())
|
||||
t.Fatalf("sources output missing overlay family contributor %q:\n%s", want, sources.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigArtifactsReportsBaseAndProfileContributors(t *testing.T) {
|
||||
func TestConfigArtifactsReportsBaseAndOverlayContributors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||
profilePath := filepath.Join(dir, "profiles", "testing.yml")
|
||||
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
|
||||
mustWriteTestFile(t, pipelinePath, `workspace:
|
||||
root: /srv/narratio
|
||||
whisperx:
|
||||
@@ -124,26 +124,26 @@ scriptorium:
|
||||
enabled: false
|
||||
output_path: artifacts/notes.md
|
||||
`)
|
||||
mustWriteTestFile(t, profilePath, `scriptorium:
|
||||
mustWriteTestFile(t, overlayPath, `scriptorium:
|
||||
artifacts:
|
||||
notes:
|
||||
enabled: true
|
||||
prompt_id: notes
|
||||
profile_only:
|
||||
overlay_only:
|
||||
enabled: false
|
||||
output_path: artifacts/profile-only.md
|
||||
output_path: artifacts/overlay-only.md
|
||||
`)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &out); err != nil {
|
||||
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records := catalogRecordsByKey(t, out.String())
|
||||
if got := strings.Split(records["notes"][2], ","); !reflect.DeepEqual(got, []string{pipelinePath, profilePath}) {
|
||||
if got := strings.Split(records["notes"][2], ","); !reflect.DeepEqual(got, []string{overlayPath, pipelinePath}) {
|
||||
t.Fatalf("notes definitions = %#v", got)
|
||||
}
|
||||
if got := records["profile_only"][2]; got != profilePath {
|
||||
t.Fatalf("profile-only definition = %q, want %q", got, profilePath)
|
||||
if got := records["overlay_only"][2]; got != overlayPath {
|
||||
t.Fatalf("overlay-only definition = %q, want %q", got, overlayPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ type inspectionFlags struct {
|
||||
pipelinePath string
|
||||
campaignPath string
|
||||
campaignFilePath string
|
||||
profile singletonStringFlag
|
||||
overlay singletonStringFlag
|
||||
secretsDir singletonStringFlag
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@ func configValidateWithDependencies(_ context.Context, args []string, out io.Wri
|
||||
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))
|
||||
_, err = fmt.Fprintf(out, "Configuration valid: root=%s; overlay=%s; digest=%s\n",
|
||||
inspectionRootPath(resolved), inspectionOverlay(resolved.Pipeline), config.EffectivePipelineDigest(resolved.Pipeline))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -214,12 +214,12 @@ func parseInspectionFlags(command string, args []string, out io.Writer) (inspect
|
||||
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")
|
||||
flags.overlay.name = "overlay"
|
||||
fs.Var(&flags.overlay, "overlay", "named pipeline overlay")
|
||||
flags.secretsDir.name = "secrets-dir"
|
||||
fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(out, "Usage: narratio %s [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--profile <name>] [--secrets-dir <path>]\n\n", command)
|
||||
fmt.Fprintf(out, "Usage: narratio %s [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--overlay <name>] [--secrets-dir <path>]\n\n", command)
|
||||
fmt.Fprintln(out, "Resolves configuration without a session, workspace, manifest, adapters, or external services.")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Flags:")
|
||||
@@ -238,7 +238,7 @@ func parseInspectionFlags(command string, args []string, out io.Writer) (inspect
|
||||
}
|
||||
|
||||
func resolveInspectionConfigWithDependencies(flags inspectionFlags, deps commandDependencies) (*inspectionConfig, error) {
|
||||
pipelinePath, pipeline, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}, flags.secretsDir.pointer(), deps)
|
||||
pipelinePath, pipeline, err := loadPipelineConfigWithDependencies(flags.pipelinePath, config.PipelineLoadOptions{Overlay: flags.overlay.pointer()}, flags.secretsDir.pointer(), deps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -315,9 +315,9 @@ func inspectionRootPath(resolved *inspectionConfig) string {
|
||||
return resolved.PipelinePath
|
||||
}
|
||||
|
||||
func inspectionProfile(pipeline *config.PipelineConfig) string {
|
||||
if profile, ok := config.SelectedPipelineProfile(pipeline); ok {
|
||||
return profile.Name
|
||||
func inspectionOverlay(pipeline *config.PipelineConfig) string {
|
||||
if overlay, ok := config.SelectedPipelineOverlay(pipeline); ok {
|
||||
return overlay.Name
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
@@ -325,7 +325,7 @@ func inspectionProfile(pipeline *config.PipelineConfig) string {
|
||||
func writeConfigCommandUsage(out io.Writer) {
|
||||
fmt.Fprintln(out, "Usage: narratio config <validate|show|sources|artifacts|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, config artifacts to list concrete artifacts, or config diff to compare two profiles.")
|
||||
fmt.Fprintln(out, "Use config validate to check an effective pipeline, config show to print normalized YAML, config sources to report ownership, config artifacts to list concrete artifacts, or config diff to compare two overlays.")
|
||||
}
|
||||
|
||||
func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) error {
|
||||
@@ -343,11 +343,11 @@ func writeInspectionSourceHeader(out io.Writer, resolved *inspectionConfig) erro
|
||||
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 {
|
||||
if overlay, ok := config.SelectedPipelineOverlay(resolved.Pipeline); ok {
|
||||
if _, err := fmt.Fprintf(out, "overlay: name=%s selection=%s path=%s\n", overlay.Name, overlay.Source, config.NormalizedConfigurationPath(overlay.OverlayPath)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := fmt.Fprintln(out, "profile: none"); err != nil {
|
||||
} else if _, err := fmt.Fprintln(out, "overlay: none"); err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved.Campaign == nil {
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestConfigCommandHelpAndDispatch(t *testing.T) {
|
||||
{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},
|
||||
{name: "retired selector", args: []string{"config", "validate", "--profile", "testing"}, want: "flag provided but not defined", code: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
@@ -102,7 +103,7 @@ whisperx:
|
||||
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=") {
|
||||
if !strings.Contains(validateOut.String(), "Configuration valid: root=") || !strings.Contains(validateOut.String(), "; overlay=none; digest=") {
|
||||
t.Fatalf("validate output = %q", validateOut.String())
|
||||
}
|
||||
|
||||
@@ -127,7 +128,7 @@ whisperx:
|
||||
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")
|
||||
writeInspectionOverlays(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)
|
||||
@@ -143,10 +144,10 @@ whisperx:
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffReportsSemanticProfileChanges(t *testing.T) {
|
||||
func TestConfigDiffReportsSemanticOverlayChanges(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
writeInspectionProfiles(t, pipelinePath, `workspace:
|
||||
writeInspectionOverlays(t, pipelinePath, `workspace:
|
||||
cleanup_after_publish: true
|
||||
publish:
|
||||
upload_run: true
|
||||
@@ -203,7 +204,7 @@ scriptorium:
|
||||
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())
|
||||
t.Fatalf("reversed diff did not independently resolve overlays:\n%s", reversed.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +220,7 @@ audita:
|
||||
scriptorium:
|
||||
artifacts: null
|
||||
`)
|
||||
mustWriteTestFile(t, filepath.Join(dir, "profiles", "empty.yml"), `audita:
|
||||
mustWriteTestFile(t, filepath.Join(dir, "overlays", "empty.yml"), `audita:
|
||||
modules: []
|
||||
scriptorium:
|
||||
artifacts: {}
|
||||
@@ -229,7 +230,7 @@ scriptorium:
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath}, &base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "empty"}, &empty); err != nil {
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "empty"}, &empty); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"modules: null\n", "artifacts: null\n"} {
|
||||
@@ -239,7 +240,7 @@ scriptorium:
|
||||
}
|
||||
for _, want := range []string{"modules: []\n", "artifacts: {}\n"} {
|
||||
if !strings.Contains(empty.String(), want) {
|
||||
t.Fatalf("empty-profile show output missing %q:\n%s", want, empty.String())
|
||||
t.Fatalf("empty-overlay show output missing %q:\n%s", want, empty.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +260,7 @@ scriptorium:
|
||||
|
||||
func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
writeInspectionProfiles(t, pipelinePath, `scriptorium:
|
||||
writeInspectionOverlays(t, pipelinePath, `scriptorium:
|
||||
artifact_families:
|
||||
character_note:
|
||||
enabled: false
|
||||
@@ -301,11 +302,11 @@ func TestConfigDiffComparesExpandedFamiliesAndPublishRules(t *testing.T) {
|
||||
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")
|
||||
writeInspectionOverlays(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)
|
||||
t.Fatalf("ConfigDiff() equal overlays error = %v", err)
|
||||
}
|
||||
if got := equal.String(); got != "no differences\n" {
|
||||
t.Fatalf("equal diff output = %q", got)
|
||||
@@ -316,7 +317,7 @@ func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
|
||||
{"production", "--config", pipelinePath},
|
||||
{"production", "production", "--config", pipelinePath},
|
||||
{"unknown", "testing", "--config", pipelinePath},
|
||||
{"production", "testing", "--config", pipelinePath, "--profile", "production"},
|
||||
{"production", "testing", "--config", pipelinePath, "--overlay", "production"},
|
||||
{"production", "testing", "--config", pipelinePath, "--config", pipelinePath},
|
||||
{"production", "testing", "--config", pipelinePath, "--campaign", "sample-campaign", "--campaign-file", campaignPath},
|
||||
} {
|
||||
@@ -326,10 +327,10 @@ func TestConfigDiffReportsEqualityAndRejectsInvalidInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiffBootstrapsSecretsOnceForBothProfiles(t *testing.T) {
|
||||
func TestConfigDiffBootstrapsSecretsOnceForBothOverlays(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, _, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
writeInspectionProfiles(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
|
||||
writeInspectionOverlays(t, pipelinePath, "whisperx:\n language: en\n", "whisperx:\n language: fr\n")
|
||||
secretsDir := t.TempDir()
|
||||
const secretName = "NARRATIO_DIFF_BOOTSTRAP_REPORT"
|
||||
restoreEnvAfterTest(t, secretName)
|
||||
@@ -393,27 +394,27 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
|
||||
if err := os.WriteFile(pipelinePath, []byte(pipelineYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeInspectionProfiles(t, pipelinePath,
|
||||
writeInspectionOverlays(t, pipelinePath,
|
||||
"audita:\n model: production\n llm_api_key: NARRATIO_INSPECT_LLM_ONE\n",
|
||||
"audita:\n model: testing\n llm_api_key: NARRATIO_INSPECT_LLM_TWO\nnotarius:\n llm_api_key: NARRATIO_INSPECT_NOTARIUS_TWO\n",
|
||||
)
|
||||
mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "profiles", "without-notarius-key.yml"), "notarius:\n llm_api_key: null\n")
|
||||
mustWriteTestFile(t, filepath.Join(filepath.Dir(pipelinePath), "overlays", "without-notarius-key.yml"), "notarius:\n llm_api_key: null\n")
|
||||
|
||||
var validate bytes.Buffer
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &validate); err != nil {
|
||||
if err := ConfigValidate(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &validate); err != nil {
|
||||
t.Fatalf("config validate: %v", err)
|
||||
}
|
||||
|
||||
var show bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &show); err != nil {
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &show); err != nil {
|
||||
t.Fatalf("config show: %v", err)
|
||||
}
|
||||
var sources bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &sources); err != nil {
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &sources); err != nil {
|
||||
t.Fatalf("config sources: %v", err)
|
||||
}
|
||||
var catalog bytes.Buffer
|
||||
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--profile", "production"}, &catalog); err != nil {
|
||||
if err := ConfigArtifacts(context.Background(), []string{"--config", pipelinePath, "--overlay", "production"}, &catalog); err != nil {
|
||||
t.Fatalf("config artifacts: %v", err)
|
||||
}
|
||||
var diff bytes.Buffer
|
||||
@@ -421,7 +422,7 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
|
||||
t.Fatalf("config diff: %v", err)
|
||||
}
|
||||
var removed bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "without-notarius-key"}, &removed); err != nil {
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "without-notarius-key"}, &removed); err != nil {
|
||||
t.Fatalf("config show with removed Notarius credential: %v", err)
|
||||
}
|
||||
for label, output := range map[string]string{"show": show.String(), "sources": sources.String(), "artifacts": catalog.String(), "diff": diff.String()} {
|
||||
@@ -442,7 +443,7 @@ func TestConfigInspectionOutputsNeverExposeResolvedCredentialValues(t *testing.T
|
||||
t.Fatalf("sources omitted Notarius credential ownership: %q", sources.String())
|
||||
}
|
||||
if strings.Contains(removed.String(), "llm_api_key") || strings.Contains(removed.String(), "NARRATIO_INSPECT_NOTARIUS_ONE") {
|
||||
t.Fatalf("profile null did not remove Notarius credential: %q", removed.String())
|
||||
t.Fatalf("overlay null did not remove Notarius credential: %q", removed.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,35 +477,35 @@ func TestConfigCommandsInspectMaintainedSplitBundleWithoutRuntimeState(t *testin
|
||||
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())
|
||||
t.Fatalf("split bundle overlay 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) {
|
||||
func writeInspectionOverlays(t *testing.T, pipelinePath, production, testing string) {
|
||||
t.Helper()
|
||||
profilesDir := filepath.Join(filepath.Dir(pipelinePath), "profiles")
|
||||
mustWriteTestFile(t, filepath.Join(profilesDir, "production.yml"), production)
|
||||
mustWriteTestFile(t, filepath.Join(profilesDir, "testing.yml"), testing)
|
||||
overlaysDir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
|
||||
mustWriteTestFile(t, filepath.Join(overlaysDir, "production.yml"), production)
|
||||
mustWriteTestFile(t, filepath.Join(overlaysDir, "testing.yml"), testing)
|
||||
}
|
||||
|
||||
func TestConfigCommandsSelectProfilesAndCampaigns(t *testing.T) {
|
||||
func TestConfigCommandsSelectOverlaysAndCampaigns(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
enableCommandTestProfiles(t, pipelinePath)
|
||||
enableCommandTestOverlays(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=none") {
|
||||
if !strings.Contains(defaultOut.String(), "overlay=none") {
|
||||
t.Fatalf("default output = %q", defaultOut.String())
|
||||
}
|
||||
|
||||
var explicitOut bytes.Buffer
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &explicitOut); err != nil {
|
||||
if err := ConfigShow(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &explicitOut); err != nil {
|
||||
t.Fatalf("explicit ConfigShow() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(explicitOut.String(), "language: fr") {
|
||||
@@ -589,9 +590,9 @@ whisperx:
|
||||
func TestConfigSourcesReportsStableOwnership(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
|
||||
enableCommandTestProfiles(t, pipelinePath)
|
||||
enableCommandTestOverlays(t, pipelinePath)
|
||||
|
||||
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"}
|
||||
args := []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}
|
||||
var first, second bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), args, &first); err != nil {
|
||||
t.Fatalf("first ConfigSources() error = %v", err)
|
||||
@@ -606,13 +607,13 @@ func TestConfigSourcesReportsStableOwnership(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"root: ",
|
||||
"imports: none",
|
||||
"profile: name=testing selection=explicit overlay=",
|
||||
"overlay: name=testing selection=explicit path=",
|
||||
"campaign: id=sample-campaign source=",
|
||||
"party: source=",
|
||||
"digest: ",
|
||||
"path\trole\tsource\n",
|
||||
"workspace.root\troot\t",
|
||||
"whisperx.language\tprofile\t",
|
||||
"whisperx.language\toverlay\t",
|
||||
"derived.players\tparty\t",
|
||||
"trim.enabled\tdefault\tdefault",
|
||||
} {
|
||||
@@ -647,10 +648,10 @@ func TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing.T) {
|
||||
func TestConfigSourcesPreservesOverlayOwnershipThroughFamilyExpansion(t *testing.T) {
|
||||
pipelinePath, campaignPath := writeInspectionFamilyConfig(t)
|
||||
profilePath := filepath.Join(filepath.Dir(pipelinePath), "profiles", "testing.yml")
|
||||
mustWriteTestFile(t, profilePath, `scriptorium:
|
||||
overlayPath := filepath.Join(filepath.Dir(pipelinePath), "overlays", "testing.yml")
|
||||
mustWriteTestFile(t, overlayPath, `scriptorium:
|
||||
artifact_families:
|
||||
character_note:
|
||||
enabled: true
|
||||
@@ -658,12 +659,12 @@ func TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion(t *testing
|
||||
`)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--profile", "testing"}, &out); err != nil {
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--campaign-file", campaignPath, "--overlay", "testing"}, &out); err != nil {
|
||||
t.Fatalf("ConfigSources() error = %v", err)
|
||||
}
|
||||
want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + profilePath
|
||||
want := "scriptorium.artifacts.character_note_arannis.enabled\tfamily\t" + overlayPath
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("sources output missing profile-generated ownership %q:\n%s", want, out.String())
|
||||
t.Fatalf("sources output missing overlay-generated ownership %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,7 +672,7 @@ func TestConfigSourcesAttributesInferredArtifactDependencies(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pipelinePath := filepath.Join(dir, "pipeline.yml")
|
||||
inputPath := filepath.Join(dir, "input-only.yml")
|
||||
profilePath := filepath.Join(dir, "profiles", "testing.yml")
|
||||
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
|
||||
mustWriteTestFile(t, pipelinePath, `configkit:
|
||||
imports:
|
||||
- path: input-only.yml
|
||||
@@ -701,7 +702,7 @@ scriptorium:
|
||||
prior:
|
||||
source: narratio.artifact.first
|
||||
`)
|
||||
mustWriteTestFile(t, profilePath, `scriptorium:
|
||||
mustWriteTestFile(t, overlayPath, `scriptorium:
|
||||
artifacts:
|
||||
mixed:
|
||||
inputs:
|
||||
@@ -710,13 +711,13 @@ scriptorium:
|
||||
`)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--profile", "testing"}, &out); err != nil {
|
||||
if err := ConfigSources(context.Background(), []string{"--config", pipelinePath, "--overlay", "testing"}, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"scriptorium.artifacts.input_only.depends_on\timport\t" + inputPath,
|
||||
"scriptorium.artifacts.mixed.depends_on\troot\t" + pipelinePath,
|
||||
"scriptorium.artifacts.mixed.depends_on\tprofile\t" + profilePath,
|
||||
"scriptorium.artifacts.mixed.depends_on\toverlay\t" + overlayPath,
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("sources output missing dependency contributor %q:\n%s", want, out.String())
|
||||
@@ -727,11 +728,11 @@ scriptorium:
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSourcesReportsRootImportProfileAndDefaultOwnership(t *testing.T) {
|
||||
func TestConfigSourcesReportsRootImportOverlayAndDefaultOwnership(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
rootPath := filepath.Join(dir, "pipeline.yml")
|
||||
importPath := filepath.Join(dir, "base.yml")
|
||||
profilePath := filepath.Join(dir, "profiles", "testing.yml")
|
||||
overlayPath := filepath.Join(dir, "overlays", "testing.yml")
|
||||
mustWriteTestFile(t, rootPath, `configkit:
|
||||
imports:
|
||||
- path: base.yml
|
||||
@@ -741,19 +742,19 @@ whisperx:
|
||||
transcribe_url: https://transcription.example.com/transcribe
|
||||
`)
|
||||
mustWriteTestFile(t, importPath, "storage:\n backend: local\n")
|
||||
mustWriteTestFile(t, profilePath, "whisperx:\n language: fr\n")
|
||||
mustWriteTestFile(t, overlayPath, "whisperx:\n language: fr\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := ConfigSources(context.Background(), []string{"--config", rootPath, "--profile", "testing"}, &out); err != nil {
|
||||
if err := ConfigSources(context.Background(), []string{"--config", rootPath, "--overlay", "testing"}, &out); err != nil {
|
||||
t.Fatalf("ConfigSources() error = %v", err)
|
||||
}
|
||||
output := out.String()
|
||||
for _, want := range []string{
|
||||
"import: " + importPath,
|
||||
"profile: name=testing selection=explicit overlay=" + profilePath,
|
||||
"overlay: name=testing selection=explicit path=" + overlayPath,
|
||||
"workspace.root\troot\t" + rootPath,
|
||||
"storage.backend\timport\t" + importPath,
|
||||
"whisperx.language\tprofile\t" + profilePath,
|
||||
"whisperx.language\toverlay\t" + overlayPath,
|
||||
"trim.enabled\tdefault\tdefault",
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
|
||||
@@ -20,8 +20,8 @@ type configDiffFlags struct {
|
||||
}
|
||||
|
||||
type configDiffRequest struct {
|
||||
leftProfile string
|
||||
rightProfile string
|
||||
leftOverlay string
|
||||
rightOverlay string
|
||||
flags configDiffFlags
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ type configDiffRecord struct {
|
||||
Right string
|
||||
}
|
||||
|
||||
// ConfigDiff compares two explicitly selected profiles through the same
|
||||
// ConfigDiff compares two explicitly selected overlays 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.
|
||||
@@ -100,7 +100,7 @@ func configDiffWithDependencies(_ context.Context, args []string, out io.Writer,
|
||||
}
|
||||
|
||||
func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, error) {
|
||||
profiles, flagArgs := splitConfigDiffArguments(args)
|
||||
overlays, flagArgs := splitConfigDiffArguments(args)
|
||||
fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
|
||||
fs.SetOutput(out)
|
||||
var request configDiffRequest
|
||||
@@ -113,9 +113,9 @@ func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, er
|
||||
fs.Var(&request.flags.campaignFilePath, "campaign-file", "path to campaign.yml")
|
||||
fs.Var(&request.flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintln(out, "Usage: narratio config diff <left-profile> <right-profile> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <path>]")
|
||||
fmt.Fprintln(out, "Usage: narratio config diff <left-overlay> <right-overlay> [--config <pipeline.yml>] [--campaign <id> | --campaign-file <campaign.yml>] [--secrets-dir <path>]")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Compares fully resolved profiles without a session, workspace, manifest, adapters, or external services.")
|
||||
fmt.Fprintln(out, "Compares fully resolved overlays without a session, workspace, manifest, adapters, or external services.")
|
||||
fmt.Fprintln(out)
|
||||
fmt.Fprintln(out, "Flags:")
|
||||
fs.PrintDefaults()
|
||||
@@ -123,30 +123,30 @@ func parseConfigDiffRequest(args []string, out io.Writer) (configDiffRequest, er
|
||||
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")
|
||||
overlays = append(overlays, fs.Args()...)
|
||||
if len(overlays) != 2 || strings.TrimSpace(overlays[0]) == "" || strings.TrimSpace(overlays[1]) == "" {
|
||||
return configDiffRequest{}, fmt.Errorf("config diff: exactly two non-empty overlay names are required")
|
||||
}
|
||||
if profiles[0] == profiles[1] {
|
||||
return configDiffRequest{}, fmt.Errorf("config diff: profile names must differ")
|
||||
if overlays[0] == overlays[1] {
|
||||
return configDiffRequest{}, fmt.Errorf("config diff: overlay names must differ")
|
||||
}
|
||||
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]
|
||||
request.leftOverlay = overlays[0]
|
||||
request.rightOverlay = overlays[1]
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// splitConfigDiffArguments accepts the documented positional-first syntax and
|
||||
// also permits flags before or between profile names. Values belonging to the
|
||||
// also permits flags before or between overlay 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) {
|
||||
// overlay names.
|
||||
func splitConfigDiffArguments(args []string) (overlays, flagArgs []string) {
|
||||
for index := 0; index < len(args); index++ {
|
||||
argument := args[index]
|
||||
if !strings.HasPrefix(argument, "-") || argument == "-" {
|
||||
profiles = append(profiles, argument)
|
||||
overlays = append(overlays, argument)
|
||||
continue
|
||||
}
|
||||
flagArgs = append(flagArgs, argument)
|
||||
@@ -161,12 +161,12 @@ func splitConfigDiffArguments(args []string) (profiles, flagArgs []string) {
|
||||
index++
|
||||
flagArgs = append(flagArgs, args[index])
|
||||
}
|
||||
return profiles, flagArgs
|
||||
return overlays, flagArgs
|
||||
}
|
||||
|
||||
func configDiffStringFlag(name string) bool {
|
||||
switch name {
|
||||
case "config", "campaign", "campaign-file", "profile", "secrets-dir":
|
||||
case "config", "campaign", "campaign-file", "secrets-dir":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -175,13 +175,13 @@ func configDiffStringFlag(name string) bool {
|
||||
|
||||
func resolveConfigDiff(request configDiffRequest, deps commandDependencies) (*configDiffResolved, error) {
|
||||
deps = normalizeCommandDependencies(deps)
|
||||
pipelinePath, leftPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Profile: &request.leftProfile}, request.flags.secretsDir.pointer(), deps)
|
||||
pipelinePath, leftPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Overlay: &request.leftOverlay}, request.flags.secretsDir.pointer(), deps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load left pipeline profile: %w", err)
|
||||
return nil, fmt.Errorf("load left pipeline overlay: %w", err)
|
||||
}
|
||||
_, rightPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Profile: &request.rightProfile}, request.flags.secretsDir.pointer(), deps)
|
||||
_, rightPipeline, err := loadPipelineConfigWithDependencies(request.flags.pipelinePath.value, config.PipelineLoadOptions{Overlay: &request.rightOverlay}, request.flags.secretsDir.pointer(), deps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load right pipeline profile: %w", err)
|
||||
return nil, fmt.Errorf("load right pipeline overlay: %w", err)
|
||||
}
|
||||
needsCampaign := pipelineHasArtifactFamilies(leftPipeline) || pipelineHasArtifactFamilies(rightPipeline)
|
||||
hasCampaignSelection := request.flags.campaignPath.set || request.flags.campaignFilePath.set
|
||||
@@ -259,7 +259,7 @@ func resolveSharedDiffCampaignPath(left, right *config.PipelineConfig, flags con
|
||||
return "", rightErr
|
||||
}
|
||||
if leftPath != rightPath {
|
||||
return "", fmt.Errorf("profile selections resolve different campaign files; specify --campaign-file to compare one campaign")
|
||||
return "", fmt.Errorf("overlay selections resolve different campaign files; specify --campaign-file to compare one campaign")
|
||||
}
|
||||
return leftPath, nil
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func loadCommandConfigWithDependencies(ctx context.Context, pipelineFlag, campai
|
||||
}
|
||||
}()
|
||||
|
||||
base, err := loadPipelineCampaignConfigWithDependencies(pipelineFlag, campaignFlag, campaignFileFlag, config.PipelineLoadOptions{Profile: sessionOpts.Profile}, secretsDir, deps)
|
||||
base, err := loadPipelineCampaignConfigWithDependencies(pipelineFlag, campaignFlag, campaignFileFlag, config.PipelineLoadOptions{Overlay: sessionOpts.Overlay}, secretsDir, deps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ func effectiveConfigProvenance(cfg *config.Config) *manifest.EffectiveConfigProv
|
||||
provenance := &manifest.EffectiveConfigProvenance{
|
||||
EffectiveConfigDigest: config.EffectivePipelineDigest(cfg.Pipeline),
|
||||
}
|
||||
if selected, ok := config.SelectedPipelineProfile(cfg.Pipeline); ok {
|
||||
provenance.SelectedProfile = &manifest.SelectedProfileProvenance{
|
||||
if selected, ok := config.SelectedPipelineOverlay(cfg.Pipeline); ok {
|
||||
provenance.SelectedOverlay = &manifest.SelectedOverlayProvenance{
|
||||
Name: selected.Name,
|
||||
Source: selected.Source,
|
||||
}
|
||||
}
|
||||
if provenance.SelectedProfile == nil && provenance.EffectiveConfigDigest == "" {
|
||||
if provenance.SelectedOverlay == nil && provenance.EffectiveConfigDigest == "" {
|
||||
return nil
|
||||
}
|
||||
return provenance
|
||||
@@ -35,30 +35,30 @@ func applyEffectiveConfigProvenance(m *manifest.Manifest, cfg *config.Config) {
|
||||
func effectiveConfigSummary(cfg *config.Config) string {
|
||||
provenance := effectiveConfigProvenance(cfg)
|
||||
if provenance == nil {
|
||||
return "profile=none digest=unavailable"
|
||||
return "overlay=none digest=unavailable"
|
||||
}
|
||||
profile := "none"
|
||||
if provenance.SelectedProfile != nil {
|
||||
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
|
||||
overlay := "none"
|
||||
if provenance.SelectedOverlay != nil {
|
||||
overlay = provenance.SelectedOverlay.Name + " (" + provenance.SelectedOverlay.Source + ")"
|
||||
}
|
||||
digest := provenance.EffectiveConfigDigest
|
||||
if digest == "" {
|
||||
digest = "unavailable"
|
||||
}
|
||||
return fmt.Sprintf("profile=%s digest=%s", profile, digest)
|
||||
return fmt.Sprintf("overlay=%s digest=%s", overlay, digest)
|
||||
}
|
||||
|
||||
func persistedEffectiveConfigSummary(provenance *manifest.EffectiveConfigProvenance) string {
|
||||
if provenance == nil {
|
||||
return "profile=none digest=unavailable"
|
||||
return "overlay=none digest=unavailable"
|
||||
}
|
||||
profile := "none"
|
||||
if provenance.SelectedProfile != nil {
|
||||
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
|
||||
overlay := "none"
|
||||
if provenance.SelectedOverlay != nil {
|
||||
overlay = provenance.SelectedOverlay.Name + " (" + provenance.SelectedOverlay.Source + ")"
|
||||
}
|
||||
digest := provenance.EffectiveConfigDigest
|
||||
if digest == "" {
|
||||
digest = "unavailable"
|
||||
}
|
||||
return fmt.Sprintf("profile=%s digest=%s", profile, digest)
|
||||
return fmt.Sprintf("overlay=%s digest=%s", overlay, digest)
|
||||
}
|
||||
|
||||
@@ -817,7 +817,7 @@ func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T
|
||||
if err := os.WriteFile(filepath.Join(filepath.Dir(pipelinePath), "notarius.yml"), []byte("pipelines: {}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profilesDir := filepath.Join(filepath.Dir(pipelinePath), "profiles")
|
||||
profilesDir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
|
||||
if err := os.MkdirAll(profilesDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -832,7 +832,7 @@ func TestExtractLifecycleRealConfigProfilesSelectNotariusExplicitly(t *testing.T
|
||||
}
|
||||
load := func(profile string) *config.Config {
|
||||
t.Helper()
|
||||
loaded, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{Profile: &profile})
|
||||
loaded, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{Overlay: &profile})
|
||||
if err != nil {
|
||||
t.Fatalf("load profile %q: %v", profile, err)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type commonConfigFlags struct {
|
||||
sessionPath string
|
||||
sessionID string
|
||||
previousSessionID string
|
||||
profile singletonStringFlag
|
||||
overlay singletonStringFlag
|
||||
secretsDir singletonStringFlag
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
||||
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
|
||||
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||
flags.profile.name = "profile"
|
||||
fs.Var(&flags.profile, "profile", "named pipeline profile")
|
||||
flags.overlay.name = "overlay"
|
||||
fs.Var(&flags.overlay, "overlay", "named pipeline overlay")
|
||||
flags.secretsDir.name = "secrets-dir"
|
||||
fs.Var(&flags.secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
||||
return config.SessionLoadOptions{
|
||||
SessionID: f.sessionID,
|
||||
PreviousSessionID: f.previousSessionID,
|
||||
Profile: f.profile.pointer(),
|
||||
Overlay: f.overlay.pointer(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ func sessionInitWithDependencies(ctx context.Context, args []string, out io.Writ
|
||||
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
|
||||
fs.SetOutput(io.Discard)
|
||||
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
||||
var profile singletonStringFlag
|
||||
var overlay singletonStringFlag
|
||||
var secretsDir singletonStringFlag
|
||||
var remote, force bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
@@ -40,8 +40,8 @@ func sessionInitWithDependencies(ctx context.Context, args []string, out io.Writ
|
||||
fs.StringVar(&output, "output", "", "local output session.yml path")
|
||||
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
|
||||
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
|
||||
profile.name = "profile"
|
||||
fs.Var(&profile, "profile", "named pipeline profile")
|
||||
overlay.name = "overlay"
|
||||
fs.Var(&overlay, "overlay", "named pipeline overlay")
|
||||
secretsDir.name = "secrets-dir"
|
||||
fs.Var(&secretsDir, "secrets-dir", "secrets directory (optional; defaults beside pipeline.yml)")
|
||||
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
|
||||
@@ -59,7 +59,7 @@ func sessionInitWithDependencies(ctx context.Context, args []string, out io.Writ
|
||||
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
|
||||
}
|
||||
|
||||
base, err := loadPipelineCampaignConfigWithDependencies(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Profile: profile.pointer()}, secretsDir.pointer(), deps)
|
||||
base, err := loadPipelineCampaignConfigWithDependencies(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Overlay: overlay.pointer()}, secretsDir.pointer(), deps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("session init: %w", err)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
|
||||
func TestRunOverlaySelectionFlowsThroughSharedLoader(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
enableCommandTestProfiles(t, pipelinePath)
|
||||
enableCommandTestOverlays(t, pipelinePath)
|
||||
|
||||
var language string
|
||||
executor := func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
|
||||
@@ -23,12 +23,17 @@ func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
|
||||
}
|
||||
|
||||
if err := runWithPipelineExecutor(context.Background(), []string{
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "testing",
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--overlay", "testing",
|
||||
}, io.Discard, executor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if language != "fr" {
|
||||
t.Fatalf("explicit profile language = %q, want fr", language)
|
||||
t.Fatalf("explicit overlay language = %q, want fr", language)
|
||||
}
|
||||
if err := runWithPipelineExecutor(context.Background(), []string{
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "testing",
|
||||
}, io.Discard, executor); err == nil {
|
||||
t.Fatal("retired pipeline selector was accepted")
|
||||
}
|
||||
|
||||
if err := runWithPipelineExecutor(context.Background(), []string{
|
||||
@@ -40,25 +45,25 @@ func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
|
||||
t.Fatalf("base configuration language = %q, want en", language)
|
||||
}
|
||||
|
||||
for _, profile := range []string{"unknown"} {
|
||||
for _, overlay := range []string{"unknown"} {
|
||||
err := runWithPipelineExecutor(context.Background(), []string{
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", profile,
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--overlay", overlay,
|
||||
}, io.Discard, executor)
|
||||
if err == nil || !strings.Contains(err.Error(), "profile") {
|
||||
t.Fatalf("profile %q error = %v, want selection rejection", profile, err)
|
||||
if err == nil || !strings.Contains(err.Error(), "overlay") {
|
||||
t.Fatalf("overlay %q error = %v, want selection rejection", overlay, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := runWithPipelineExecutor(context.Background(), []string{
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "",
|
||||
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--overlay", "",
|
||||
}, io.Discard, executor); err != nil {
|
||||
t.Fatalf("explicit empty profile should select base: %v", err)
|
||||
t.Fatalf("explicit empty overlay should select base: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func enableCommandTestProfiles(t *testing.T, pipelinePath string) {
|
||||
func enableCommandTestOverlays(t *testing.T, pipelinePath string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(filepath.Dir(pipelinePath), "profiles")
|
||||
dir := filepath.Join(filepath.Dir(pipelinePath), "overlays")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func TestRegenerateArtifactsForwardsCanonicalPipelineRequest(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
|
||||
enableCommandTestProfiles(t, pipelinePath)
|
||||
enableCommandTestOverlays(t, pipelinePath)
|
||||
var capturedPlan BoundedPlan
|
||||
var capturedOptions RunOptions
|
||||
executor := func(_ context.Context, cfg *config.Config, plan BoundedPlan, options RunOptions) (*RunSummary, error) {
|
||||
@@ -30,7 +30,7 @@ func TestRegenerateArtifactsForwardsCanonicalPipelineRequest(t *testing.T) {
|
||||
"--config", pipelinePath,
|
||||
"--campaign-file", campaignPath,
|
||||
"--session", sessionPath,
|
||||
"--profile", "testing",
|
||||
"--overlay", "testing",
|
||||
}, io.Discard, io.Discard, executor)
|
||||
if code != 0 {
|
||||
t.Fatalf("executeWithPipelineExecutor() code = %d, want 0", code)
|
||||
|
||||
@@ -80,7 +80,7 @@ func runStageWithDependencies(ctx context.Context, args []string, out io.Writer,
|
||||
SessionPath: flags.sessionPath,
|
||||
SessionID: flags.sessionID,
|
||||
PreviousSessionID: flags.previousSessionID,
|
||||
Profile: flags.profile.pointer(),
|
||||
Overlay: flags.overlay.pointer(),
|
||||
SecretsDir: flags.secretsDir.pointer(),
|
||||
Force: force,
|
||||
SelectedArtifacts: normalizedArtifacts,
|
||||
@@ -139,7 +139,7 @@ func analyzeWithDependencies(ctx context.Context, args []string, out io.Writer,
|
||||
SessionPath: flags.sessionPath,
|
||||
SessionID: flags.sessionID,
|
||||
PreviousSessionID: flags.previousSessionID,
|
||||
Profile: flags.profile.pointer(),
|
||||
Overlay: flags.overlay.pointer(),
|
||||
SecretsDir: flags.secretsDir.pointer(),
|
||||
Force: true,
|
||||
SelectedArtifacts: normalizedArtifacts,
|
||||
@@ -196,7 +196,7 @@ func publishWithDependencies(ctx context.Context, args []string, out io.Writer,
|
||||
SessionPath: flags.sessionPath,
|
||||
SessionID: flags.sessionID,
|
||||
PreviousSessionID: flags.previousSessionID,
|
||||
Profile: flags.profile.pointer(),
|
||||
Overlay: flags.overlay.pointer(),
|
||||
SecretsDir: flags.secretsDir.pointer(),
|
||||
Force: true,
|
||||
SelectedArtifacts: normalizedArtifacts,
|
||||
@@ -224,7 +224,7 @@ type singleStageCommand struct {
|
||||
SessionPath string
|
||||
SessionID string
|
||||
PreviousSessionID string
|
||||
Profile *string
|
||||
Overlay *string
|
||||
SecretsDir *string
|
||||
Force bool
|
||||
SelectedArtifacts []string
|
||||
@@ -240,7 +240,7 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand, executor
|
||||
loaded, err := loadCommandConfigWithDependencies(ctx, req.PipelinePath, req.CampaignPath, req.CampaignFilePath, req.SessionPath, config.SessionLoadOptions{
|
||||
SessionID: req.SessionID,
|
||||
PreviousSessionID: req.PreviousSessionID,
|
||||
Profile: req.Profile,
|
||||
Overlay: req.Overlay,
|
||||
}, req.SecretsDir, deps)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", req.CommandName, err)
|
||||
|
||||
@@ -93,7 +93,7 @@ func declarationDefinitionSources(cfg *PipelineConfig, declaration configkit.Pat
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
for _, change := range cfg.resolution.configkitMetadata.History(declaration) {
|
||||
if change.Origin.Layer != configkit.LayerBase && change.Origin.Layer != configkit.LayerProfile {
|
||||
if change.Origin.Layer != configkit.LayerBase && change.Origin.Layer != configkit.LayerOverlay {
|
||||
continue
|
||||
}
|
||||
for _, location := range change.Origin.Locations {
|
||||
|
||||
@@ -14,21 +14,21 @@ const (
|
||||
effectivePipelineDigestVersion = "narratio-effective-pipeline-v3\n"
|
||||
)
|
||||
|
||||
// PipelineProfileProvenance identifies the profile selected while resolving a
|
||||
// PipelineOverlayProvenance identifies the overlay selected while resolving a
|
||||
// pipeline. It contains only non-secret selection metadata.
|
||||
type PipelineProfileProvenance struct {
|
||||
type PipelineOverlayProvenance 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 {
|
||||
// SelectedPipelineOverlay reports the overlay used to resolve cfg, if any.
|
||||
func SelectedPipelineOverlay(cfg *PipelineConfig) (*PipelineOverlayProvenance, bool) {
|
||||
if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedOverlay == nil {
|
||||
return nil, false
|
||||
}
|
||||
selection := cfg.resolution.selectedProfile
|
||||
return &PipelineProfileProvenance{Name: selection.name, Source: selection.source, OverlayPath: selection.overlayPath}, true
|
||||
selection := cfg.resolution.selectedOverlay
|
||||
return &PipelineOverlayProvenance{Name: selection.name, Source: selection.source, OverlayPath: selection.overlayPath}, true
|
||||
}
|
||||
|
||||
// EffectivePipelineDigest reports the deterministic secret-free digest for a
|
||||
|
||||
@@ -401,8 +401,8 @@ func pipelineSourceRole(cfg *PipelineConfig, source string) string {
|
||||
if source == cfg.resolution.rootPath {
|
||||
return "root"
|
||||
}
|
||||
if cfg.resolution.selectedProfile != nil && source == cfg.resolution.selectedProfile.overlayPath {
|
||||
return "profile"
|
||||
if cfg.resolution.selectedOverlay != nil && source == cfg.resolution.selectedOverlay.overlayPath {
|
||||
return "overlay"
|
||||
}
|
||||
for _, imported := range cfg.resolution.imports {
|
||||
if source == imported {
|
||||
|
||||
@@ -15,14 +15,14 @@ func LoadPipeline(path string) (*PipelineConfig, error) {
|
||||
return LoadPipelineWithOptions(path, PipelineLoadOptions{})
|
||||
}
|
||||
|
||||
// PipelineLoadOptions carries an optional explicit profile selection. Nil,
|
||||
// PipelineLoadOptions carries an optional explicit overlay selection. Nil,
|
||||
// empty, and the exact name "default" select the base configuration.
|
||||
type PipelineLoadOptions struct {
|
||||
Profile *string
|
||||
Overlay *string
|
||||
}
|
||||
|
||||
// LoadPipelineWithOptions loads pipeline configuration with strict field
|
||||
// checking and optional named-profile selection.
|
||||
// checking and optional named-overlay selection.
|
||||
func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineConfig, error) {
|
||||
cfg, err := loadPipelineWithConfigKit(path, opts)
|
||||
if err != nil {
|
||||
@@ -31,20 +31,20 @@ func LoadPipelineWithOptions(path string, opts PipelineLoadOptions) (*PipelineCo
|
||||
return finalizeLoadedPipeline(path, cfg)
|
||||
}
|
||||
|
||||
// LoadPipelineProfilePair resolves two profiles as independent ConfigKit loads.
|
||||
func LoadPipelineProfilePair(path, leftProfile, rightProfile string) (*PipelineConfig, *PipelineConfig, error) {
|
||||
if leftProfile == rightProfile {
|
||||
return nil, nil, fmt.Errorf("load pipeline profiles: left and right profile selections must differ")
|
||||
// LoadPipelineOverlayPair resolves two overlays as independent ConfigKit loads.
|
||||
func LoadPipelineOverlayPair(path, leftOverlay, rightOverlay string) (*PipelineConfig, *PipelineConfig, error) {
|
||||
if leftOverlay == rightOverlay {
|
||||
return nil, nil, fmt.Errorf("load pipeline overlays: left and right overlay selections must differ")
|
||||
}
|
||||
leftOptions := PipelineLoadOptions{Profile: &leftProfile}
|
||||
rightOptions := PipelineLoadOptions{Profile: &rightProfile}
|
||||
leftOptions := PipelineLoadOptions{Overlay: &leftOverlay}
|
||||
rightOptions := PipelineLoadOptions{Overlay: &rightOverlay}
|
||||
left, err := LoadPipelineWithOptions(path, leftOptions)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load left pipeline profile: %w", err)
|
||||
return nil, nil, fmt.Errorf("load left pipeline overlay: %w", err)
|
||||
}
|
||||
right, err := LoadPipelineWithOptions(path, rightOptions)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load right pipeline profile: %w", err)
|
||||
return nil, nil, fmt.Errorf("load right pipeline overlay: %w", err)
|
||||
}
|
||||
return left, right, nil
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func LoadSession(path string) (*SessionConfig, error) {
|
||||
type SessionLoadOptions struct {
|
||||
SessionID string
|
||||
PreviousSessionID string
|
||||
Profile *string
|
||||
Overlay *string
|
||||
}
|
||||
|
||||
// LoadSessionWithOptions loads session configuration from a YAML file with
|
||||
@@ -143,7 +143,7 @@ func Load(pipelinePath string, paths ...string) (*Config, error) {
|
||||
// LoadWithSessionOptions loads and resolves combined pipeline, campaign, and
|
||||
// session configuration with expected session identity checks.
|
||||
func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
|
||||
pipelineCfg, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Profile: sessionOpts.Profile})
|
||||
pipelineCfg, err := LoadPipelineWithOptions(pipelinePath, PipelineLoadOptions{Overlay: sessionOpts.Overlay})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ func TestExamplesLoadAndValidate(t *testing.T) {
|
||||
t.Run(profile.name, func(t *testing.T) {
|
||||
options := PipelineLoadOptions{}
|
||||
if profile.selection != "" {
|
||||
options.Profile = &profile.selection
|
||||
options.Overlay = &profile.selection
|
||||
}
|
||||
pipeline, err := LoadPipelineWithOptions(pipelinePath, options)
|
||||
if err != nil {
|
||||
@@ -381,12 +381,12 @@ func TestExamplesLoadAndValidate(t *testing.T) {
|
||||
if cfg.Pipeline.Audita.LLMAPIKey != nil || cfg.Pipeline.Storage.Backend != StorageBackendLocal || cfg.Pipeline.Storage.S3 != nil {
|
||||
t.Fatalf("split bundle must remain offline and secret-free: audita=%#v storage=%#v", cfg.Pipeline.Audita, cfg.Pipeline.Storage)
|
||||
}
|
||||
selectedProfile, selected := SelectedPipelineProfile(cfg.Pipeline)
|
||||
selectedOverlay, selected := SelectedPipelineOverlay(cfg.Pipeline)
|
||||
if profile.selection == "" && selected {
|
||||
t.Fatalf("production base unexpectedly selected profile %#v", selectedProfile)
|
||||
t.Fatalf("production base unexpectedly selected overlay %#v", selectedOverlay)
|
||||
}
|
||||
if profile.selection != "" && (!selected || selectedProfile.Name != profile.selection) {
|
||||
t.Fatalf("selected profile = %#v, want %q", selectedProfile, profile.selection)
|
||||
if profile.selection != "" && (!selected || selectedOverlay.Name != profile.selection) {
|
||||
t.Fatalf("selected overlay = %#v, want %q", selectedOverlay, profile.selection)
|
||||
}
|
||||
for _, key := range []string{
|
||||
"session_recap",
|
||||
|
||||
@@ -16,7 +16,7 @@ type pipelineResolutionMetadata struct {
|
||||
rootPath string
|
||||
imports []string
|
||||
sources []string
|
||||
selectedProfile *pipelineProfileSelection
|
||||
selectedOverlay *pipelineOverlaySelection
|
||||
effectiveDigest string
|
||||
logicalNotariusConfig string
|
||||
logicalNotariusWorking string
|
||||
@@ -29,7 +29,7 @@ type pipelineResolutionMetadata struct {
|
||||
configkitSources []configkit.SourceInfo
|
||||
}
|
||||
|
||||
type pipelineProfileSelection struct {
|
||||
type pipelineOverlaySelection struct {
|
||||
name string
|
||||
source string
|
||||
overlayPath string
|
||||
@@ -41,8 +41,8 @@ type pipelineFieldOwnership struct {
|
||||
}
|
||||
|
||||
func loadPipelineWithConfigKit(path string, opts PipelineLoadOptions) (*PipelineConfig, error) {
|
||||
profile := selectedPipelineProfileName(opts)
|
||||
result, err := loadPipelineConfigKitResult(path, profile)
|
||||
overlay := selectedPipelineOverlayName(opts)
|
||||
result, err := loadPipelineConfigKitResult(path, overlay)
|
||||
if err != nil {
|
||||
return nil, describePipelineLoadError(err)
|
||||
}
|
||||
@@ -55,10 +55,10 @@ func loadPipelineWithConfigKit(path string, opts PipelineLoadOptions) (*Pipeline
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func loadPipelineConfigKitResult(path, profile string) (*configkit.Result[PipelineConfig], error) {
|
||||
func loadPipelineConfigKitResult(path, overlay string) (*configkit.Result[PipelineConfig], error) {
|
||||
return configkit.Load(configkit.File(path), configkit.Options[PipelineConfig]{
|
||||
Imports: true,
|
||||
Profile: profile,
|
||||
Overlay: overlay,
|
||||
Definition: configkit.Definition[PipelineConfig]{
|
||||
Defaults: pipelineConfigDefaults,
|
||||
Resolve: resolvePipelineConfig,
|
||||
@@ -104,11 +104,11 @@ func describeConfigKitLoadError(kind string, err error) error {
|
||||
return fmt.Errorf("load %s config: strict decode failed or validation failed: %w: %s", kind, err, strings.TrimSpace(diagnostics.String()))
|
||||
}
|
||||
|
||||
func selectedPipelineProfileName(opts PipelineLoadOptions) string {
|
||||
if opts.Profile == nil || *opts.Profile == "" || *opts.Profile == "default" {
|
||||
func selectedPipelineOverlayName(opts PipelineLoadOptions) string {
|
||||
if opts.Overlay == nil || *opts.Overlay == "" || *opts.Overlay == "default" {
|
||||
return ""
|
||||
}
|
||||
return *opts.Profile
|
||||
return *opts.Overlay
|
||||
}
|
||||
|
||||
func pipelineConfigDefaults() PipelineConfig {
|
||||
@@ -282,8 +282,8 @@ func pipelineMetadataFromConfigKit(cfg *PipelineConfig, result *configkit.Result
|
||||
} else if source.Layer == configkit.LayerBase {
|
||||
resolved.imports = append(resolved.imports, path)
|
||||
}
|
||||
if source.Layer == configkit.LayerProfile {
|
||||
resolved.selectedProfile = &pipelineProfileSelection{name: selectedPipelineProfileName(opts), source: "explicit", overlayPath: path}
|
||||
if source.Layer == configkit.LayerOverlay {
|
||||
resolved.selectedOverlay = &pipelineOverlaySelection{name: selectedPipelineOverlayName(opts), source: "explicit", overlayPath: path}
|
||||
}
|
||||
}
|
||||
detailed, err := pipelineMetadataPaths(cfg)
|
||||
@@ -347,7 +347,7 @@ func rejectDuplicateBaseDefinitions(metadata configkit.Metadata, sources []confi
|
||||
pathsByID[source.ID] = source.Path
|
||||
}
|
||||
// Enumerate retained declarations, not just surviving effective keys. Loading
|
||||
// the base separately would also resolve credentials removed by the profile.
|
||||
// the base separately would also resolve credentials removed by the overlay.
|
||||
// ConfigKit returns canonical JSON Pointers in deterministic order.
|
||||
for _, path := range metadata.HistoricalPaths() {
|
||||
parts := strings.Split(path.String(), "/")
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPipelineConfigKitImportsProfileAndBaseSelection(t *testing.T) {
|
||||
func TestPipelineConfigKitImportsOverlayAndBaseSelection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePipelineFixture(t, dir, "pipeline.yml", `configkit:
|
||||
imports:
|
||||
@@ -24,33 +24,62 @@ whisperx:
|
||||
enabled: false
|
||||
output_path: artifacts/recap.md
|
||||
`)
|
||||
writePipelineFixture(t, dir, "profiles/testing.yml", "workspace:\n root: /srv/testing\n")
|
||||
writePipelineFixture(t, dir, "profiles/unselected.yml", "unknown: [\n")
|
||||
writePipelineFixture(t, dir, "overlays/testing.yml", "workspace:\n root: /srv/testing\n")
|
||||
writePipelineFixture(t, dir, "overlays/identical.yml", "workspace:\n root: /srv/base\n")
|
||||
writePipelineFixture(t, dir, "overlays/unselected.yml", "unknown: [\n")
|
||||
|
||||
root := filepath.Join(dir, "pipeline.yml")
|
||||
var baseDigest string
|
||||
for _, profile := range []*string{nil, stringPointer(""), stringPointer("default")} {
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: profile})
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: profile})
|
||||
if err != nil {
|
||||
t.Fatalf("load base selection %#v: %v", profile, err)
|
||||
}
|
||||
if cfg.Workspace.Root != "/srv/base" {
|
||||
t.Fatalf("base workspace = %q", cfg.Workspace.Root)
|
||||
}
|
||||
if baseDigest == "" {
|
||||
baseDigest = EffectivePipelineDigest(cfg)
|
||||
} else if EffectivePipelineDigest(cfg) != baseDigest {
|
||||
t.Fatal("equivalent base selections changed effective digest")
|
||||
}
|
||||
}
|
||||
identical := "identical"
|
||||
unchanged, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &identical})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if EffectivePipelineDigest(unchanged) != baseDigest {
|
||||
t.Fatal("equivalent overlay changed effective digest")
|
||||
}
|
||||
selected := "testing"
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &selected})
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &selected})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Workspace.Root != "/srv/testing" || len(EffectivePipelineImports(cfg)) != 2 {
|
||||
t.Fatalf("profiled pipeline = %#v, imports=%#v", cfg.Workspace, EffectivePipelineImports(cfg))
|
||||
}
|
||||
profile, ok := SelectedPipelineProfile(cfg)
|
||||
if !ok || profile.Name != "testing" || profile.OverlayPath != filepath.Join(dir, "profiles", "testing.yml") {
|
||||
profile, ok := SelectedPipelineOverlay(cfg)
|
||||
if !ok || profile.Name != "testing" || profile.OverlayPath != filepath.Join(dir, "overlays", "testing.yml") {
|
||||
t.Fatalf("selected profile = %#v, %v", profile, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineOverlayDoesNotDiscoverLegacyDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writePipelineFixture(t, dir, "pipeline.yml", "configkit:\n imports:\n - path: profiles/shared.yml\n")
|
||||
writePipelineFixture(t, dir, "profiles/shared.yml", "workspace:\n root: /srv/base\n")
|
||||
writePipelineFixture(t, dir, "profiles/testing.yml", "workspace:\n root: /srv/old\n")
|
||||
if base, err := LoadPipeline(filepath.Join(dir, "pipeline.yml")); err != nil || base.Workspace.Root != "/srv/base" {
|
||||
t.Fatalf("explicit import from profiles directory = %#v, %v", base, err)
|
||||
}
|
||||
selected := "testing"
|
||||
if _, err := LoadPipelineWithOptions(filepath.Join(dir, "pipeline.yml"), PipelineLoadOptions{Overlay: &selected}); err == nil || !strings.Contains(err.Error(), "overlay.missing") {
|
||||
t.Fatalf("legacy selected file result = %v, want missing overlay", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineConfigKitRejectsDuplicateBaseDeclarations(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, first, second, clearedContainer string
|
||||
@@ -69,14 +98,14 @@ whisperx:
|
||||
`)
|
||||
writePipelineFixture(t, dir, "first.yml", test.first)
|
||||
writePipelineFixture(t, dir, "second.yml", test.second)
|
||||
writePipelineFixture(t, dir, "profiles/clear-container.yml", test.clearedContainer)
|
||||
writePipelineFixture(t, dir, "profiles/clear-scriptorium.yml", "scriptorium: null\n")
|
||||
writePipelineFixture(t, dir, "overlays/clear-container.yml", test.clearedContainer)
|
||||
writePipelineFixture(t, dir, "overlays/clear-scriptorium.yml", "scriptorium: null\n")
|
||||
|
||||
for _, profile := range []string{"", "clear-container", "clear-scriptorium"} {
|
||||
t.Run(profile, func(t *testing.T) {
|
||||
options := PipelineLoadOptions{}
|
||||
if profile != "" {
|
||||
options.Profile = &profile
|
||||
options.Overlay = &profile
|
||||
}
|
||||
_, err := LoadPipelineWithOptions(filepath.Join(dir, "pipeline.yml"), options)
|
||||
if err == nil || !strings.Contains(err.Error(), "declared by multiple base files") ||
|
||||
@@ -89,7 +118,7 @@ whisperx:
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelineProfileDoesNotRequireUnusedBaseCredentials(t *testing.T) {
|
||||
func TestPipelineOverlayDoesNotRequireUnusedBaseCredentials(t *testing.T) {
|
||||
const production = "NARRATIO_PROFILE_UNUSED_PROD_KEY"
|
||||
const testingKey = "NARRATIO_PROFILE_TEST_KEY"
|
||||
t.Setenv(production, "")
|
||||
@@ -101,9 +130,9 @@ func TestPipelineProfileDoesNotRequireUnusedBaseCredentials(t *testing.T) {
|
||||
t.Run(credential, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\naudita:\n llm_api_key: "+production+"\n")
|
||||
writePipelineFixture(t, dir, "profiles/testing.yml", "audita:\n llm_api_key: "+credential+"\n")
|
||||
writePipelineFixture(t, dir, "overlays/testing.yml", "audita:\n llm_api_key: "+credential+"\n")
|
||||
profile := "testing"
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile})
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -130,9 +159,9 @@ func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) {
|
||||
t.Run(credential, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+baseKey+"\n")
|
||||
overlay := writePipelineFixture(t, dir, "profiles/testing.yml", "notarius:\n llm_api_key: "+credential+"\n")
|
||||
overlay := writePipelineFixture(t, dir, "overlays/testing.yml", "notarius:\n llm_api_key: "+credential+"\n")
|
||||
profile := "testing"
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile})
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -187,8 +216,8 @@ func TestNotariusConfigProfileComposition(t *testing.T) {
|
||||
root := writePipelineFixture(t, dir, "pipeline.yml", rootYAML)
|
||||
options := PipelineLoadOptions{}
|
||||
if test.overlayName != "" {
|
||||
writePipelineFixture(t, dir, "profiles/"+test.overlayName+".yml", test.overlay)
|
||||
options.Profile = &test.overlayName
|
||||
writePipelineFixture(t, dir, "overlays/"+test.overlayName+".yml", test.overlay)
|
||||
options.Overlay = &test.overlayName
|
||||
}
|
||||
cfg, err := LoadPipelineWithOptions(root, options)
|
||||
if err != nil {
|
||||
@@ -238,9 +267,9 @@ func TestNotariusCacheControlProfileReplacementAndNullReset(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n resume: false\n chunk_cache: bypass\n")
|
||||
overlay := writePipelineFixture(t, dir, "profiles/testing.yml", test.overlay)
|
||||
overlay := writePipelineFixture(t, dir, "overlays/testing.yml", test.overlay)
|
||||
profile := "testing"
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile})
|
||||
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Overlay: &profile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -282,9 +311,9 @@ whisperx:
|
||||
transcribe_url: https://transcription.example.test/transcribe
|
||||
`)
|
||||
writePipelineFixture(t, dir, "artifact.yml", "scriptorium:\n artifacts:\n recap:\n prompt_id: base\n output_path: artifacts/recap.md\n")
|
||||
writePipelineFixture(t, dir, "profiles/testing.yml", "scriptorium:\n artifacts:\n recap:\n prompt_id: testing\n")
|
||||
writePipelineFixture(t, dir, "overlays/testing.yml", "scriptorium:\n artifacts:\n recap:\n prompt_id: testing\n")
|
||||
profile := "testing"
|
||||
cfg, err := LoadPipelineWithOptions(filepath.Join(dir, "pipeline.yml"), PipelineLoadOptions{Profile: &profile})
|
||||
cfg, err := LoadPipelineWithOptions(filepath.Join(dir, "pipeline.yml"), PipelineLoadOptions{Overlay: &profile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -71,13 +72,25 @@ type StageRecord struct {
|
||||
// EffectiveConfigProvenance records bounded non-secret configuration identity
|
||||
// for one resolved invocation.
|
||||
type EffectiveConfigProvenance struct {
|
||||
SelectedProfile *SelectedProfileProvenance `json:"selected_profile,omitempty"`
|
||||
SelectedOverlay *SelectedOverlayProvenance `json:"selected_overlay,omitempty"`
|
||||
EffectiveConfigDigest string `json:"effective_config_digest,omitempty"`
|
||||
}
|
||||
|
||||
// SelectedProfileProvenance identifies an explicitly or default-selected
|
||||
// pipeline profile without recording profile content.
|
||||
type SelectedProfileProvenance struct {
|
||||
// UnmarshalJSON accepts unknown informational provenance from older manifests.
|
||||
// The enclosing manifest and its execution state remain strictly decoded.
|
||||
func (p *EffectiveConfigProvenance) UnmarshalJSON(data []byte) error {
|
||||
type provenance EffectiveConfigProvenance
|
||||
var decoded provenance
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
*p = EffectiveConfigProvenance(decoded)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectedOverlayProvenance identifies an explicitly or default-selected
|
||||
// pipeline overlay without recording overlay content.
|
||||
type SelectedOverlayProvenance struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
|
||||
m.S3SessionPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/"
|
||||
m.S3RunPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/runs/20260515T031522Z-a1b2c3d4/"
|
||||
m.EffectiveConfig = &EffectiveConfigProvenance{
|
||||
SelectedProfile: &SelectedProfileProvenance{Name: "production", Source: "default"},
|
||||
SelectedOverlay: &SelectedOverlayProvenance{Name: "production", Source: "default"},
|
||||
EffectiveConfigDigest: "001122",
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
|
||||
if loaded.RunID != "20260515T031522Z-a1b2c3d4" {
|
||||
t.Fatalf("RunID = %q, want run id", loaded.RunID)
|
||||
}
|
||||
if loaded.EffectiveConfig == nil || loaded.EffectiveConfig.SelectedProfile == nil || loaded.EffectiveConfig.SelectedProfile.Name != "production" || loaded.EffectiveConfig.EffectiveConfigDigest != "001122" {
|
||||
if loaded.EffectiveConfig == nil || loaded.EffectiveConfig.SelectedOverlay == nil || loaded.EffectiveConfig.SelectedOverlay.Name != "production" || loaded.EffectiveConfig.EffectiveConfigDigest != "001122" {
|
||||
t.Fatalf("effective config provenance = %#v", loaded.EffectiveConfig)
|
||||
}
|
||||
stage, ok := loaded.Stages["prepare"]
|
||||
@@ -75,6 +75,21 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStoreLoadsManifestWithHistoricalSelectionProvenance(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||
data := `{"schema_version":"narratio.session-manifest.v1","session_id":"2026-05-03","created_at":"2026-05-03T10:00:00Z","updated_at":"2026-05-03T10:00:00Z","effective_config":{"selected_profile":{"name":"testing","source":"explicit"},"effective_config_digest":"001122"},"stages":{}}`
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := (&LocalStore{}).Load(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.EffectiveConfig == nil || loaded.EffectiveConfig.EffectiveConfigDigest != "001122" || loaded.EffectiveConfig.SelectedOverlay != nil {
|
||||
t.Fatalf("historical informational provenance = %#v", loaded.EffectiveConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalStorePreservesOptionalArtifactMetadata(t *testing.T) {
|
||||
store := &LocalStore{}
|
||||
ctx := context.Background()
|
||||
|
||||
Reference in New Issue
Block a user