From da99857fd01eb6aac7e4e95ca024c608bedf0b67 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 4 Sep 2026 18:37:17 +0000 Subject: [PATCH] Reconcile removed analysis artifacts --- internal/stage/analyze.go | 47 +++++++++---------- internal/stage/analyze_resume.go | 16 ++++--- internal/stage/analyze_resume_test.go | 66 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 32 deletions(-) diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 9bb0073..f79934d 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -71,25 +71,19 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S if err != nil { return nil, fmt.Errorf("analyze: resolve run-stage layout: %w", err) } - if env.Config.Pipeline.Scriptorium == nil { - return &StageResult{Metadata: map[string]any{ - "stage": "analyze", - "skipped": true, - "reason": "pipeline.scriptorium is not configured", - }}, nil - } - if len(env.Config.Pipeline.Scriptorium.Artifacts) == 0 { - return &StageResult{Metadata: map[string]any{ - "stage": "analyze", - "skipped": true, - "reason": "no scriptorium artifacts configured", - }}, nil + scriptoriumCfg := env.Config.Pipeline.Scriptorium + noWorkReason := "" + if scriptoriumCfg == nil { + noWorkReason = "pipeline.scriptorium is not configured" + scriptoriumCfg = &config.ScriptoriumConfig{} + } else if len(scriptoriumCfg.Artifacts) == 0 { + noWorkReason = "no scriptorium artifacts configured" } effective := env.EffectiveArtifacts if !effective.Resolved() { effective, err = artifacts.ResolveEffectiveArtifactSet( - artifacts.ConfiguredArtifactDefinitions(env.Config.Pipeline.Scriptorium.Artifacts), + artifacts.ConfiguredArtifactDefinitions(scriptoriumCfg.Artifacts), env.SelectedArtifactKeys, ) if err != nil { @@ -100,7 +94,7 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S runtimeCatalog, err := buildAnalyzeRuntimeArtifactCatalog( paths, m, - env.Config.Pipeline.Scriptorium, + scriptoriumCfg, env.Config.Pipeline.Notarius, 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) } - if len(effective.Keys()) == 0 { - return &StageResult{Metadata: map[string]any{ - "stage": "analyze", - "skipped": true, - "reason": "no selected scriptorium artifacts to execute", - }}, nil + if len(effective.Keys()) == 0 && noWorkReason == "" { + noWorkReason = "no selected scriptorium artifacts to execute" } execution := analyzeExecutionContext{ @@ -125,12 +115,12 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths), Catalog: runtimeCatalog, } - reconciliation, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, execution) + reconciliation, err := reconcileAnalyzeArtifacts(scriptoriumCfg, execution) if err != nil { return nil, fmt.Errorf("analyze: reconcile configured artifacts: %w", err) } workPlan, err := planAnalyzeWork( - env.Config.Pipeline.Scriptorium, + scriptoriumCfg, env.SelectedArtifactKeys, env.Force, reconciliation, @@ -148,10 +138,10 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S } for _, item := range workPlan.ExecutionOrder { - artifactCfg := env.Config.Pipeline.Scriptorium.Artifacts[item.Key] + artifactCfg := scriptoriumCfg.Artifacts[item.Key] fingerprint, _, _, err := computeAnalyzeArtifactFingerprint( item.Key, - env.Config.Pipeline.Scriptorium, + scriptoriumCfg, execution, ) 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.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) { diff --git a/internal/stage/analyze_resume.go b/internal/stage/analyze_resume.go index 68b2cdf..5d11ee0 100644 --- a/internal/stage/analyze_resume.go +++ b/internal/stage/analyze_resume.go @@ -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") } cfg := env.Config.Pipeline.Scriptorium - if cfg == nil || len(cfg.Artifacts) == 0 { - return ResumeValidation{Resumable: true, Analyze: &AnalyzeResumeSummary{}}, nil + if cfg == nil { + cfg = &config.ScriptoriumConfig{} } sessionID := "" 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) } } - if len(effective.Keys()) == 0 { - return ResumeValidation{Resumable: true, Analyze: &AnalyzeResumeSummary{}}, nil - } paths := sessionPathsForEnv(env, sessionID) catalog, err := buildAnalyzeRuntimeArtifactCatalog( 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) } 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 } + 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) return ResumeValidation{ Reason: "analysis artifacts require execution: " + strings.Join(keys, ", "), diff --git a/internal/stage/analyze_resume_test.go b/internal/stage/analyze_resume_test.go index 3020e43..fa9ac79 100644 --- a/internal/stage/analyze_resume_test.go +++ b/internal/stage/analyze_resume_test.go @@ -4,8 +4,11 @@ import ( "context" "path/filepath" "reflect" + "strings" "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/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) { env, m, _ := currentAnalyzeFixture(t, true) env.SelectedArtifactKeys = []string{"session_recap"}