Expose pipeline profile selection and provenance
This commit is contained in:
@@ -43,6 +43,7 @@ Most session-aware commands accept:
|
|||||||
- `--session <session.yml>`
|
- `--session <session.yml>`
|
||||||
- `--session-id <session_id>`
|
- `--session-id <session_id>`
|
||||||
- `--previous-session-id <session_id>`
|
- `--previous-session-id <session_id>`
|
||||||
|
- `--profile <name>`
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
@@ -51,6 +52,10 @@ Rules:
|
|||||||
- if both positional `<session_id>` and `--session-id` are provided, values must match.
|
- if both positional `<session_id>` and `--session-id` are provided, values must match.
|
||||||
- `--previous-session-id` is a strict expectation: the selected session file
|
- `--previous-session-id` is a strict expectation: the selected session file
|
||||||
must contain the same `previous_session_id`.
|
must contain the same `previous_session_id`.
|
||||||
|
- `--profile` selects a declared pipeline profile. It may be supplied once;
|
||||||
|
an explicit empty or unknown value fails configuration resolution. When it is
|
||||||
|
omitted, a declared `default_profile` is used. The same selection applies to
|
||||||
|
all common-flag commands, including `regenerate-artifacts`.
|
||||||
- `clean --all` cannot be combined with campaign/session selectors.
|
- `clean --all` cannot be combined with campaign/session selectors.
|
||||||
- notification delivery is currently limited to the configured `noop` mode; see
|
- notification delivery is currently limited to the configured `noop` mode; see
|
||||||
the [configuration reference](./config.md#notifications).
|
the [configuration reference](./config.md#notifications).
|
||||||
@@ -102,6 +107,7 @@ Behavior:
|
|||||||
`--name=value` spellings;
|
`--name=value` spellings;
|
||||||
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
- continues interrupted or partially completed sessions by running non-succeeded stages;
|
||||||
- writes session and run manifests.
|
- writes session and run manifests.
|
||||||
|
- reports the resolved profile (or `none`) and effective configuration digest.
|
||||||
|
|
||||||
When `--artifacts` is present, the selected range must contain `analyze` or
|
When `--artifacts` is present, the selected range must contain `analyze` or
|
||||||
`publish`. Either consumer is sufficient, including a one-stage range.
|
`publish`. Either consumer is sufficient, including a one-stage range.
|
||||||
@@ -203,6 +209,8 @@ Uses the same inclusive bounds, endpoint validation, force scope, and artifact
|
|||||||
selection contract as `run`. It validates config and prints run/skip decisions
|
selection contract as `run`. It validates config and prints run/skip decisions
|
||||||
for selected stages only without creating the local workdir or changing the
|
for selected stages only without creating the local workdir or changing the
|
||||||
manifest. Resume-capable selected stages are checked against durable evidence.
|
manifest. Resume-capable selected stages are checked against durable evidence.
|
||||||
|
The output includes the resolved profile (or `none`) and effective configuration
|
||||||
|
digest without writing provenance or any manifest state.
|
||||||
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
|
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
|
||||||
execution order, and reusable current artifacts with concise reasons. These
|
execution order, and reusable current artifacts with concise reasons. These
|
||||||
artifact decisions come from the same reconciliation and work planner used by
|
artifact decisions come from the same reconciliation and work planner used by
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ does not turn incidental canonical bytes into manifest authority.
|
|||||||
`manifest.RunManifest` is created for each invocation and records:
|
`manifest.RunManifest` is created for each invocation and records:
|
||||||
|
|
||||||
- invocation identity and `force` flag
|
- invocation identity and `force` flag
|
||||||
|
- the selected profile (when any) and secret-free effective configuration digest
|
||||||
- requested stages
|
- requested stages
|
||||||
- per-stage action (`run` or `skip`)
|
- per-stage action (`run` or `skip`)
|
||||||
- per-stage status
|
- per-stage status
|
||||||
@@ -233,6 +234,15 @@ after such a private content change.
|
|||||||
Session manifest is the authoritative stage-progress ledger across invocations.
|
Session manifest is the authoritative stage-progress ledger across invocations.
|
||||||
Run manifest is invocation-scoped audit state.
|
Run manifest is invocation-scoped audit state.
|
||||||
|
|
||||||
|
Both manifests retain the most recently resolved invocation's bounded
|
||||||
|
configuration provenance. It identifies the selected profile name and source
|
||||||
|
(`default` or `cli`) plus the effective configuration digest, but never a raw
|
||||||
|
secret or profile content. This provenance is informational: it does not
|
||||||
|
participate in stage resume or cache decisions. A profile change therefore
|
||||||
|
invalidates only stages whose semantic configuration changed. When a private
|
||||||
|
external-tool model, module, prompt, or profile changes behind an unchanged
|
||||||
|
configured identifier, use `--force` for the affected work.
|
||||||
|
|
||||||
`session plan` computes the same current fingerprint and applies the same
|
`session plan` computes the same current fingerprint and applies the same
|
||||||
comparison and invalidation rules to a cloned manifest. It predicts the runner
|
comparison and invalidation rules to a cloned manifest. It predicts the runner
|
||||||
decision without persisting session or invocation state. The shared helper
|
decision without persisting session or invocation state. The shared helper
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ narratio run 2026-04-04
|
|||||||
narratio session status 2026-04-04
|
narratio session status 2026-04-04
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run, plan, and status output identify the resolved pipeline profile (or `none`)
|
||||||
|
and effective configuration digest. Status distinguishes the current resolved
|
||||||
|
value from the last value persisted in the session manifest, which helps
|
||||||
|
diagnose profile switches without changing resume authority.
|
||||||
|
|
||||||
## Stage Execution and Continuation Behavior
|
## Stage Execution and Continuation Behavior
|
||||||
|
|
||||||
Canonical stage order:
|
Canonical stage order:
|
||||||
|
|||||||
@@ -547,7 +547,7 @@ command-specific resolution before CLI profile selection is exposed.
|
|||||||
|
|
||||||
## Stage 9 — Profile CLI Plumbing, Reporting, And Manifest Provenance
|
## Stage 9 — Profile CLI Plumbing, Reporting, And Manifest Provenance
|
||||||
|
|
||||||
**Status: Pending**
|
**Status: Completed**
|
||||||
|
|
||||||
### Goal
|
### Goal
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ func (f *singletonStringFlag) Set(value string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f singletonStringFlag) pointer() *string {
|
||||||
|
if !f.set {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value := f.value
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
type singletonBoolFlag struct {
|
type singletonBoolFlag struct {
|
||||||
name string
|
name string
|
||||||
value bool
|
value bool
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
|
|||||||
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
|
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
|
||||||
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
|
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
|
||||||
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
|
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
|
||||||
|
{name: "profile", args: []string{"session", "--profile", "production", "--profile=testing"}, want: "--profile may be specified only once"},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -68,6 +69,32 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBoundedRunParsingRetainsExplicitProfilePresence(t *testing.T) {
|
||||||
|
withoutProfile, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := withoutProfile.Config.sessionOptions().Profile; got != nil {
|
||||||
|
t.Fatalf("omitted profile = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
withProfile, err := parseBoundedRunRequest("run", []string{"session", "--profile", "testing"}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := withProfile.Config.sessionOptions().Profile; got == nil || *got != "testing" {
|
||||||
|
t.Fatalf("explicit profile = %#v, want testing", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--profile", ""}, io.Discard)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := explicitEmpty.Config.sessionOptions().Profile; got == nil || *got != "" {
|
||||||
|
t.Fatalf("explicit empty profile = %#v, want non-nil empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) {
|
func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) {
|
||||||
args := []string{"session", "--from", "publish", "--through", "render"}
|
args := []string{"session", "--from", "publish", "--through", "render"}
|
||||||
runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard)
|
runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard)
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writ
|
|||||||
strings.TrimSpace(flags.previousSessionID) != "" {
|
strings.TrimSpace(flags.previousSessionID) != "" {
|
||||||
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
|
return fmt.Errorf("clean: --all cannot be combined with --campaign, --campaign-file, --session, a session_id, or --previous-session-id")
|
||||||
}
|
}
|
||||||
_, pipelineCfg, err := loadPipelineConfig(flags.pipelinePath)
|
_, pipelineCfg, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("clean: %w", err)
|
return fmt.Errorf("clean: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
type pipelineCampaignConfig = config.LoadedPipelineCampaign
|
type pipelineCampaignConfig = config.LoadedPipelineCampaign
|
||||||
|
|
||||||
var downloadObjectToTempFn = storage.DownloadObjectToTemp
|
var downloadObjectToTempFn = storage.DownloadObjectToTemp
|
||||||
var loadPipelineConfigFn = config.LoadPipeline
|
var loadPipelineConfigFn = config.LoadPipelineWithOptions
|
||||||
|
|
||||||
type commandConfig struct {
|
type commandConfig struct {
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
@@ -48,7 +48,7 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag)
|
base, err := loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag, config.PipelineLoadOptions{Profile: sessionOpts.Profile})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -137,8 +137,8 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign
|
|||||||
return loaded, nil
|
return loaded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) {
|
func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string, pipelineOpts config.PipelineLoadOptions) (*pipelineCampaignConfig, error) {
|
||||||
loadedPipelinePath, pipelineCfg, err := loadPipelineConfig(pipelineFlag)
|
loadedPipelinePath, pipelineCfg, err := loadPipelineConfig(pipelineFlag, pipelineOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -163,12 +163,12 @@ func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag str
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPipelineConfig(pipelineFlag string) (string, *config.PipelineConfig, error) {
|
func loadPipelineConfig(pipelineFlag string, opts config.PipelineLoadOptions) (string, *config.PipelineConfig, error) {
|
||||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
pipelineCfg, err := loadPipelineConfigFn(resolvedPipelinePath)
|
pipelineCfg, err := loadPipelineConfigFn(resolvedPipelinePath, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, err
|
return "", nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ func TestLoadCommandConfigRetainsInitiallyLoadedPipeline(t *testing.T) {
|
|||||||
|
|
||||||
originalLoader := loadPipelineConfigFn
|
originalLoader := loadPipelineConfigFn
|
||||||
loadCalls := 0
|
loadCalls := 0
|
||||||
loadPipelineConfigFn = func(path string) (*config.PipelineConfig, error) {
|
loadPipelineConfigFn = func(path string, opts config.PipelineLoadOptions) (*config.PipelineConfig, error) {
|
||||||
loaded, err := originalLoader(path)
|
loaded, err := originalLoader(path, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
70
internal/app/config_provenance.go
Normal file
70
internal/app/config_provenance.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func effectiveConfigProvenance(cfg *config.Config) *manifest.EffectiveConfigProvenance {
|
||||||
|
if cfg == nil || cfg.Pipeline == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
provenance := &manifest.EffectiveConfigProvenance{
|
||||||
|
EffectiveConfigDigest: config.EffectivePipelineDigest(cfg.Pipeline),
|
||||||
|
}
|
||||||
|
if selected, ok := config.SelectedPipelineProfile(cfg.Pipeline); ok {
|
||||||
|
provenance.SelectedProfile = &manifest.SelectedProfileProvenance{
|
||||||
|
Name: selected.Name,
|
||||||
|
Source: selected.Source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if provenance.SelectedProfile == nil && provenance.EffectiveConfigDigest == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return provenance
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEffectiveConfigProvenance(m *manifest.Manifest, cfg *config.Config) {
|
||||||
|
if m != nil {
|
||||||
|
m.EffectiveConfig = effectiveConfigProvenance(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyEffectiveConfigProvenanceToRun(m *manifest.RunManifest, cfg *config.Config) {
|
||||||
|
if m != nil {
|
||||||
|
m.EffectiveConfig = effectiveConfigProvenance(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func effectiveConfigSummary(cfg *config.Config) string {
|
||||||
|
provenance := effectiveConfigProvenance(cfg)
|
||||||
|
if provenance == nil {
|
||||||
|
return "profile=none digest=unavailable"
|
||||||
|
}
|
||||||
|
profile := "none"
|
||||||
|
if provenance.SelectedProfile != nil {
|
||||||
|
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
|
||||||
|
}
|
||||||
|
digest := provenance.EffectiveConfigDigest
|
||||||
|
if digest == "" {
|
||||||
|
digest = "unavailable"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("profile=%s digest=%s", profile, digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistedEffectiveConfigSummary(provenance *manifest.EffectiveConfigProvenance) string {
|
||||||
|
if provenance == nil {
|
||||||
|
return "profile=none digest=unavailable"
|
||||||
|
}
|
||||||
|
profile := "none"
|
||||||
|
if provenance.SelectedProfile != nil {
|
||||||
|
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
|
||||||
|
}
|
||||||
|
digest := provenance.EffectiveConfigDigest
|
||||||
|
if digest == "" {
|
||||||
|
digest = "unavailable"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("profile=%s digest=%s", profile, digest)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ type commonConfigFlags struct {
|
|||||||
sessionPath string
|
sessionPath string
|
||||||
sessionID string
|
sessionID string
|
||||||
previousSessionID string
|
previousSessionID string
|
||||||
|
profile singletonStringFlag
|
||||||
}
|
}
|
||||||
|
|
||||||
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
||||||
@@ -29,12 +30,15 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
|||||||
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
|
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
|
||||||
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
|
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
|
||||||
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
||||||
|
flags.profile.name = "profile"
|
||||||
|
fs.Var(&flags.profile, "profile", "named pipeline profile")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
||||||
return config.SessionLoadOptions{
|
return config.SessionLoadOptions{
|
||||||
SessionID: f.sessionID,
|
SessionID: f.sessionID,
|
||||||
PreviousSessionID: f.previousSessionID,
|
PreviousSessionID: f.previousSessionID,
|
||||||
|
Profile: f.profile.pointer(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
|
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
||||||
|
var profile singletonStringFlag
|
||||||
var remote, force bool
|
var remote, force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
||||||
@@ -33,6 +34,8 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
fs.StringVar(&output, "output", "", "local output session.yml path")
|
fs.StringVar(&output, "output", "", "local output session.yml path")
|
||||||
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
|
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
|
||||||
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
|
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
|
||||||
|
profile.name = "profile"
|
||||||
|
fs.Var(&profile, "profile", "named pipeline profile")
|
||||||
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
|
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
|
||||||
fs.BoolVar(&force, "force", false, "overwrite existing target")
|
fs.BoolVar(&force, "force", false, "overwrite existing target")
|
||||||
if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil {
|
if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil {
|
||||||
@@ -48,7 +51,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
|
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath)
|
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Profile: profile.pointer()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("session init: %w", err)
|
return fmt.Errorf("session init: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign)
|
fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign)
|
||||||
fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
|
fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
|
||||||
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
|
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
|
||||||
|
fmt.Fprintf(out, "Configuration (current): %s\n", effectiveConfigSummary(cfg))
|
||||||
writeStatusStableInputs(out, inspectStableInputs(cfg))
|
writeStatusStableInputs(out, inspectStableInputs(cfg))
|
||||||
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
|
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
|
||||||
|
|
||||||
@@ -51,6 +52,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
} else {
|
} else {
|
||||||
localManifest = m
|
localManifest = m
|
||||||
fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath)
|
fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath)
|
||||||
|
fmt.Fprintf(out, "Configuration (last persisted): %s\n", persistedEffectiveConfigSummary(m.EffectiveConfig))
|
||||||
writeStageStatuses(out, m)
|
writeStageStatuses(out, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
runCount := 0
|
runCount := 0
|
||||||
skipCount := 0
|
skipCount := 0
|
||||||
if _, err := fmt.Fprintf(out, "narratio session plan: read-only workdir at %s\n", paths.Root); err != nil {
|
if _, err := fmt.Fprintf(out, "narratio session plan: read-only workdir at %s; %s\n", paths.Root, effectiveConfigSummary(cfg)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, selectedStage := range stages {
|
for _, selectedStage := range stages {
|
||||||
|
|||||||
72
internal/app/profile_commands_test.go
Normal file
72
internal/app/profile_commands_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||||
|
enableCommandTestProfiles(t, pipelinePath)
|
||||||
|
|
||||||
|
original := executeStagesFn
|
||||||
|
t.Cleanup(func() { executeStagesFn = original })
|
||||||
|
var language string
|
||||||
|
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
|
||||||
|
language = cfg.Pipeline.WhisperX.Language
|
||||||
|
return &RunSummary{SessionID: cfg.Session.SessionID, ManifestPath: "manifest.json"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Run(context.Background(), []string{
|
||||||
|
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "testing",
|
||||||
|
}, io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if language != "fr" {
|
||||||
|
t.Fatalf("explicit profile language = %q, want fr", language)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Run(context.Background(), []string{
|
||||||
|
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
|
||||||
|
}, io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if language != "en" {
|
||||||
|
t.Fatalf("default profile language = %q, want en", language)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, profile := range []string{"unknown", ""} {
|
||||||
|
err := Run(context.Background(), []string{
|
||||||
|
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", profile,
|
||||||
|
}, io.Discard)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "profile") {
|
||||||
|
t.Fatalf("profile %q error = %v, want selection rejection", profile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableCommandTestProfiles(t *testing.T, pipelinePath string) {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(pipelinePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
composition := "composition:\n default_profile: production\n profiles:\n production:\n overlay: production.yml\n testing:\n overlay: testing.yml\n"
|
||||||
|
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(pipelinePath)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "production.yml"), []byte("whisperx:\n language: en\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "testing.yml"), []byte("whisperx:\n language: fr\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
|
|||||||
"--artifacts=player_handout",
|
"--artifacts=player_handout",
|
||||||
"--config", "pipeline.yml",
|
"--config", "pipeline.yml",
|
||||||
"--campaign", "sample-campaign",
|
"--campaign", "sample-campaign",
|
||||||
|
"--profile", "testing",
|
||||||
}, io.Discard, io.Discard)
|
}, io.Discard, io.Discard)
|
||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("Execute() code = %d, want 0", code)
|
t.Fatalf("Execute() code = %d, want 0", code)
|
||||||
@@ -34,6 +35,7 @@ func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
|
|||||||
"--artifacts=player_handout",
|
"--artifacts=player_handout",
|
||||||
"--config", "pipeline.yml",
|
"--config", "pipeline.yml",
|
||||||
"--campaign", "sample-campaign",
|
"--campaign", "sample-campaign",
|
||||||
|
"--profile", "testing",
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(captured, want) {
|
if !reflect.DeepEqual(captured, want) {
|
||||||
t.Fatalf("forwarded args = %#v, want %#v", captured, want)
|
t.Fatalf("forwarded args = %#v, want %#v", captured, want)
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
|
|
||||||
_, err = fmt.Fprintf(
|
_, err = fmt.Fprintf(
|
||||||
out,
|
out,
|
||||||
"narratio run: session %s; executed=%d skipped=%d; manifest=%s\n",
|
"narratio run: session %s; executed=%d skipped=%d; manifest=%s; %s\n",
|
||||||
summary.SessionID,
|
summary.SessionID,
|
||||||
len(summary.Executed),
|
len(summary.Executed),
|
||||||
len(summary.Skipped),
|
len(summary.Skipped),
|
||||||
summary.ManifestPath,
|
summary.ManifestPath,
|
||||||
|
effectiveConfigSummary(cfg),
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
|||||||
return nil, fmt.Errorf("validate bounded run prerequisites under session lock: %w", err)
|
return nil, fmt.Errorf("validate bounded run prerequisites under session lock: %w", err)
|
||||||
}
|
}
|
||||||
identity.applyToSessionManifest(m)
|
identity.applyToSessionManifest(m)
|
||||||
|
applyEffectiveConfigProvenance(m, cfg)
|
||||||
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
|
||||||
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
|
||||||
}
|
}
|
||||||
@@ -172,6 +173,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
|
|||||||
return nil, fmt.Errorf("create run manifest: %w", err)
|
return nil, fmt.Errorf("create run manifest: %w", err)
|
||||||
}
|
}
|
||||||
identity.applyToRunManifest(runManifest, manifestPath)
|
identity.applyToRunManifest(runManifest, manifestPath)
|
||||||
|
applyEffectiveConfigProvenanceToRun(runManifest, cfg)
|
||||||
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
|
||||||
return nil, persistTerminalFailure(
|
return nil, persistTerminalFailure(
|
||||||
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
|
||||||
|
|||||||
@@ -10,6 +10,31 @@ import (
|
|||||||
|
|
||||||
const pipelineDefaultOwnershipSource = "default"
|
const pipelineDefaultOwnershipSource = "default"
|
||||||
|
|
||||||
|
// PipelineProfileProvenance identifies the profile selected while resolving a
|
||||||
|
// pipeline. It contains only non-secret selection metadata.
|
||||||
|
type PipelineProfileProvenance struct {
|
||||||
|
Name string
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedPipelineProfile reports the profile used to resolve cfg, if any.
|
||||||
|
func SelectedPipelineProfile(cfg *PipelineConfig) (*PipelineProfileProvenance, bool) {
|
||||||
|
if cfg == nil || cfg.resolution == nil || cfg.resolution.selectedProfile == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
selection := cfg.resolution.selectedProfile
|
||||||
|
return &PipelineProfileProvenance{Name: selection.name, Source: selection.source}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectivePipelineDigest reports the deterministic secret-free digest for a
|
||||||
|
// resolved pipeline.
|
||||||
|
func EffectivePipelineDigest(cfg *PipelineConfig) string {
|
||||||
|
if cfg == nil || cfg.resolution == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cfg.resolution.effectiveDigest
|
||||||
|
}
|
||||||
|
|
||||||
func finalizePipelineResolution(cfg *PipelineConfig) error {
|
func finalizePipelineResolution(cfg *PipelineConfig) error {
|
||||||
if cfg == nil || cfg.resolution == nil {
|
if cfg == nil || cfg.resolution == nil {
|
||||||
return fmt.Errorf("pipeline resolution metadata is required")
|
return fmt.Errorf("pipeline resolution metadata is required")
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ type PostPublishCleanup struct {
|
|||||||
Targets []CleanupTarget `json:"targets"`
|
Targets []CleanupTarget `json:"targets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EffectiveConfigProvenance records bounded non-secret configuration identity
|
||||||
|
// for one resolved invocation.
|
||||||
|
type EffectiveConfigProvenance struct {
|
||||||
|
SelectedProfile *SelectedProfileProvenance `json:"selected_profile,omitempty"`
|
||||||
|
EffectiveConfigDigest string `json:"effective_config_digest,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedProfileProvenance identifies an explicitly or default-selected
|
||||||
|
// pipeline profile without recording profile content.
|
||||||
|
type SelectedProfileProvenance struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
// Manifest is the durable run-state record for a session execution.
|
// Manifest is the durable run-state record for a session execution.
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
@@ -90,6 +104,7 @@ type Manifest struct {
|
|||||||
S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
|
S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
|
||||||
S3RunPrefix string `json:"s3_run_prefix,omitempty"`
|
S3RunPrefix string `json:"s3_run_prefix,omitempty"`
|
||||||
PipelineVersion string `json:"pipeline_version,omitempty"`
|
PipelineVersion string `json:"pipeline_version,omitempty"`
|
||||||
|
EffectiveConfig *EffectiveConfigProvenance `json:"effective_config,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
LastError *ErrorRecord `json:"last_error,omitempty"`
|
LastError *ErrorRecord `json:"last_error,omitempty"`
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type RunManifest struct {
|
|||||||
S3Bucket string `json:"s3_bucket,omitempty"`
|
S3Bucket string `json:"s3_bucket,omitempty"`
|
||||||
S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
|
S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
|
||||||
S3RunPrefix string `json:"s3_run_prefix,omitempty"`
|
S3RunPrefix string `json:"s3_run_prefix,omitempty"`
|
||||||
|
EffectiveConfig *EffectiveConfigProvenance `json:"effective_config,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
|
|||||||
m.S3Bucket = "my-dnd-archive"
|
m.S3Bucket = "my-dnd-archive"
|
||||||
m.S3SessionPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/"
|
m.S3SessionPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/"
|
||||||
m.S3RunPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/runs/20260515T031522Z-a1b2c3d4/"
|
m.S3RunPrefix = "dnd/campaigns/forsaken/sessions/2026-05-03/runs/20260515T031522Z-a1b2c3d4/"
|
||||||
|
m.EffectiveConfig = &EffectiveConfigProvenance{
|
||||||
|
SelectedProfile: &SelectedProfileProvenance{Name: "production", Source: "default"},
|
||||||
|
EffectiveConfigDigest: "001122",
|
||||||
|
}
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||||
if err := store.Save(ctx, path, m); err != nil {
|
if err := store.Save(ctx, path, m); err != nil {
|
||||||
@@ -52,6 +56,9 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) {
|
|||||||
if loaded.RunID != "20260515T031522Z-a1b2c3d4" {
|
if loaded.RunID != "20260515T031522Z-a1b2c3d4" {
|
||||||
t.Fatalf("RunID = %q, want run id", loaded.RunID)
|
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" {
|
||||||
|
t.Fatalf("effective config provenance = %#v", loaded.EffectiveConfig)
|
||||||
|
}
|
||||||
stage, ok := loaded.Stages["prepare"]
|
stage, ok := loaded.Stages["prepare"]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("stage prepare not found")
|
t.Fatalf("stage prepare not found")
|
||||||
@@ -264,6 +271,10 @@ func TestLocalStoreCreateSaveLoadRunManifestRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("CreateRun() error = %v", err)
|
t.Fatalf("CreateRun() error = %v", err)
|
||||||
}
|
}
|
||||||
run.SessionManifestPath = "/var/lib/narratio/work/forsaken/2026-05-03/manifest.json"
|
run.SessionManifestPath = "/var/lib/narratio/work/forsaken/2026-05-03/manifest.json"
|
||||||
|
run.EffectiveConfig = &EffectiveConfigProvenance{
|
||||||
|
SelectedProfile: &SelectedProfileProvenance{Name: "testing", Source: "cli"},
|
||||||
|
EffectiveConfigDigest: "aabbcc",
|
||||||
|
}
|
||||||
run.MarkStageRunning("prepare", time.Date(2026, 5, 3, 12, 1, 0, 0, time.UTC))
|
run.MarkStageRunning("prepare", time.Date(2026, 5, 3, 12, 1, 0, 0, time.UTC))
|
||||||
run.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 12, 2, 0, 0, time.UTC), []ArtifactRecord{
|
run.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 12, 2, 0, 0, time.UTC), []ArtifactRecord{
|
||||||
{Kind: "input", LocalPath: "inputs/session.yml"},
|
{Kind: "input", LocalPath: "inputs/session.yml"},
|
||||||
@@ -298,6 +309,9 @@ func TestLocalStoreCreateSaveLoadRunManifestRoundTrip(t *testing.T) {
|
|||||||
if loaded.Stages["prepare"].Action != RunStageActionRun {
|
if loaded.Stages["prepare"].Action != RunStageActionRun {
|
||||||
t.Fatalf("prepare action = %q, want %q", loaded.Stages["prepare"].Action, RunStageActionRun)
|
t.Fatalf("prepare action = %q, want %q", loaded.Stages["prepare"].Action, RunStageActionRun)
|
||||||
}
|
}
|
||||||
|
if loaded.EffectiveConfig == nil || loaded.EffectiveConfig.SelectedProfile == nil || loaded.EffectiveConfig.SelectedProfile.Name != "testing" || loaded.EffectiveConfig.EffectiveConfigDigest != "aabbcc" {
|
||||||
|
t.Fatalf("effective config provenance = %#v", loaded.EffectiveConfig)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadRunRejectsInvalidManifest(t *testing.T) {
|
func TestLoadRunRejectsInvalidManifest(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user