diff --git a/docs/cli.md b/docs/cli.md index 704ac7b..61efcfb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -310,6 +310,12 @@ and precedence. ## `--artifacts` Selection Rules +An artifact-family key selects all of its concrete character members. A +concrete generated key selects only that member; mixed family and concrete +selection is deduplicated and executed as concrete keys. The resulting plan +and command output identify both the concrete key and, where applicable, its +family and character ID. + - accepted on `run`, `session plan`, `run-stage`, `analyze`, and `publish`; - repeatable and comma-separated values are combined, surrounding whitespace is removed, and duplicate names are collapsed; diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index fceaf1c..3e2a340 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -60,6 +60,10 @@ exact named configured definitions become the effective set for that invocation, regardless of their `enabled` value. The effective-set resolver itself does not expand dependencies; the analyze work planner closes those targets over their configured prerequisite graph. Availability is separate from executability. +Configuration may normalize a family selection into its concrete generated +members before this resolver runs. The effective set retains optional family +and character origin metadata, but its keys, catalog sources, and runtime +lookups remain concrete configured-artifact identities. Configured outputs, including non-executable prerequisites, become available only when the versioned analyze state identifies a current result whose source, contract, canonical configured path, size, and checksum match a confined diff --git a/docs/internal/manifest.md b/docs/internal/manifest.md index bd19428..688f1e4 100644 --- a/docs/internal/manifest.md +++ b/docs/internal/manifest.md @@ -66,6 +66,11 @@ checksum, and positive byte size. Non-current records cannot carry an output, so an older file is not advertised through stale, missing, failed, or unselected state. +Family-produced records additionally retain optional `family` and +`character_id` provenance supplied by configuration resolution. These fields +do not replace the concrete configured key or infer family membership from a +name, so older records without them remain valid. + The session-stage collection is the reconciled authority across invocations. The corresponding collection on an invocation's `analyze` stage record is an audit of only the artifacts evaluated or attempted by that run. These records diff --git a/docs/operations.md b/docs/operations.md index b5b9647..812c791 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -201,6 +201,11 @@ canonical file into place or editing the manifest. See ## Artifact Selection +For a configured artifact family, selecting its family key expands to every +concrete character artifact. Select a concrete generated key to operate on one +member only. Manifests and plan output retain the concrete key as the durable +identity and include the family and character ID as optional provenance. + `--artifacts` can be used on `run`, `session plan`, `run-stage`, `analyze`, and `publish`. For a bounded run or plan, the selected range must contain `analyze` or `publish`. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 34eddfd..d17c9f9 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -858,7 +858,7 @@ family-specific dependency syntax before runtime validation. ## Stage 15 — Family Selection, Origin Reporting, And Reconciliation -**Status: Pending** +**Status: Completed** ### Goal diff --git a/internal/app/analyze_artifacts.go b/internal/app/analyze_artifacts.go index eb6b1e2..71ff8bd 100644 --- a/internal/app/analyze_artifacts.go +++ b/internal/app/analyze_artifacts.go @@ -60,10 +60,14 @@ func resolveEffectiveArtifacts(cfg *config.Config, selected []string) (artifacts return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires pipeline.scriptorium.artifacts to be configured") } configured := artifacts.ConfiguredArtifactDefinitions(cfg.Pipeline.Scriptorium.Artifacts) - if len(selected) > 0 && len(configured) == 0 { + normalized, err := normalizeArtifactSelection(cfg, selected) + if err != nil { + return artifacts.EffectiveArtifactSet{}, err + } + if len(normalized) > 0 && len(configured) == 0 { return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires at least one configured artifact in pipeline.scriptorium.artifacts") } - effective, err := artifacts.ResolveEffectiveArtifactSet(configured, selected) + effective, err := artifacts.ResolveEffectiveArtifactSet(configured, normalized) if err != nil { if strings.Contains(err.Error(), "is not configured") { return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts includes unknown artifact %q", selectedArtifactName(err)) @@ -73,7 +77,47 @@ func resolveEffectiveArtifacts(cfg *config.Config, selected []string) (artifacts if err := validateEffectiveArtifactConfiguration(cfg.Pipeline.Scriptorium.Artifacts, effective); err != nil { return artifacts.EffectiveArtifactSet{}, err } - return effective, nil + return effective.WithOrigins(effectiveArtifactOrigins(cfg.Pipeline)), nil +} + +func normalizeArtifactSelection(cfg *config.Config, selected []string) ([]string, error) { + if len(selected) == 0 { + return nil, nil + } + configured := cfg.Pipeline.Scriptorium.Artifacts + families := config.ArtifactFamilies(cfg.Pipeline).Families + set := make(map[string]struct{}, len(selected)) + for _, raw := range selected { + key := strings.TrimSpace(raw) + if key == "" { + return nil, fmt.Errorf("artifact names must be non-empty") + } + if family, ok := families[key]; ok { + for _, member := range family.Members { + set[member] = struct{}{} + } + continue + } + if _, ok := configured[key]; !ok { + return nil, fmt.Errorf("--artifacts includes unknown artifact %q", key) + } + set[key] = struct{}{} + } + normalized := make([]string, 0, len(set)) + for key := range set { + normalized = append(normalized, key) + } + sort.Strings(normalized) + return normalized, nil +} + +func effectiveArtifactOrigins(pipeline *config.PipelineConfig) map[string]artifacts.EffectiveArtifactOrigin { + catalog := config.ArtifactFamilies(pipeline) + origins := make(map[string]artifacts.EffectiveArtifactOrigin, len(catalog.Members)) + for key, member := range catalog.Members { + origins[key] = artifacts.EffectiveArtifactOrigin{Family: member.Family, CharacterID: member.CharacterID} + } + return origins } func selectedArtifactName(err error) string { diff --git a/internal/app/analyze_artifacts_test.go b/internal/app/analyze_artifacts_test.go index e112064..4a658d7 100644 --- a/internal/app/analyze_artifacts_test.go +++ b/internal/app/analyze_artifacts_test.go @@ -1,11 +1,71 @@ package app import ( + "os" + "path/filepath" + "reflect" + "strings" "testing" "gitea.maximumdirect.net/eric/narratio/internal/config" ) +func TestResolveEffectiveArtifactsExpandsFamilySelections(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path + } + pipeline := write("pipeline.yml", `workspace: {root: /tmp/narratio-work} +whisperx: {transcribe_url: https://example.test/transcribe} +notification: {mode: noop} +scriptorium: + artifact_families: + character_meta: + enabled: false + for_each: party.characters + prompt_id: dnd.character_meta + output_path_pattern: artifacts/characters/{character_id}/meta.md +`) + campaign := write("campaign.yml", `campaign_id: campaign +inputs: {speakers_file: speakers.yml, autocorrect_file: autocorrect.yml, glossary_file: glossary.yml, party_file: party.yml} +`) + session := write("session.yml", `session_id: session +campaign: campaign +inputs: {audio_dir: audio} +`) + write("party.yml", `schema_version: narratio.party.v1 +characters: + zeta: {player: {name: Z}, character: {name: Zeta, classes: [{name: wizard}]}} + alpha: {player: {name: A}, character: {name: Alpha, classes: [{name: ranger}]}} +`) + cfg, err := config.LoadWithSessionOptions(pipeline, campaign, session, config.SessionLoadOptions{}) + if err != nil { + t.Fatal(err) + } + effective, err := resolveEffectiveArtifacts(cfg, []string{"character_meta", "character_meta_alpha"}) + if err != nil { + t.Fatal(err) + } + if got, want := effective.Keys(), []string{"character_meta_alpha", "character_meta_zeta"}; !reflect.DeepEqual(got, want) { + t.Fatalf("keys = %#v, want %#v", got, want) + } + if origin, ok := effective.Origin("character_meta_alpha"); !ok || origin.Family != "character_meta" || origin.CharacterID != "alpha" { + t.Fatalf("origin = %#v, %t", origin, ok) + } + if _, err := resolveEffectiveArtifacts(cfg, []string{"unknown"}); err == nil || !strings.Contains(err.Error(), "unknown artifact") { + t.Fatalf("unknown selection error = %v", err) + } + if defaultEffective, err := resolveEffectiveArtifacts(cfg, nil); err != nil { + t.Fatal(err) + } else if len(defaultEffective.Keys()) != 0 { + t.Fatal("default selection should omit disabled family members") + } +} + func TestArtifactSelectionFlagNormalize(t *testing.T) { tests := []struct { name string diff --git a/internal/app/operator_artifact_rendering.go b/internal/app/operator_artifact_rendering.go index 8d1104b..47a4bbd 100644 --- a/internal/app/operator_artifact_rendering.go +++ b/internal/app/operator_artifact_rendering.go @@ -43,12 +43,13 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art } writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet) fmt.Fprintln(out, "Configured:") + origins := config.ArtifactFamilies(cfg.Pipeline).Members for _, entry := range catalog.ListConfigured() { state := "unavailable" if entry.Available { state = "available" } - writeExtractionArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet) + writeConfiguredArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet, origins) } fmt.Fprintln(out, "Extraction:") for _, entry := range catalog.ListExtraction() { @@ -70,6 +71,22 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art } } +func writeConfiguredArtifactLine(out io.Writer, source, state, provenance string, lockSet map[string]config.PublishLockRule, origins map[string]config.ArtifactFamilyMemberOrigin) { + parts := []string{source, "planned", state} + if key, ok := artifactpolicy.ParseConfiguredSource(source); ok { + if origin, family := origins[key]; family { + parts = append(parts, "family="+origin.Family, "character_id="+origin.CharacterID) + } + } + if strings.TrimSpace(provenance) != "" { + parts = append(parts, "provenance="+strings.TrimSpace(provenance)) + } + if _, ok := lockSet[source]; ok { + parts = append(parts, "locked") + } + fmt.Fprintf(out, "- %s\n", strings.Join(parts, " ")) +} + func writeExtractionArtifactLine(out io.Writer, source, state, provenance string, lockSet map[string]config.PublishLockRule) { parts := []string{source, "planned", state} if strings.TrimSpace(provenance) != "" { diff --git a/internal/app/plan.go b/internal/app/plan.go index 876a39a..598b5a9 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -35,7 +35,11 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { if err := config.Validate(cfg); err != nil { return fmt.Errorf("plan: %w", err) } - effective, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts) + selectedArtifacts, err := normalizeArtifactSelection(cfg, request.SelectedArtifacts) + if err != nil { + return fmt.Errorf("plan: %w", err) + } + effective, err := resolveEffectiveArtifacts(cfg, selectedArtifacts) if err != nil { return fmt.Errorf("plan: %w", err) } @@ -55,7 +59,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error { stages := request.Plan.Stages() stageEnv := &stage.Env{ - Config: cfg, SelectedArtifactKeys: append([]string(nil), request.SelectedArtifacts...), + Config: cfg, SelectedArtifactKeys: append([]string(nil), selectedArtifacts...), EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force, } @@ -226,7 +230,11 @@ func planArtifactList(values []stage.AnalyzeResumeArtifact) string { if value.Forced { detail += ":forced" } - parts = append(parts, fmt.Sprintf("%s(%s)", value.Key, detail)) + identity := value.Key + if value.Family != "" { + identity += fmt.Sprintf("[family=%s character_id=%s]", value.Family, value.CharacterID) + } + parts = append(parts, fmt.Sprintf("%s(%s)", identity, detail)) } return strings.Join(parts, ", ") } diff --git a/internal/app/run.go b/internal/app/run.go index 0775ef5..ee05977 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -29,13 +29,17 @@ func Run(ctx context.Context, args []string, out io.Writer) error { if err := config.Validate(cfg); err != nil { return fmt.Errorf("run: %w", err) } - effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts) + selectedArtifacts, err := normalizeArtifactSelection(cfg, request.SelectedArtifacts) + if err != nil { + return fmt.Errorf("run: %w", err) + } + effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, selectedArtifacts) if err != nil { return fmt.Errorf("run: %w", err) } summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{ Force: request.Force, - SelectedArtifacts: request.SelectedArtifacts, + SelectedArtifacts: selectedArtifacts, EffectiveArtifacts: effectiveArtifacts, }) if err != nil { diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index 378b8de..b9897d3 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -216,13 +216,17 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum if err := config.Validate(cfg); err != nil { return nil, fmt.Errorf("%s: %w", req.CommandName, err) } - effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, req.SelectedArtifacts) + selectedArtifacts, err := normalizeArtifactSelection(cfg, req.SelectedArtifacts) + if err != nil { + return nil, fmt.Errorf("%s: %w", req.CommandName, err) + } + effectiveArtifacts, err := resolveEffectiveArtifacts(cfg, selectedArtifacts) if err != nil { return nil, fmt.Errorf("%s: %w", req.CommandName, err) } summary, err := executeStagesFn(ctx, cfg, plan, RunOptions{ Force: req.Force, - SelectedArtifacts: req.SelectedArtifacts, + SelectedArtifacts: selectedArtifacts, EffectiveArtifacts: effectiveArtifacts, }) if err != nil { diff --git a/internal/artifacts/effective_set.go b/internal/artifacts/effective_set.go index f710f44..88ab905 100644 --- a/internal/artifacts/effective_set.go +++ b/internal/artifacts/effective_set.go @@ -13,9 +13,17 @@ import ( // analyze invocation will execute. type EffectiveArtifactSet struct { keys []string + origins map[string]EffectiveArtifactOrigin resolved bool } +// EffectiveArtifactOrigin identifies a concrete artifact produced by a +// resolved family declaration. +type EffectiveArtifactOrigin struct { + Family string + CharacterID string +} + // ResolveEffectiveArtifactSet applies an explicit artifact selection when one // is supplied; otherwise it selects the configured enabled artifacts. func ResolveEffectiveArtifactSet( @@ -88,6 +96,27 @@ func (s EffectiveArtifactSet) Resolved() bool { return s.resolved } +// WithOrigins attaches optional resolution provenance without changing the +// selected concrete keys or lookup semantics. +func (s EffectiveArtifactSet) WithOrigins(origins map[string]EffectiveArtifactOrigin) EffectiveArtifactSet { + if len(origins) == 0 { + return s + } + s.origins = make(map[string]EffectiveArtifactOrigin, len(origins)) + for key, origin := range origins { + if s.Includes(key) { + s.origins[key] = origin + } + } + return s +} + +// Origin reports optional family provenance for a concrete artifact key. +func (s EffectiveArtifactSet) Origin(key string) (EffectiveArtifactOrigin, bool) { + origin, ok := s.origins[strings.TrimSpace(key)] + return origin, ok +} + func newEffectiveArtifactSet(set map[string]struct{}) EffectiveArtifactSet { keys := make([]string, 0, len(set)) for key := range set { diff --git a/internal/manifest/analyze_state.go b/internal/manifest/analyze_state.go index db8aef6..b1fadbc 100644 --- a/internal/manifest/analyze_state.go +++ b/internal/manifest/analyze_state.go @@ -53,6 +53,8 @@ type AnalyzeArtifactRecord struct { Status AnalyzeArtifactStatus `json:"status"` FingerprintVersion int `json:"fingerprint_version,omitempty"` Fingerprint string `json:"fingerprint,omitempty"` + Family string `json:"family,omitempty"` + CharacterID string `json:"character_id,omitempty"` Dependencies []string `json:"dependencies,omitempty"` Output *ArtifactRecord `json:"output,omitempty"` OutputSize int64 `json:"output_size,omitempty"` @@ -134,6 +136,14 @@ func validateAnalyzeArtifactRecord(mapKey string, record AnalyzeArtifactRecord) if err := validateAnalyzeDependencies(record.Dependencies); err != nil { return err } + if (record.Family == "") != (record.CharacterID == "") { + return fmt.Errorf("family and character_id must be present together") + } + if record.Family != "" { + if !artifactpolicy.IsConfiguredKey(record.Family) || !artifactpolicy.IsConfiguredKey(record.CharacterID) { + return fmt.Errorf("family and character_id must match ^[a-z][a-z0-9_]*$") + } + } if err := validateAnalyzeFingerprint(record.FingerprintVersion, record.Fingerprint, record.Status == AnalyzeArtifactCurrent); err != nil { return err } diff --git a/internal/manifest/analyze_state_test.go b/internal/manifest/analyze_state_test.go index 8117b25..992d3a1 100644 --- a/internal/manifest/analyze_state_test.go +++ b/internal/manifest/analyze_state_test.go @@ -40,6 +40,8 @@ func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T records["session_recap"] = func() AnalyzeArtifactRecord { record := records["session_recap"] record.Dependencies = []string{"quest_log", "gm_notes"} + record.Family = "character_meta" + record.CharacterID = "arannis" return record }() @@ -74,6 +76,9 @@ func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T if got := analyze.AnalyzeArtifacts["session_recap"].Dependencies; !reflect.DeepEqual(got, []string{"gm_notes", "quest_log"}) { t.Fatalf("canonical dependencies = %#v", got) } + if got := analyze.AnalyzeArtifacts["session_recap"]; got.Family != "character_meta" || got.CharacterID != "arannis" { + t.Fatalf("family provenance = %#v", got) + } data, err := os.ReadFile(path) if err != nil { diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 389cdb6..81e2ef7 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -205,6 +205,10 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S logs = append(logs, artifactResult.Logs...) generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...) + if origin, ok := analyzeArtifactOrigin(execution, plan.Name); ok { + artifactResult.Metadata["family"] = origin.Family + artifactResult.Metadata["character_id"] = origin.CharacterID + } artifactMetadata = append(artifactMetadata, artifactResult.Metadata) for _, reused := range artifactResult.ReusedArtifacts { sourceID, _ := reused["source_id"].(string) @@ -314,6 +318,10 @@ func failedAnalyzeResult( UpdatedAt: time.Now().UTC(), Error: NonResumable(cause.Error()).Reason, } + if origin, ok := analyzeArtifactOrigin(execution, item.Key); ok { + record.Family = origin.Family + record.CharacterID = origin.CharacterID + } if fingerprint != "" { record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion record.Fingerprint = fingerprint @@ -332,6 +340,14 @@ func failedAnalyzeResult( }} } +func analyzeArtifactOrigin(execution analyzeExecutionContext, key string) (config.ArtifactFamilyMemberOrigin, bool) { + if execution.Env == nil || execution.Env.Config == nil || execution.Env.Config.Pipeline == nil { + return config.ArtifactFamilyMemberOrigin{}, false + } + origin, ok := config.ArtifactFamilies(execution.Env.Config.Pipeline).Members[key] + return origin, ok +} + func artifactCfgMap(execution analyzeExecutionContext) map[string]config.ScriptoriumArtifactConfig { if execution.Env == nil || execution.Env.Config == nil || execution.Env.Config.Pipeline == nil || execution.Env.Config.Pipeline.Scriptorium == nil { diff --git a/internal/stage/analyze_resume.go b/internal/stage/analyze_resume.go index e34feab..68b2cdf 100644 --- a/internal/stage/analyze_resume.go +++ b/internal/stage/analyze_resume.go @@ -6,6 +6,7 @@ import ( "strings" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) @@ -60,7 +61,7 @@ func (analyzeStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani if err != nil { return ResumeValidation{}, fmt.Errorf("analyze resume: plan configured artifacts: %w", err) } - summary := analyzeResumeSummary(plan) + summary := analyzeResumeSummary(plan, env.Config.Pipeline) if len(plan.ExecutionOrder) == 0 { return ResumeValidation{Resumable: true, Analyze: summary}, nil } @@ -71,24 +72,28 @@ func (analyzeStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani }, nil } -func analyzeResumeSummary(plan analyzeWorkPlan) *AnalyzeResumeSummary { +func analyzeResumeSummary(plan analyzeWorkPlan, pipeline *config.PipelineConfig) *AnalyzeResumeSummary { return &AnalyzeResumeSummary{ ExplicitTargets: append([]string(nil), plan.ExplicitTargets...), - PrerequisiteWork: exportAnalyzeResumeItems(plan.PrerequisiteWork), - ExecutionOrder: exportAnalyzeResumeItems(plan.ExecutionOrder), - ReusedCurrent: exportAnalyzeResumeItems(plan.ReusedCurrent), + PrerequisiteWork: exportAnalyzeResumeItems(plan.PrerequisiteWork, pipeline), + ExecutionOrder: exportAnalyzeResumeItems(plan.ExecutionOrder, pipeline), + ReusedCurrent: exportAnalyzeResumeItems(plan.ReusedCurrent, pipeline), } } -func exportAnalyzeResumeItems(items []analyzePlanItem) []AnalyzeResumeArtifact { +func exportAnalyzeResumeItems(items []analyzePlanItem, pipeline *config.PipelineConfig) []AnalyzeResumeArtifact { if len(items) == 0 { return nil } result := make([]AnalyzeResumeArtifact, 0, len(items)) for _, item := range items { - result = append(result, AnalyzeResumeArtifact{ + entry := AnalyzeResumeArtifact{ Key: item.Key, Role: string(item.Role), Reason: string(item.Reason), Forced: item.Forced, - }) + } + if origin, ok := config.ArtifactFamilies(pipeline).Members[item.Key]; ok { + entry.Family, entry.CharacterID = origin.Family, origin.CharacterID + } + result = append(result, entry) } return result } diff --git a/internal/stage/stage.go b/internal/stage/stage.go index 56aefa0..bb765ba 100644 --- a/internal/stage/stage.go +++ b/internal/stage/stage.go @@ -67,10 +67,12 @@ type AnalyzeResumeSummary struct { // AnalyzeResumeArtifact is one deterministic artifact-level plan entry. type AnalyzeResumeArtifact struct { - Key string - Role string - Reason string - Forced bool + Key string + Family string + CharacterID string + Role string + Reason string + Forced bool } // Normalized returns a result with a bounded reason and no reason on success.