Reconcile removed analysis artifacts

This commit is contained in:
2026-09-04 18:37:17 +00:00
parent a520cfdcc8
commit da99857fd0
3 changed files with 97 additions and 32 deletions

View File

@@ -71,25 +71,19 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil { if err != nil {
return nil, fmt.Errorf("analyze: resolve run-stage layout: %w", err) return nil, fmt.Errorf("analyze: resolve run-stage layout: %w", err)
} }
if env.Config.Pipeline.Scriptorium == nil { scriptoriumCfg := env.Config.Pipeline.Scriptorium
return &StageResult{Metadata: map[string]any{ noWorkReason := ""
"stage": "analyze", if scriptoriumCfg == nil {
"skipped": true, noWorkReason = "pipeline.scriptorium is not configured"
"reason": "pipeline.scriptorium is not configured", scriptoriumCfg = &config.ScriptoriumConfig{}
}}, nil } else if len(scriptoriumCfg.Artifacts) == 0 {
} noWorkReason = "no scriptorium artifacts configured"
if len(env.Config.Pipeline.Scriptorium.Artifacts) == 0 {
return &StageResult{Metadata: map[string]any{
"stage": "analyze",
"skipped": true,
"reason": "no scriptorium artifacts configured",
}}, nil
} }
effective := env.EffectiveArtifacts effective := env.EffectiveArtifacts
if !effective.Resolved() { if !effective.Resolved() {
effective, err = artifacts.ResolveEffectiveArtifactSet( effective, err = artifacts.ResolveEffectiveArtifactSet(
artifacts.ConfiguredArtifactDefinitions(env.Config.Pipeline.Scriptorium.Artifacts), artifacts.ConfiguredArtifactDefinitions(scriptoriumCfg.Artifacts),
env.SelectedArtifactKeys, env.SelectedArtifactKeys,
) )
if err != nil { if err != nil {
@@ -100,7 +94,7 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog( runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog(
paths, paths,
m, m,
env.Config.Pipeline.Scriptorium, scriptoriumCfg,
env.Config.Pipeline.Notarius, env.Config.Pipeline.Notarius,
effective, effective,
) )
@@ -108,12 +102,8 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err) return nil, fmt.Errorf("analyze: build runtime artifact catalog: %w", err)
} }
if len(effective.Keys()) == 0 { if len(effective.Keys()) == 0 && noWorkReason == "" {
return &StageResult{Metadata: map[string]any{ noWorkReason = "no selected scriptorium artifacts to execute"
"stage": "analyze",
"skipped": true,
"reason": "no selected scriptorium artifacts to execute",
}}, nil
} }
execution := analyzeExecutionContext{ execution := analyzeExecutionContext{
@@ -125,12 +115,12 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths), TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths),
Catalog: runtimeCatalog, Catalog: runtimeCatalog,
} }
reconciliation, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, execution) reconciliation, err := reconcileAnalyzeArtifacts(scriptoriumCfg, execution)
if err != nil { if err != nil {
return nil, fmt.Errorf("analyze: reconcile configured artifacts: %w", err) return nil, fmt.Errorf("analyze: reconcile configured artifacts: %w", err)
} }
workPlan, err := planAnalyzeWork( workPlan, err := planAnalyzeWork(
env.Config.Pipeline.Scriptorium, scriptoriumCfg,
env.SelectedArtifactKeys, env.SelectedArtifactKeys,
env.Force, env.Force,
reconciliation, reconciliation,
@@ -148,10 +138,10 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
} }
for _, item := range workPlan.ExecutionOrder { for _, item := range workPlan.ExecutionOrder {
artifactCfg := env.Config.Pipeline.Scriptorium.Artifacts[item.Key] artifactCfg := scriptoriumCfg.Artifacts[item.Key]
fingerprint, _, _, err := computeAnalyzeArtifactFingerprint( fingerprint, _, _, err := computeAnalyzeArtifactFingerprint(
item.Key, item.Key,
env.Config.Pipeline.Scriptorium, scriptoriumCfg,
execution, execution,
) )
if err != nil { if err != nil {
@@ -167,7 +157,12 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return state.failedResult(item, artifactCfg, fingerprint, err), err return state.failedResult(item, artifactCfg, fingerprint, err), err
} }
} }
return state.successResult(), nil result := state.successResult()
if noWorkReason != "" {
result.Metadata["skipped"] = true
result.Metadata["reason"] = noWorkReason
}
return result, nil
} }
func analyzeArtifactOrigin(execution analyzeExecutionContext, key string) (config.ArtifactFamilyMemberOrigin, bool) { func analyzeArtifactOrigin(execution analyzeExecutionContext, key string) (config.ArtifactFamilyMemberOrigin, bool) {

View File

@@ -15,8 +15,8 @@ func (analyzeStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
return ResumeValidation{}, fmt.Errorf("analyze resume: resolved stage environment config is required") return ResumeValidation{}, fmt.Errorf("analyze resume: resolved stage environment config is required")
} }
cfg := env.Config.Pipeline.Scriptorium cfg := env.Config.Pipeline.Scriptorium
if cfg == nil || len(cfg.Artifacts) == 0 { if cfg == nil {
return ResumeValidation{Resumable: true, Analyze: &AnalyzeResumeSummary{}}, nil cfg = &config.ScriptoriumConfig{}
} }
sessionID := "" sessionID := ""
if m != nil { if m != nil {
@@ -39,9 +39,6 @@ func (analyzeStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
return ResumeValidation{}, fmt.Errorf("analyze resume: resolve effective artifacts: %w", err) return ResumeValidation{}, fmt.Errorf("analyze resume: resolve effective artifacts: %w", err)
} }
} }
if len(effective.Keys()) == 0 {
return ResumeValidation{Resumable: true, Analyze: &AnalyzeResumeSummary{}}, nil
}
paths := sessionPathsForEnv(env, sessionID) paths := sessionPathsForEnv(env, sessionID)
catalog, err := buildAnalyzeRuntimeArtifactCatalog( catalog, err := buildAnalyzeRuntimeArtifactCatalog(
paths, m, cfg, env.Config.Pipeline.Notarius, effective, paths, m, cfg, env.Config.Pipeline.Notarius, effective,
@@ -62,9 +59,16 @@ func (analyzeStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
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, env.Config.Pipeline) summary := analyzeResumeSummary(plan, env.Config.Pipeline)
if len(plan.ExecutionOrder) == 0 { if len(plan.ExecutionOrder) == 0 && len(plan.Removed) == 0 {
return ResumeValidation{Resumable: true, Analyze: summary}, nil return ResumeValidation{Resumable: true, Analyze: summary}, nil
} }
if len(plan.ExecutionOrder) == 0 {
keys := analyzePlanKeysForMetadata(plan.Removed)
return ResumeValidation{
Reason: "analysis artifact state requires reconciliation: " + strings.Join(keys, ", "),
Analyze: summary,
}, nil
}
keys := analyzePlanKeysForMetadata(plan.ExecutionOrder) keys := analyzePlanKeysForMetadata(plan.ExecutionOrder)
return ResumeValidation{ return ResumeValidation{
Reason: "analysis artifacts require execution: " + strings.Join(keys, ", "), Reason: "analysis artifacts require execution: " + strings.Join(keys, ", "),

View File

@@ -4,8 +4,11 @@ import (
"context" "context"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest" "gitea.maximumdirect.net/eric/narratio/internal/manifest"
) )
@@ -37,6 +40,69 @@ func TestAnalyzeResumeValidationHonorsFullAndPartialSelections(t *testing.T) {
} }
} }
func TestAnalyzeRemovedConfigurationRequiresAndPersistsReconciliation(t *testing.T) {
for _, test := range []struct {
name string
remove func(*config.PipelineConfig)
reason string
}{
{
name: "scriptorium omitted",
remove: func(pipeline *config.PipelineConfig) {
pipeline.Scriptorium = nil
},
reason: "pipeline.scriptorium is not configured",
},
{
name: "artifact map empty",
remove: func(pipeline *config.PipelineConfig) {
pipeline.Scriptorium.Artifacts = map[string]config.ScriptoriumArtifactConfig{}
},
reason: "no scriptorium artifacts configured",
},
} {
t.Run(test.name, func(t *testing.T) {
env, m, initialRequests := currentAnalyzeFixture(t, false)
test.remove(env.Config.Pipeline)
validation, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if validation.Resumable || !strings.Contains(validation.Reason, "session_recap") {
t.Fatalf("validation = %#v, want reconciliation of removed session_recap", validation)
}
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if result.Metadata["skipped"] != true || result.Metadata["reason"] != test.reason {
t.Fatalf("metadata = %#v, want skipped reason %q", result.Metadata, test.reason)
}
if len(result.Outputs) != 0 || len(m.Stages["analyze"].AnalyzeArtifacts) != 0 {
t.Fatalf("result outputs = %#v; analyze records = %#v, want both empty", result.Outputs, m.Stages["analyze"].AnalyzeArtifacts)
}
fake := env.Scriptorium.(*scriptorium.FakeRunner)
if len(fake.RunRequests) != initialRequests {
t.Fatalf("adapter requests = %d, want unchanged count %d", len(fake.RunRequests), initialRequests)
}
paths := sessionPathsForEnv(env, m.SessionID)
stored, err := env.ManifestStore.Load(
context.Background(),
artifacts.SessionManifestPathForCampaign(paths.WorkspaceRoot, m.Campaign, m.SessionID),
)
if err != nil {
t.Fatal(err)
}
if len(stored.Stages["analyze"].AnalyzeArtifacts) != 0 {
t.Fatalf("durable analyze records = %#v, want empty", stored.Stages["analyze"].AnalyzeArtifacts)
}
})
}
}
func TestAnalyzeResumeValidationReportsForceAndCurrentPrerequisites(t *testing.T) { func TestAnalyzeResumeValidationReportsForceAndCurrentPrerequisites(t *testing.T) {
env, m, _ := currentAnalyzeFixture(t, true) env, m, _ := currentAnalyzeFixture(t, true)
env.SelectedArtifactKeys = []string{"session_recap"} env.SelectedArtifactKeys = []string{"session_recap"}