From 4e991fa21dd11a879ddb92a0a8c13bf7d53ee009 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 30 Aug 2026 13:41:26 +0000 Subject: [PATCH] Expose pipeline profile selection and provenance --- docs/cli.md | 8 +++ docs/internal/manifest.md | 10 ++++ docs/operations.md | 5 ++ docs/roadmap/implementation.md | 2 +- internal/app/bounded_run.go | 8 +++ internal/app/bounded_run_test.go | 27 +++++++++ internal/app/clean.go | 2 +- internal/app/config_loader.go | 12 ++-- internal/app/config_loader_test.go | 4 +- internal/app/config_provenance.go | 70 ++++++++++++++++++++++ internal/app/operator_helpers.go | 4 ++ internal/app/operator_session_init.go | 5 +- internal/app/operator_status.go | 2 + internal/app/plan.go | 2 +- internal/app/profile_commands_test.go | 72 +++++++++++++++++++++++ internal/app/regenerate_artifacts_test.go | 2 + internal/app/run.go | 3 +- internal/app/runner.go | 2 + internal/config/effective_digest.go | 25 ++++++++ internal/manifest/manifest.go | 47 ++++++++++----- internal/manifest/run_manifest.go | 1 + internal/manifest/store_test.go | 14 +++++ 22 files changed, 298 insertions(+), 29 deletions(-) create mode 100644 internal/app/config_provenance.go create mode 100644 internal/app/profile_commands_test.go diff --git a/docs/cli.md b/docs/cli.md index 0145117..704ac7b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -43,6 +43,7 @@ Most session-aware commands accept: - `--session ` - `--session-id ` - `--previous-session-id ` +- `--profile ` Rules: @@ -51,6 +52,10 @@ Rules: - if both positional `` 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 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. - notification delivery is currently limited to the configured `noop` mode; see the [configuration reference](./config.md#notifications). @@ -102,6 +107,7 @@ Behavior: `--name=value` spellings; - continues interrupted or partially completed sessions by running non-succeeded stages; - 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 `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 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 +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 artifact decisions come from the same reconciliation and work planner used by diff --git a/docs/internal/manifest.md b/docs/internal/manifest.md index 058e944..fc38ba6 100644 --- a/docs/internal/manifest.md +++ b/docs/internal/manifest.md @@ -113,6 +113,7 @@ does not turn incidental canonical bytes into manifest authority. `manifest.RunManifest` is created for each invocation and records: - invocation identity and `force` flag +- the selected profile (when any) and secret-free effective configuration digest - requested stages - per-stage action (`run` or `skip`) - per-stage status @@ -233,6 +234,15 @@ after such a private content change. Session manifest is the authoritative stage-progress ledger across invocations. Run manifest is invocation-scoped audit state. +Both manifests retain the most recently resolved invocation's bounded +configuration provenance. It identifies the selected profile name and source +(`default` or `cli`) plus the effective configuration digest, but never a raw +secret or profile content. This provenance is informational: it does not +participate in stage resume or cache decisions. A profile change therefore +invalidates only stages whose semantic configuration changed. When a private +external-tool model, module, prompt, or profile changes behind an unchanged +configured identifier, use `--force` for the affected work. + `session plan` computes the same current fingerprint and applies the same comparison and invalidation rules to a cloned manifest. It predicts the runner decision without persisting session or invocation state. The shared helper diff --git a/docs/operations.md b/docs/operations.md index 67e4c89..21f9abf 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -70,6 +70,11 @@ narratio run 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 Canonical stage order: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index ac60cb9..9fb748e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -547,7 +547,7 @@ command-specific resolution before CLI profile selection is exposed. ## Stage 9 — Profile CLI Plumbing, Reporting, And Manifest Provenance -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/app/bounded_run.go b/internal/app/bounded_run.go index 65d98b7..df03e02 100644 --- a/internal/app/bounded_run.go +++ b/internal/app/bounded_run.go @@ -31,6 +31,14 @@ func (f *singletonStringFlag) Set(value string) error { return nil } +func (f singletonStringFlag) pointer() *string { + if !f.set { + return nil + } + value := f.value + return &value +} + type singletonBoolFlag struct { name string value bool diff --git a/internal/app/bounded_run_test.go b/internal/app/bounded_run_test.go index a4a203a..1bd58e3 100644 --- a/internal/app/bounded_run_test.go +++ b/internal/app/bounded_run_test.go @@ -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: "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"}, } for _, test := range tests { 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) { args := []string{"session", "--from", "publish", "--through", "render"} runRequest, runErr := parseBoundedRunRequest("run", args, io.Discard) diff --git a/internal/app/clean.go b/internal/app/clean.go index 44aee68..c05c30c 100644 --- a/internal/app/clean.go +++ b/internal/app/clean.go @@ -88,7 +88,7 @@ func cleanAllLocal(flags commonConfigFlags, dryRun, clearCache bool, out io.Writ 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 := loadPipelineConfig(flags.pipelinePath) + _, pipelineCfg, err := loadPipelineConfig(flags.pipelinePath, config.PipelineLoadOptions{Profile: flags.profile.pointer()}) if err != nil { return fmt.Errorf("clean: %w", err) } diff --git a/internal/app/config_loader.go b/internal/app/config_loader.go index 3439d15..5260932 100644 --- a/internal/app/config_loader.go +++ b/internal/app/config_loader.go @@ -17,7 +17,7 @@ import ( type pipelineCampaignConfig = config.LoadedPipelineCampaign var downloadObjectToTempFn = storage.DownloadObjectToTemp -var loadPipelineConfigFn = config.LoadPipeline +var loadPipelineConfigFn = config.LoadPipelineWithOptions type commandConfig struct { 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 { return nil, err } @@ -137,8 +137,8 @@ func loadCommandConfig(ctx context.Context, pipelineFlag, campaignFlag, campaign return loaded, nil } -func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string) (*pipelineCampaignConfig, error) { - loadedPipelinePath, pipelineCfg, err := loadPipelineConfig(pipelineFlag) +func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag string, pipelineOpts config.PipelineLoadOptions) (*pipelineCampaignConfig, error) { + loadedPipelinePath, pipelineCfg, err := loadPipelineConfig(pipelineFlag, pipelineOpts) if err != nil { return nil, err } @@ -163,12 +163,12 @@ func loadPipelineCampaignConfig(pipelineFlag, campaignFlag, campaignFileFlag str }, nil } -func loadPipelineConfig(pipelineFlag string) (string, *config.PipelineConfig, error) { +func loadPipelineConfig(pipelineFlag string, opts config.PipelineLoadOptions) (string, *config.PipelineConfig, error) { resolvedPipelinePath, err := resolvePipelineConfigPath(pipelineFlag) if err != nil { return "", nil, err } - pipelineCfg, err := loadPipelineConfigFn(resolvedPipelinePath) + pipelineCfg, err := loadPipelineConfigFn(resolvedPipelinePath, opts) if err != nil { return "", nil, err } diff --git a/internal/app/config_loader_test.go b/internal/app/config_loader_test.go index 33c34d0..eb6a4ab 100644 --- a/internal/app/config_loader_test.go +++ b/internal/app/config_loader_test.go @@ -15,8 +15,8 @@ func TestLoadCommandConfigRetainsInitiallyLoadedPipeline(t *testing.T) { originalLoader := loadPipelineConfigFn loadCalls := 0 - loadPipelineConfigFn = func(path string) (*config.PipelineConfig, error) { - loaded, err := originalLoader(path) + loadPipelineConfigFn = func(path string, opts config.PipelineLoadOptions) (*config.PipelineConfig, error) { + loaded, err := originalLoader(path, opts) if err != nil { return nil, err } diff --git a/internal/app/config_provenance.go b/internal/app/config_provenance.go new file mode 100644 index 0000000..159d9ef --- /dev/null +++ b/internal/app/config_provenance.go @@ -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) +} diff --git a/internal/app/operator_helpers.go b/internal/app/operator_helpers.go index 50b0b70..4143083 100644 --- a/internal/app/operator_helpers.go +++ b/internal/app/operator_helpers.go @@ -20,6 +20,7 @@ type commonConfigFlags struct { sessionPath string sessionID string previousSessionID string + profile singletonStringFlag } 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.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") } func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions { return config.SessionLoadOptions{ SessionID: f.sessionID, PreviousSessionID: f.previousSessionID, + Profile: f.profile.pointer(), } } diff --git a/internal/app/operator_session_init.go b/internal/app/operator_session_init.go index b0b3143..30cb5cb 100644 --- a/internal/app/operator_session_init.go +++ b/internal/app/operator_session_init.go @@ -22,6 +22,7 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error { 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 remote, force bool fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)") 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(&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") fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix") fs.BoolVar(&force, "force", false, "overwrite existing target") 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") } - base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath) + base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath, config.PipelineLoadOptions{Profile: profile.pointer()}) if err != nil { return fmt.Errorf("session init: %w", err) } diff --git a/internal/app/operator_status.go b/internal/app/operator_status.go index eb34003..99af7a1 100644 --- a/internal/app/operator_status.go +++ b/internal/app/operator_status.go @@ -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, "Workspace: %s\n", paths.Root) fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg)) + fmt.Fprintf(out, "Configuration (current): %s\n", effectiveConfigSummary(cfg)) writeStatusStableInputs(out, inspectStableInputs(cfg)) writeStatusLocalAudio(out, inspectLocalAudioPresence(cfg)) @@ -51,6 +52,7 @@ func Status(ctx context.Context, args []string, out io.Writer) error { } else { localManifest = m fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath) + fmt.Fprintf(out, "Configuration (last persisted): %s\n", persistedEffectiveConfigSummary(m.EffectiveConfig)) writeStageStatuses(out, m) } diff --git a/internal/app/plan.go b/internal/app/plan.go index 12bb4f8..876a39a 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -61,7 +61,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { runCount := 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 } for _, selectedStage := range stages { diff --git a/internal/app/profile_commands_test.go b/internal/app/profile_commands_test.go new file mode 100644 index 0000000..fad3dfe --- /dev/null +++ b/internal/app/profile_commands_test.go @@ -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) + } +} diff --git a/internal/app/regenerate_artifacts_test.go b/internal/app/regenerate_artifacts_test.go index 8fe8654..0b5f02d 100644 --- a/internal/app/regenerate_artifacts_test.go +++ b/internal/app/regenerate_artifacts_test.go @@ -24,6 +24,7 @@ func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) { "--artifacts=player_handout", "--config", "pipeline.yml", "--campaign", "sample-campaign", + "--profile", "testing", }, io.Discard, io.Discard) if code != 0 { t.Fatalf("Execute() code = %d, want 0", code) @@ -34,6 +35,7 @@ func TestRegenerateArtifactsForwardsExactCanonicalRunArguments(t *testing.T) { "--artifacts=player_handout", "--config", "pipeline.yml", "--campaign", "sample-campaign", + "--profile", "testing", } if !reflect.DeepEqual(captured, want) { t.Fatalf("forwarded args = %#v, want %#v", captured, want) diff --git a/internal/app/run.go b/internal/app/run.go index 7f895b0..0775ef5 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -44,11 +44,12 @@ func Run(ctx context.Context, args []string, out io.Writer) error { _, err = fmt.Fprintf( 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, len(summary.Executed), len(summary.Skipped), summary.ManifestPath, + effectiveConfigSummary(cfg), ) return err } diff --git a/internal/app/runner.go b/internal/app/runner.go index 3e9e0b3..88e9cfe 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -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) } identity.applyToSessionManifest(m) + applyEffectiveConfigProvenance(m, cfg) if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil { 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) } identity.applyToRunManifest(runManifest, manifestPath) + applyEffectiveConfigProvenanceToRun(runManifest, cfg) if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil { return nil, persistTerminalFailure( ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest, diff --git a/internal/config/effective_digest.go b/internal/config/effective_digest.go index d2038fd..e952a1f 100644 --- a/internal/config/effective_digest.go +++ b/internal/config/effective_digest.go @@ -10,6 +10,31 @@ import ( 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 { if cfg == nil || cfg.resolution == nil { return fmt.Errorf("pipeline resolution metadata is required") diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 2de8425..97fbb53 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -79,24 +79,39 @@ type PostPublishCleanup struct { 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. type Manifest struct { - SessionID string `json:"session_id"` - Campaign string `json:"campaign,omitempty"` - RunID string `json:"run_id,omitempty"` - LocalWorkDir string `json:"local_workdir,omitempty"` - LocalSpoolDir string `json:"local_spool_dir,omitempty"` - S3Bucket string `json:"s3_bucket,omitempty"` - S3SessionPrefix string `json:"s3_session_prefix,omitempty"` - S3RunPrefix string `json:"s3_run_prefix,omitempty"` - PipelineVersion string `json:"pipeline_version,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - LastError *ErrorRecord `json:"last_error,omitempty"` - Inputs []InputRecord `json:"inputs,omitempty"` - Artifacts []ArtifactRecord `json:"artifacts,omitempty"` - Stages map[string]*StageRecord `json:"stages"` - PostPublishCleanup *PostPublishCleanup `json:"post_publish_cleanup,omitempty"` + SessionID string `json:"session_id"` + Campaign string `json:"campaign,omitempty"` + RunID string `json:"run_id,omitempty"` + LocalWorkDir string `json:"local_workdir,omitempty"` + LocalSpoolDir string `json:"local_spool_dir,omitempty"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3SessionPrefix string `json:"s3_session_prefix,omitempty"` + S3RunPrefix string `json:"s3_run_prefix,omitempty"` + PipelineVersion string `json:"pipeline_version,omitempty"` + EffectiveConfig *EffectiveConfigProvenance `json:"effective_config,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastError *ErrorRecord `json:"last_error,omitempty"` + Inputs []InputRecord `json:"inputs,omitempty"` + Artifacts []ArtifactRecord `json:"artifacts,omitempty"` + Stages map[string]*StageRecord `json:"stages"` + PostPublishCleanup *PostPublishCleanup `json:"post_publish_cleanup,omitempty"` } // New constructs a new manifest with deterministic timestamps. diff --git a/internal/manifest/run_manifest.go b/internal/manifest/run_manifest.go index aff8ea2..58ae6ee 100644 --- a/internal/manifest/run_manifest.go +++ b/internal/manifest/run_manifest.go @@ -52,6 +52,7 @@ type RunManifest struct { S3Bucket string `json:"s3_bucket,omitempty"` S3SessionPrefix string `json:"s3_session_prefix,omitempty"` S3RunPrefix string `json:"s3_run_prefix,omitempty"` + EffectiveConfig *EffectiveConfigProvenance `json:"effective_config,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` StartedAt *time.Time `json:"started_at,omitempty"` diff --git a/internal/manifest/store_test.go b/internal/manifest/store_test.go index 42ad5b7..01c6036 100644 --- a/internal/manifest/store_test.go +++ b/internal/manifest/store_test.go @@ -32,6 +32,10 @@ func TestLocalStoreCreateSaveLoadRoundTrip(t *testing.T) { m.S3Bucket = "my-dnd-archive" 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"}, + EffectiveConfigDigest: "001122", + } path := filepath.Join(t.TempDir(), "manifest.json") if err := store.Save(ctx, path, m); err != nil { @@ -52,6 +56,9 @@ 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" { + t.Fatalf("effective config provenance = %#v", loaded.EffectiveConfig) + } stage, ok := loaded.Stages["prepare"] if !ok { t.Fatalf("stage prepare not found") @@ -264,6 +271,10 @@ func TestLocalStoreCreateSaveLoadRunManifestRoundTrip(t *testing.T) { t.Fatalf("CreateRun() error = %v", err) } 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.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 12, 2, 0, 0, time.UTC), []ArtifactRecord{ {Kind: "input", LocalPath: "inputs/session.yml"}, @@ -298,6 +309,9 @@ func TestLocalStoreCreateSaveLoadRunManifestRoundTrip(t *testing.T) { if 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) {