Expose pipeline profile selection and provenance

This commit is contained in:
2026-08-30 13:41:26 +00:00
parent f86b17045d
commit 4e991fa21d
22 changed files with 298 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,14 @@ func (f *singletonStringFlag) Set(value string) error {
return nil return nil
} }
func (f singletonStringFlag) pointer() *string {
if !f.set {
return nil
}
value := f.value
return &value
}
type singletonBoolFlag struct { type singletonBoolFlag struct {
name string name string
value bool value bool

View File

@@ -57,6 +57,7 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
{name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"}, {name: "through mixed", args: []string{"session", "--through", "analyze", "--through=publish"}, want: "--through may be specified only once"},
{name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"}, {name: "force separate", args: []string{"session", "--force", "--force"}, want: "--force may be specified only once"},
{name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"}, {name: "force equals", args: []string{"session", "--force=true", "--force=false"}, want: "--force may be specified only once"},
{name: "profile", args: []string{"session", "--profile", "production", "--profile=testing"}, want: "--profile may be specified only once"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -68,6 +69,32 @@ func TestBoundedRunParsingRejectsDuplicateSingletons(t *testing.T) {
} }
} }
func TestBoundedRunParsingRetainsExplicitProfilePresence(t *testing.T) {
withoutProfile, err := parseBoundedRunRequest("run", []string{"session"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if got := withoutProfile.Config.sessionOptions().Profile; got != nil {
t.Fatalf("omitted profile = %#v, want nil", got)
}
withProfile, err := parseBoundedRunRequest("run", []string{"session", "--profile", "testing"}, io.Discard)
if err != nil {
t.Fatal(err)
}
if got := withProfile.Config.sessionOptions().Profile; got == nil || *got != "testing" {
t.Fatalf("explicit profile = %#v, want testing", got)
}
explicitEmpty, err := parseBoundedRunRequest("run", []string{"session", "--profile", ""}, io.Discard)
if err != nil {
t.Fatal(err)
}
if got := explicitEmpty.Config.sessionOptions().Profile; got == nil || *got != "" {
t.Fatalf("explicit empty profile = %#v, want non-nil empty", got)
}
}
func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) { func TestBoundedRunParsingUsesSharedRangeValidation(t *testing.T) {
args := []string{"session", "--from", "publish", "--through", "render"} args := []string{"session", "--from", "publish", "--through", "render"}
runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard) runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
package app
import (
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func effectiveConfigProvenance(cfg *config.Config) *manifest.EffectiveConfigProvenance {
if cfg == nil || cfg.Pipeline == nil {
return nil
}
provenance := &manifest.EffectiveConfigProvenance{
EffectiveConfigDigest: config.EffectivePipelineDigest(cfg.Pipeline),
}
if selected, ok := config.SelectedPipelineProfile(cfg.Pipeline); ok {
provenance.SelectedProfile = &manifest.SelectedProfileProvenance{
Name: selected.Name,
Source: selected.Source,
}
}
if provenance.SelectedProfile == nil && provenance.EffectiveConfigDigest == "" {
return nil
}
return provenance
}
func applyEffectiveConfigProvenance(m *manifest.Manifest, cfg *config.Config) {
if m != nil {
m.EffectiveConfig = effectiveConfigProvenance(cfg)
}
}
func applyEffectiveConfigProvenanceToRun(m *manifest.RunManifest, cfg *config.Config) {
if m != nil {
m.EffectiveConfig = effectiveConfigProvenance(cfg)
}
}
func effectiveConfigSummary(cfg *config.Config) string {
provenance := effectiveConfigProvenance(cfg)
if provenance == nil {
return "profile=none digest=unavailable"
}
profile := "none"
if provenance.SelectedProfile != nil {
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
}
digest := provenance.EffectiveConfigDigest
if digest == "" {
digest = "unavailable"
}
return fmt.Sprintf("profile=%s digest=%s", profile, digest)
}
func persistedEffectiveConfigSummary(provenance *manifest.EffectiveConfigProvenance) string {
if provenance == nil {
return "profile=none digest=unavailable"
}
profile := "none"
if provenance.SelectedProfile != nil {
profile = provenance.SelectedProfile.Name + " (" + provenance.SelectedProfile.Source + ")"
}
digest := provenance.EffectiveConfigDigest
if digest == "" {
digest = "unavailable"
}
return fmt.Sprintf("profile=%s digest=%s", profile, digest)
}

View File

@@ -20,6 +20,7 @@ type commonConfigFlags struct {
sessionPath string sessionPath string
sessionID string sessionID string
previousSessionID string previousSessionID string
profile singletonStringFlag
} }
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) { func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
@@ -29,12 +30,15 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml") fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier") fs.StringVar(&flags.sessionID, "session-id", "", "session identifier")
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier") fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
flags.profile.name = "profile"
fs.Var(&flags.profile, "profile", "named pipeline profile")
} }
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions { func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
return config.SessionLoadOptions{ return config.SessionLoadOptions{
SessionID: f.sessionID, SessionID: f.sessionID,
PreviousSessionID: f.previousSessionID, PreviousSessionID: f.previousSessionID,
Profile: f.profile.pointer(),
} }
} }

View File

@@ -22,6 +22,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("session init", flag.ContinueOnError) fs := flag.NewFlagSet("session init", flag.ContinueOnError)
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
var profile singletonStringFlag
var remote, force bool var remote, force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "campaign ID") fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
@@ -33,6 +34,8 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&output, "output", "", "local output session.yml path") fs.StringVar(&output, "output", "", "local output session.yml path")
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix") fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory") fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
profile.name = "profile"
fs.Var(&profile, "profile", "named pipeline profile")
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix") fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
fs.BoolVar(&force, "force", false, "overwrite existing target") fs.BoolVar(&force, "force", false, "overwrite existing target")
if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil { if err := parseSessionAwareFlags("session init", fs, args, &sessionID); err != nil {
@@ -48,7 +51,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive") return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
} }
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath) base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Profile: profile.pointer()})
if err != nil { if err != nil {
return fmt.Errorf("session init: %w", err) return fmt.Errorf("session init: %w", err)
} }

View File

@@ -40,6 +40,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign) fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign)
fmt.Fprintf(out, "Workspace: %s\n", paths.Root) fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg)) fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
fmt.Fprintf(out, "Configuration (current): %s\n", effectiveConfigSummary(cfg))
writeStatusStableInputs(out, inspectStableInputs(cfg)) writeStatusStableInputs(out, inspectStableInputs(cfg))
writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg)) writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg))
@@ -51,6 +52,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error {
} else { } else {
localManifest = m localManifest = m
fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath) fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath)
fmt.Fprintf(out, "Configuration (last persisted): %s\n", persistedEffectiveConfigSummary(m.EffectiveConfig))
writeStageStatuses(out, m) writeStageStatuses(out, m)
} }

View File

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

View File

@@ -0,0 +1,72 @@
package app
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestRunProfileSelectionFlowsThroughSharedLoader(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
enableCommandTestProfiles(t, pipelinePath)
original := executeStagesFn
t.Cleanup(func() { executeStagesFn = original })
var language string
executeStagesFn = func(_ context.Context, cfg *config.Config, _ BoundedPlan, _ RunOptions) (*RunSummary, error) {
language = cfg.Pipeline.WhisperX.Language
return &RunSummary{SessionID: cfg.Session.SessionID, ManifestPath: "manifest.json"}, nil
}
if err := Run(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", "testing",
}, io.Discard); err != nil {
t.Fatal(err)
}
if language != "fr" {
t.Fatalf("explicit profile language = %q, want fr", language)
}
if err := Run(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
}, io.Discard); err != nil {
t.Fatal(err)
}
if language != "en" {
t.Fatalf("default profile language = %q, want en", language)
}
for _, profile := range []string{"unknown", ""} {
err := Run(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath, "--profile", profile,
}, io.Discard)
if err == nil || !strings.Contains(err.Error(), "profile") {
t.Fatalf("profile %q error = %v, want selection rejection", profile, err)
}
}
}
func enableCommandTestProfiles(t *testing.T, pipelinePath string) {
t.Helper()
data, err := os.ReadFile(pipelinePath)
if err != nil {
t.Fatal(err)
}
composition := "composition:\n default_profile: production\n profiles:\n production:\n overlay: production.yml\n testing:\n overlay: testing.yml\n"
if err := os.WriteFile(pipelinePath, append([]byte(composition), data...), 0o644); err != nil {
t.Fatal(err)
}
dir := filepath.Dir(pipelinePath)
if err := os.WriteFile(filepath.Join(dir, "production.yml"), []byte("whisperx:\n language: en\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "testing.yml"), []byte("whisperx:\n language: fr\n"), 0o644); err != nil {
t.Fatal(err)
}
}

View File

@@ -24,6 +24,7 @@ func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
"--artifacts=player_handout", "--artifacts=player_handout",
"--config", "pipeline.yml", "--config", "pipeline.yml",
"--campaign", "sample-campaign", "--campaign", "sample-campaign",
"--profile", "testing",
}, io.Discard, io.Discard) }, io.Discard, io.Discard)
if code != 0 { if code != 0 {
t.Fatalf("Execute() code = %d, want 0", code) t.Fatalf("Execute() code = %d, want 0", code)
@@ -34,6 +35,7 @@ func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) {
"--artifacts=player_handout", "--artifacts=player_handout",
"--config", "pipeline.yml", "--config", "pipeline.yml",
"--campaign", "sample-campaign", "--campaign", "sample-campaign",
"--profile", "testing",
} }
if !reflect.DeepEqual(captured, want) { if !reflect.DeepEqual(captured, want) {
t.Fatalf("forwarded args = %#v, want %#v", captured, want) t.Fatalf("forwarded args = %#v, want %#v", captured, want)

View File

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

View File

@@ -147,6 +147,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
return nil, fmt.Errorf("validate bounded run prerequisites under session lock: %w", err) return nil, fmt.Errorf("validate bounded run prerequisites under session lock: %w", err)
} }
identity.applyToSessionManifest(m) identity.applyToSessionManifest(m)
applyEffectiveConfigProvenance(m, cfg)
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil { if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err) return nil, fmt.Errorf("save manifest identity %q: %w", manifestPath, err)
} }
@@ -172,6 +173,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
return nil, fmt.Errorf("create run manifest: %w", err) return nil, fmt.Errorf("create run manifest: %w", err)
} }
identity.applyToRunManifest(runManifest, manifestPath) identity.applyToRunManifest(runManifest, manifestPath)
applyEffectiveConfigProvenanceToRun(runManifest, cfg)
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil { if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
return nil, persistTerminalFailure( return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest, ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,

View File

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

View File

@@ -79,24 +79,39 @@ type PostPublishCleanup struct {
Targets []CleanupTarget `json:"targets"` Targets []CleanupTarget `json:"targets"`
} }
// EffectiveConfigProvenance records bounded non-secret configuration identity
// for one resolved invocation.
type EffectiveConfigProvenance struct {
SelectedProfile *SelectedProfileProvenance `json:"selected_profile,omitempty"`
EffectiveConfigDigest string `json:"effective_config_digest,omitempty"`
}
// SelectedProfileProvenance identifies an explicitly or default-selected
// pipeline profile without recording profile content.
type SelectedProfileProvenance struct {
Name string `json:"name"`
Source string `json:"source"`
}
// Manifest is the durable run-state record for a session execution. // Manifest is the durable run-state record for a session execution.
type Manifest struct { type Manifest struct {
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
Campaign string `json:"campaign,omitempty"` Campaign string `json:"campaign,omitempty"`
RunID string `json:"run_id,omitempty"` RunID string `json:"run_id,omitempty"`
LocalWorkDir string `json:"local_workdir,omitempty"` LocalWorkDir string `json:"local_workdir,omitempty"`
LocalSpoolDir string `json:"local_spool_dir,omitempty"` LocalSpoolDir string `json:"local_spool_dir,omitempty"`
S3Bucket string `json:"s3_bucket,omitempty"` S3Bucket string `json:"s3_bucket,omitempty"`
S3SessionPrefix string `json:"s3_session_prefix,omitempty"` S3SessionPrefix string `json:"s3_session_prefix,omitempty"`
S3RunPrefix string `json:"s3_run_prefix,omitempty"` S3RunPrefix string `json:"s3_run_prefix,omitempty"`
PipelineVersion string `json:"pipeline_version,omitempty"` PipelineVersion string `json:"pipeline_version,omitempty"`
CreatedAt time.Time `json:"created_at"` EffectiveConfig *EffectiveConfigProvenance `json:"effective_config,omitempty"`
UpdatedAt time.Time `json:"updated_at"` CreatedAt time.Time `json:"created_at"`
LastError *ErrorRecord `json:"last_error,omitempty"` UpdatedAt time.Time `json:"updated_at"`
Inputs []InputRecord `json:"inputs,omitempty"` LastError *ErrorRecord `json:"last_error,omitempty"`
Artifacts []ArtifactRecord `json:"artifacts,omitempty"` Inputs []InputRecord `json:"inputs,omitempty"`
Stages map[string]*StageRecord `json:"stages"` Artifacts []ArtifactRecord `json:"artifacts,omitempty"`
PostPublishCleanup *PostPublishCleanup `json:"post_publish_cleanup,omitempty"` Stages map[string]*StageRecord `json:"stages"`
PostPublishCleanup *PostPublishCleanup `json:"post_publish_cleanup,omitempty"`
} }
// New constructs a new manifest with deterministic timestamps. // New constructs a new manifest with deterministic timestamps.

View File

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

View File

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