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 ## `--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`; - accepted on `run`, `session plan`, `run-stage`, `analyze`, and `publish`;
- repeatable and comma-separated values are combined, surrounding whitespace - repeatable and comma-separated values are combined, surrounding whitespace
is removed, and duplicate names are collapsed; 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 regardless of their `enabled` value. The effective-set resolver itself does not
expand dependencies; the analyze work planner closes those targets over their expand dependencies; the analyze work planner closes those targets over their
configured prerequisite graph. Availability is separate from executability. 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 Configured outputs, including non-executable prerequisites, become available
only when the versioned analyze state identifies a current result whose source, only when the versioned analyze state identifies a current result whose source,
contract, canonical configured path, size, and checksum match a confined 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 so an older file is not advertised through stale, missing, failed, or
unselected state. 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 session-stage collection is the reconciled authority across invocations.
The corresponding collection on an invocation's `analyze` stage record is an 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 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 ## 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 `--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` `publish`. For a bounded run or plan, the selected range must contain `analyze`
or `publish`. or `publish`.

View File

@@ -858,7 +858,7 @@ family-specific dependency syntax before runtime validation.
## Stage 15 — Family Selection, Origin Reporting, And Reconciliation ## Stage 15 — Family Selection, Origin Reporting, And Reconciliation
**Status: Pending** **Status: Completed**
### Goal ### 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") return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts requires pipeline.scriptorium.artifacts to be configured")
} }
configured := artifacts.ConfiguredArtifactDefinitions(cfg.Pipeline.Scriptorium.Artifacts) 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") 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 err != nil {
if strings.Contains(err.Error(), "is not configured") { if strings.Contains(err.Error(), "is not configured") {
return artifacts.EffectiveArtifactSet{}, fmt.Errorf("--artifacts includes unknown artifact %q", selectedArtifactName(err)) 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 { if err := validateEffectiveArtifactConfiguration(cfg.Pipeline.Scriptorium.Artifacts, effective); err != nil {
return artifacts.EffectiveArtifactSet{}, err 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 { func selectedArtifactName(err error) string {

View File

@@ -1,11 +1,71 @@
package app package app
import ( import (
"os"
"path/filepath"
"reflect"
"strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/narratio/internal/config" "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) { func TestArtifactSelectionFlagNormalize(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -43,12 +43,13 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
} }
writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet) writeArtifactLine(out, artifacts.ArtifactBoundsSession, lockSet)
fmt.Fprintln(out, "Configured:") fmt.Fprintln(out, "Configured:")
origins := config.ArtifactFamilies(cfg.Pipeline).Members
for _, entry := range catalog.ListConfigured() { for _, entry := range catalog.ListConfigured() {
state := "unavailable" state := "unavailable"
if entry.Available { if entry.Available {
state = "available" state = "available"
} }
writeExtractionArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet) writeConfiguredArtifactLine(out, entry.SourceID, state, entry.Provenance, lockSet, origins)
} }
fmt.Fprintln(out, "Extraction:") fmt.Fprintln(out, "Extraction:")
for _, entry := range catalog.ListExtraction() { 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) { func writeExtractionArtifactLine(out io.Writer, source, state, provenance string, lockSet map[string]config.PublishLockRule) {
parts := []string{source, "planned", state} parts := []string{source, "planned", state}
if strings.TrimSpace(provenance) != "" { 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 { if err := config.Validate(cfg); err != nil {
return fmt.Errorf("plan: %w", err) 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 { if err != nil {
return fmt.Errorf("plan: %w", err) 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() stages := request.Plan.Stages()
stageEnv := &stage.Env{ 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, EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force,
} }
@@ -226,7 +230,11 @@ func planArtifactList(values []stage.AnalyzeResumeArtifact) string {
if value.Forced { if value.Forced {
detail += ":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, ", ") 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 { if err := config.Validate(cfg); err != nil {
return fmt.Errorf("run: %w", err) 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 { if err != nil {
return fmt.Errorf("run: %w", err) return fmt.Errorf("run: %w", err)
} }
summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{ summary, err := executeStagesFn(ctx, cfg, request.Plan, RunOptions{
Force: request.Force, Force: request.Force,
SelectedArtifacts: request.SelectedArtifacts, SelectedArtifacts: selectedArtifacts,
EffectiveArtifacts: effectiveArtifacts, EffectiveArtifacts: effectiveArtifacts,
}) })
if err != nil { if err != nil {

View File

@@ -216,13 +216,17 @@ func runSingleStageCommand(ctx context.Context, req singleStageCommand) (*RunSum
if err := config.Validate(cfg); err != nil { if err := config.Validate(cfg); err != nil {
return nil, fmt.Errorf("%s: %w", req.CommandName, err) 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 { if err != nil {
return nil, fmt.Errorf("%s: %w", req.CommandName, err) return nil, fmt.Errorf("%s: %w", req.CommandName, err)
} }
summary, err := executeStagesFn(ctx, cfg, plan, RunOptions{ summary, err := executeStagesFn(ctx, cfg, plan, RunOptions{
Force: req.Force, Force: req.Force,
SelectedArtifacts: req.SelectedArtifacts, SelectedArtifacts: selectedArtifacts,
EffectiveArtifacts: effectiveArtifacts, EffectiveArtifacts: effectiveArtifacts,
}) })
if err != nil { if err != nil {

View File

@@ -13,9 +13,17 @@ import (
// analyze invocation will execute. // analyze invocation will execute.
type EffectiveArtifactSet struct { type EffectiveArtifactSet struct {
keys []string keys []string
origins map[string]EffectiveArtifactOrigin
resolved bool 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 // ResolveEffectiveArtifactSet applies an explicit artifact selection when one
// is supplied; otherwise it selects the configured enabled artifacts. // is supplied; otherwise it selects the configured enabled artifacts.
func ResolveEffectiveArtifactSet( func ResolveEffectiveArtifactSet(
@@ -88,6 +96,27 @@ func (s EffectiveArtifactSet) Resolved() bool {
return s.resolved 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 { func newEffectiveArtifactSet(set map[string]struct{}) EffectiveArtifactSet {
keys := make([]string, 0, len(set)) keys := make([]string, 0, len(set))
for key := range set { for key := range set {

View File

@@ -53,6 +53,8 @@ type AnalyzeArtifactRecord struct {
Status AnalyzeArtifactStatus `json:"status"` Status AnalyzeArtifactStatus `json:"status"`
FingerprintVersion int `json:"fingerprint_version,omitempty"` FingerprintVersion int `json:"fingerprint_version,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"` Fingerprint string `json:"fingerprint,omitempty"`
Family string `json:"family,omitempty"`
CharacterID string `json:"character_id,omitempty"`
Dependencies []string `json:"dependencies,omitempty"` Dependencies []string `json:"dependencies,omitempty"`
Output *ArtifactRecord `json:"output,omitempty"` Output *ArtifactRecord `json:"output,omitempty"`
OutputSize int64 `json:"output_size,omitempty"` OutputSize int64 `json:"output_size,omitempty"`
@@ -134,6 +136,14 @@ func validateAnalyzeArtifactRecord(mapKey string, record AnalyzeArtifactRecord)
if err := validateAnalyzeDependencies(record.Dependencies); err != nil { if err := validateAnalyzeDependencies(record.Dependencies); err != nil {
return err 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 { if err := validateAnalyzeFingerprint(record.FingerprintVersion, record.Fingerprint, record.Status == AnalyzeArtifactCurrent); err != nil {
return err return err
} }

View File

@@ -40,6 +40,8 @@ func TestAnalyzeArtifactStateRoundTripsEveryStatusDeterministically(t *testing.T
records["session_recap"] = func() AnalyzeArtifactRecord { records["session_recap"] = func() AnalyzeArtifactRecord {
record := records["session_recap"] record := records["session_recap"]
record.Dependencies = []string{"quest_log", "gm_notes"} record.Dependencies = []string{"quest_log", "gm_notes"}
record.Family = "character_meta"
record.CharacterID = "arannis"
return record 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"}) { if got := analyze.AnalyzeArtifacts["session_recap"].Dependencies; !reflect.DeepEqual(got, []string{"gm_notes", "quest_log"}) {
t.Fatalf("canonical dependencies = %#v", got) 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) data, err := os.ReadFile(path)
if err != nil { 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...) logs = append(logs, artifactResult.Logs...)
generatedConfigs = append(generatedConfigs, artifactResult.GeneratedConfigs...) 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) artifactMetadata = append(artifactMetadata, artifactResult.Metadata)
for _, reused := range artifactResult.ReusedArtifacts { for _, reused := range artifactResult.ReusedArtifacts {
sourceID, _ := reused["source_id"].(string) sourceID, _ := reused["source_id"].(string)
@@ -314,6 +318,10 @@ func failedAnalyzeResult(
UpdatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
Error: NonResumable(cause.Error()).Reason, Error: NonResumable(cause.Error()).Reason,
} }
if origin, ok := analyzeArtifactOrigin(execution, item.Key); ok {
record.Family = origin.Family
record.CharacterID = origin.CharacterID
}
if fingerprint != "" { if fingerprint != "" {
record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion
record.Fingerprint = fingerprint 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 { func artifactCfgMap(execution analyzeExecutionContext) map[string]config.ScriptoriumArtifactConfig {
if execution.Env == nil || execution.Env.Config == nil || execution.Env.Config.Pipeline == nil || if execution.Env == nil || execution.Env.Config == nil || execution.Env.Config.Pipeline == nil ||
execution.Env.Config.Pipeline.Scriptorium == nil { execution.Env.Config.Pipeline.Scriptorium == nil {

View File

@@ -6,6 +6,7 @@ import (
"strings" "strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
@@ -60,7 +61,7 @@ func (analyzeStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
if err != nil { if err != nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: plan configured artifacts: %w", err) 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 { if len(plan.ExecutionOrder) == 0 {
return ResumeValidation{Resumable: true, Analyze: summary}, nil return ResumeValidation{Resumable: true, Analyze: summary}, nil
} }
@@ -71,24 +72,28 @@ func (analyzeStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
}, nil }, nil
} }
func analyzeResumeSummary(plan analyzeWorkPlan) *AnalyzeResumeSummary { func analyzeResumeSummary(plan analyzeWorkPlan, pipeline *config.PipelineConfig) *AnalyzeResumeSummary {
return &AnalyzeResumeSummary{ return &AnalyzeResumeSummary{
ExplicitTargets: append([]string(nil), plan.ExplicitTargets...), ExplicitTargets: append([]string(nil), plan.ExplicitTargets...),
PrerequisiteWork: exportAnalyzeResumeItems(plan.PrerequisiteWork), PrerequisiteWork: exportAnalyzeResumeItems(plan.PrerequisiteWork, pipeline),
ExecutionOrder: exportAnalyzeResumeItems(plan.ExecutionOrder), ExecutionOrder: exportAnalyzeResumeItems(plan.ExecutionOrder, pipeline),
ReusedCurrent: exportAnalyzeResumeItems(plan.ReusedCurrent), ReusedCurrent: exportAnalyzeResumeItems(plan.ReusedCurrent, pipeline),
} }
} }
func exportAnalyzeResumeItems(items []analyzePlanItem) []AnalyzeResumeArtifact { func exportAnalyzeResumeItems(items []analyzePlanItem, pipeline *config.PipelineConfig) []AnalyzeResumeArtifact {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
} }
result := make([]AnalyzeResumeArtifact, 0, len(items)) result := make([]AnalyzeResumeArtifact, 0, len(items))
for _, item := range 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, 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 return result
} }

View File

@@ -67,10 +67,12 @@ type AnalyzeResumeSummary struct {
// AnalyzeResumeArtifact is one deterministic artifact-level plan entry. // AnalyzeResumeArtifact is one deterministic artifact-level plan entry.
type AnalyzeResumeArtifact struct { type AnalyzeResumeArtifact struct {
Key string Key string
Role string Family string
Reason string CharacterID string
Forced bool Role string
Reason string
Forced bool
} }
// Normalized returns a result with a bounded reason and no reason on success. // Normalized returns a result with a bounded reason and no reason on success.