Add family artifact selection and provenance

This commit is contained in:
2026-08-30 14:27:42 +00:00
parent 257f10c9fb
commit c91599ef36
17 changed files with 248 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -858,7 +858,7 @@ family-specific dependency syntax before runtime validation.
## Stage 15 — Family Selection, Origin Reporting, And Reconciliation
**Status: Pending**
**Status: Completed**
### Goal

View File

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

View File

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

View File

@@ -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) != "" {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,6 +68,8 @@ type AnalyzeResumeSummary struct {
// AnalyzeResumeArtifact is one deterministic artifact-level plan entry.
type AnalyzeResumeArtifact struct {
Key string
Family string
CharacterID string
Role string
Reason string
Forced bool