Add family artifact selection and provenance
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) != "" {
|
||||||
|
|||||||
@@ -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, ", ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ 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
|
||||||
|
Family string
|
||||||
|
CharacterID string
|
||||||
Role string
|
Role string
|
||||||
Reason string
|
Reason string
|
||||||
Forced bool
|
Forced bool
|
||||||
|
|||||||
Reference in New Issue
Block a user