From 172e2d6a4f0a97735737d98114d1e2a03970864f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 4 Sep 2026 15:57:54 +0000 Subject: [PATCH] Consolidate analyze session execution state --- docs/roadmap/implementation.md | 2 +- internal/app/runner.go | 6 +- internal/app/runner_test.go | 10 + internal/artifacts/store.go | 1 + internal/stage/analyze.go | 172 +------------ internal/stage/analyze_execution_state.go | 236 ++++++++++++++++++ internal/stage/analyze_failure_test.go | 69 ++--- .../analyze_incremental_execution_test.go | 68 ++--- internal/stage/analyze_plan.go | 22 +- internal/stage/analyze_plan_test.go | 14 +- internal/stage/analyze_resume_test.go | 10 +- internal/stage/analyze_test.go | 59 ++++- 12 files changed, 401 insertions(+), 268 deletions(-) create mode 100644 internal/stage/analyze_execution_state.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e2005f5..974be6d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -173,7 +173,7 @@ changing its persistence timing. This stage is behavior-preserving. ## Stage 2 — Establish Session-Only Analyze Execution State -**Status:** Pending +**Status:** Completed ### Goal diff --git a/internal/app/runner.go b/internal/app/runner.go index 1872653..cb683a6 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -283,6 +283,10 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string) sourceID = sourceIDForOutputKind(kind) } } + producerRunID := strings.TrimSpace(ref.ProducerRunID) + if producerRunID == "" { + producerRunID = runID + } out = append(out, manifest.ArtifactRecord{ Kind: kind, SourceID: sourceID, @@ -290,7 +294,7 @@ func mapResultOutputs(stageName string, result *stage.StageResult, runID string) ArchivePath: ref.ArchivePath, Contract: cloneContractMetadata(ref.Contract), ExternalProvenance: cloneExternalProvenance(ref.ExternalProvenance), - ProducerRunID: runID, + ProducerRunID: producerRunID, RemoteKey: ref.RemoteKey, Checksum: ref.Checksum, }) diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index d7af60c..6c17c9e 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -375,6 +375,7 @@ func TestMapResultOutputsPrefersExplicitSourceAndCopiesMetadata(t *testing.T) { Kind: "structured_data", SourceID: "narratio.example.npcs", RelativePath: "artifacts/npcs.json", + ProducerRunID: "original-run", Contract: contract, ExternalProvenance: provenance, }}} @@ -389,6 +390,9 @@ func TestMapResultOutputsPrefersExplicitSourceAndCopiesMetadata(t *testing.T) { if got[0].Kind != "structured_data" { t.Fatalf("kind = %q, want explicit output kind preserved", got[0].Kind) } + if got[0].ProducerRunID != "original-run" { + t.Fatalf("producer run id = %q, want explicit producer", got[0].ProducerRunID) + } if got[0].Contract == nil || *got[0].Contract != *contract { t.Fatalf("contract = %#v, want %#v", got[0].Contract, contract) } @@ -407,11 +411,17 @@ func TestMapResultOutputsRetainsFallbackInference(t *testing.T) { if len(transcript) != 1 || transcript[0].SourceID != artifacts.ArtifactTranscriptFinalTrimmed { t.Fatalf("transcript fallback = %#v, want final-trimmed source", transcript) } + if transcript[0].ProducerRunID != "run-id" { + t.Fatalf("transcript producer run id = %q, want active run fallback", transcript[0].ProducerRunID) + } analyze := mapResultOutputs("analyze", &stage.StageResult{Outputs: []artifacts.Ref{{Kind: "session_recap"}}}, "run-id") if len(analyze) != 1 || analyze[0].SourceID != "narratio.artifact.session_recap" || analyze[0].Kind != "scriptorium_artifact" { t.Fatalf("analyze fallback = %#v, want configured artifact inference", analyze) } + if analyze[0].ProducerRunID != "run-id" { + t.Fatalf("analyze producer run id = %q, want active run fallback", analyze[0].ProducerRunID) + } } func TestNeedsObjectStoreForRunPrepareWithPreviousRequirements(t *testing.T) { diff --git a/internal/artifacts/store.go b/internal/artifacts/store.go index 2572ced..9bf21c2 100644 --- a/internal/artifacts/store.go +++ b/internal/artifacts/store.go @@ -18,6 +18,7 @@ type Ref struct { ArchivePath string RemoteKey string Checksum string + ProducerRunID string Contract *artifactmodel.ContractMetadata ExternalProvenance *artifactmodel.ExternalProvenance } diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 843e140..68b378a 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -142,25 +142,7 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S return nil, fmt.Errorf("analyze: scriptorium adapter is required") } - logs := []string{} - generatedConfigs := []string{} - artifactMetadata := make([]map[string]any, 0, len(workPlan.ExecutionOrder)) - reusedArtifacts := []map[string]any{} - reusedSeen := map[string]struct{}{} - sessionRecords := manifest.CloneAnalyzeArtifactCollection(workPlan.ProjectedRecords) - priorCurrentRecords := make(map[string]manifest.AnalyzeArtifactRecord) - for _, item := range reconciliation.Ordered { - if item.Stored != nil && item.Stored.Status == manifest.AnalyzeArtifactCurrent { - priorCurrentRecords[item.Key] = *item.Stored - } - } - currentKeys := make(map[string]struct{}, len(workPlan.ExecutionOrder)+len(workPlan.ReusedCurrent)) - for _, item := range workPlan.ExecutionOrder { - currentKeys[item.Key] = struct{}{} - } - for _, item := range workPlan.ReusedCurrent { - currentKeys[item.Key] = struct{}{} - } + state := newAnalyzeExecutionState(execution, workPlan, reconciliation) for _, item := range workPlan.ExecutionOrder { artifactCfg := env.Config.Pipeline.Scriptorium.Artifacts[item.Key] @@ -171,158 +153,18 @@ func (analyzeStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S ) if err != nil { executionErr := fmt.Errorf("analyze: compute execution fingerprint for artifact %q: %w", item.Key, err) - return failedAnalyzeResult( - execution, - item, - artifactCfg, - "", - executionErr, - sessionRecords, - ), executionErr + return state.failedResult(item, artifactCfg, "", executionErr), executionErr } - priorRecord, hadPriorRecord := priorCurrentRecords[item.Key] plan := analyzeArtifactExecutionPlan{Name: item.Key, Cfg: artifactCfg} artifactResult, err := executeAnalyzeArtifact(ctx, execution, plan) if err != nil { - return failedAnalyzeResult( - execution, - item, - artifactCfg, - fingerprint, - err, - sessionRecords, - ), err + return state.failedResult(item, artifactCfg, fingerprint, err), err } - - 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) - path, _ := reused["path"].(string) - key := sourceID + "|" + path - if _, exists := reusedSeen[key]; exists { - continue - } - reusedSeen[key] = struct{}{} - reusedArtifacts = append(reusedArtifacts, reused) - } - - record, err := currentAnalyzeArtifactRecord( - execution, - plan, - fingerprint, - artifactResult, - ) - if err != nil { - recordErr := fmt.Errorf("analyze: record artifact %q: %w", plan.Name, err) - return failedAnalyzeResult( - execution, - item, - artifactCfg, - fingerprint, - recordErr, - sessionRecords, - ), recordErr - } - sessionRecords[plan.Name] = record - - sourceID, ok := runtimeCatalog.SourceIDForConfiguredKey(plan.Name) - if !ok { - catalogErr := fmt.Errorf("analyze: source id not found for artifact %q", plan.Name) - return failedAnalyzeResult( - execution, item, artifactCfg, fingerprint, catalogErr, - sessionRecords, - ), catalogErr - } - if err := runtimeCatalog.MarkAvailableGeneratedEvidence( - sourceID, - artifactResult.Output.AbsolutePath, - record.ProducerRunID, - record.Output.Checksum, - record.OutputSize, - record.Output.Contract, - ); err != nil { - catalogErr := fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err) - return failedAnalyzeResult( - execution, item, artifactCfg, fingerprint, catalogErr, - sessionRecords, - ), catalogErr - } - if !hadPriorRecord || !sameAnalyzeOutputIdentity(priorRecord, record) { - staleUnscheduledAnalyzeDependents( - env.Config.Pipeline.Scriptorium.Artifacts, - plan.Name, - currentKeys, - sessionRecords, - ) + if err := state.acceptArtifact(plan, fingerprint, artifactResult); err != nil { + return state.failedResult(item, artifactCfg, fingerprint, err), err } } - - metadata := map[string]any{ - "stage": "analyze", - "selected_artifacts": append([]string(nil), workPlan.ExplicitTargets...), - "executed_artifacts": analyzePlanKeysForMetadata(workPlan.ExecutionOrder), - "reused_current": analyzePlanKeysForMetadata(workPlan.ReusedCurrent), - "generated_artifacts": artifactMetadata, - "reused_artifacts": reusedArtifacts, - "artifact_count": len(artifactMetadata), - "reused_artifact_count": len(reusedArtifacts), - } - if len(artifactMetadata) == 1 { - for k, v := range artifactMetadata[0] { - metadata[k] = v - } - } - - return &StageResult{ - Logs: dedupeAndSortPaths(logs), - GeneratedConfigs: dedupeAndSortPaths(generatedConfigs), - Metadata: metadata, - AnalyzeState: &AnalyzeStateProjection{ - Session: sessionRecords, - }, - }, nil -} - -func failedAnalyzeResult( - execution analyzeExecutionContext, - item analyzePlanItem, - artifactCfg config.ScriptoriumArtifactConfig, - fingerprint string, - cause error, - sessionRecords map[string]manifest.AnalyzeArtifactRecord, -) *StageResult { - record := manifest.AnalyzeArtifactRecord{ - Key: item.Key, - Status: manifest.AnalyzeArtifactFailed, - Dependencies: normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn), - ProducerRunID: analyzeProducerRunID(execution), - 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 - } - if artifactCfg.PromptID != "" || artifactCfg.ProfileID != "" { - record.Scriptorium = &manifest.AnalyzeArtifactProvenance{ - PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, - } - } - sessionRecords[item.Key] = record - staleAnalyzeDependents(artifactCfgMap(execution), item.Key, sessionRecords) - return &StageResult{AnalyzeState: &AnalyzeStateProjection{ - Session: manifest.CloneAnalyzeArtifactCollection(sessionRecords), - }} + return state.successResult(), nil } func analyzeArtifactOrigin(execution analyzeExecutionContext, key string) (config.ArtifactFamilyMemberOrigin, bool) { @@ -464,7 +306,7 @@ func staleAnalyzeDependentClosure( if eligible != nil && !eligible(key) { continue } - staleProjectedAnalyzeRecord(records, key) + staleAnalyzeRecord(records, key) queue = append(queue, reverse[key]...) } } diff --git a/internal/stage/analyze_execution_state.go b/internal/stage/analyze_execution_state.go new file mode 100644 index 0000000..9dc094a --- /dev/null +++ b/internal/stage/analyze_execution_state.go @@ -0,0 +1,236 @@ +package stage + +import ( + "fmt" + "sort" + "strings" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +type analyzeExecutionState struct { + execution analyzeExecutionContext + workPlan analyzeWorkPlan + records map[string]manifest.AnalyzeArtifactRecord + priorCurrentRecords map[string]manifest.AnalyzeArtifactRecord + evaluatedKeys map[string]struct{} + catalog *artifacts.ArtifactCatalog + logs []string + generatedConfigs []string + artifactMetadata []map[string]any + reusedArtifacts []map[string]any + reusedSeen map[string]struct{} +} + +func newAnalyzeExecutionState( + execution analyzeExecutionContext, + workPlan analyzeWorkPlan, + reconciliation analyzeReconciliation, +) *analyzeExecutionState { + state := &analyzeExecutionState{ + execution: execution, + workPlan: workPlan, + records: manifest.CloneAnalyzeArtifactCollection(workPlan.StartingRecords), + priorCurrentRecords: make(map[string]manifest.AnalyzeArtifactRecord), + evaluatedKeys: make(map[string]struct{}, len(workPlan.ExecutionOrder)+len(workPlan.ReusedCurrent)), + catalog: execution.Catalog, + artifactMetadata: make([]map[string]any, 0, len(workPlan.ExecutionOrder)), + reusedSeen: make(map[string]struct{}), + } + for _, item := range reconciliation.Ordered { + if item.Stored != nil && item.Stored.Status == manifest.AnalyzeArtifactCurrent { + state.priorCurrentRecords[item.Key] = *item.Stored + } + } + for _, item := range workPlan.ExecutionOrder { + state.evaluatedKeys[item.Key] = struct{}{} + } + for _, item := range workPlan.ReusedCurrent { + state.evaluatedKeys[item.Key] = struct{}{} + } + return state +} + +func (state *analyzeExecutionState) acceptArtifact( + plan analyzeArtifactExecutionPlan, + fingerprint string, + result *analyzeArtifactExecutionResult, +) error { + state.collectDiagnostics(plan.Name, result) + record, err := currentAnalyzeArtifactRecord(state.execution, plan, fingerprint, result) + if err != nil { + return fmt.Errorf("analyze: record artifact %q: %w", plan.Name, err) + } + state.records[plan.Name] = record + + sourceID, ok := state.catalog.SourceIDForConfiguredKey(plan.Name) + if !ok { + return fmt.Errorf("analyze: source id not found for artifact %q", plan.Name) + } + if err := state.catalog.MarkAvailableGeneratedEvidence( + sourceID, + result.Output.AbsolutePath, + record.ProducerRunID, + record.Output.Checksum, + record.OutputSize, + record.Output.Contract, + ); err != nil { + return fmt.Errorf("analyze: mark generated artifact %q available: %w", sourceID, err) + } + prior, hadPrior := state.priorCurrentRecords[plan.Name] + if !hadPrior || !sameAnalyzeOutputIdentity(prior, record) { + staleUnscheduledAnalyzeDependents( + artifactCfgMap(state.execution), + plan.Name, + state.evaluatedKeys, + state.records, + ) + } + return nil +} + +func (state *analyzeExecutionState) collectDiagnostics(name string, result *analyzeArtifactExecutionResult) { + state.logs = append(state.logs, result.Logs...) + state.generatedConfigs = append(state.generatedConfigs, result.GeneratedConfigs...) + if origin, ok := analyzeArtifactOrigin(state.execution, name); ok { + result.Metadata["family"] = origin.Family + result.Metadata["character_id"] = origin.CharacterID + } + state.artifactMetadata = append(state.artifactMetadata, result.Metadata) + for _, reused := range result.ReusedArtifacts { + sourceID, _ := reused["source_id"].(string) + path, _ := reused["path"].(string) + key := sourceID + "|" + path + if _, exists := state.reusedSeen[key]; exists { + continue + } + state.reusedSeen[key] = struct{}{} + state.reusedArtifacts = append(state.reusedArtifacts, reused) + } +} + +func (state *analyzeExecutionState) failedResult( + item analyzePlanItem, + artifactCfg config.ScriptoriumArtifactConfig, + fingerprint string, + cause error, +) *StageResult { + record := manifest.AnalyzeArtifactRecord{ + Key: item.Key, + Status: manifest.AnalyzeArtifactFailed, + Dependencies: normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn), + ProducerRunID: analyzeProducerRunID(state.execution), + UpdatedAt: time.Now().UTC(), + Error: NonResumable(cause.Error()).Reason, + } + if origin, ok := analyzeArtifactOrigin(state.execution, item.Key); ok { + record.Family = origin.Family + record.CharacterID = origin.CharacterID + } + if fingerprint != "" { + record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion + record.Fingerprint = fingerprint + } + if artifactCfg.PromptID != "" || artifactCfg.ProfileID != "" { + record.Scriptorium = &manifest.AnalyzeArtifactProvenance{ + PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, + } + } + state.records[item.Key] = record + staleAnalyzeDependents(artifactCfgMap(state.execution), item.Key, state.records) + state.installManifestCollection() + return &StageResult{AnalyzeState: state.projection()} +} + +func (state *analyzeExecutionState) successResult() *StageResult { + metadata := map[string]any{ + "stage": "analyze", + "selected_artifacts": append([]string(nil), state.workPlan.ExplicitTargets...), + "executed_artifacts": analyzePlanKeysForMetadata(state.workPlan.ExecutionOrder), + "reused_current": analyzePlanKeysForMetadata(state.workPlan.ReusedCurrent), + "generated_artifacts": state.artifactMetadata, + "reused_artifacts": state.reusedArtifacts, + "artifact_count": len(state.artifactMetadata), + "reused_artifact_count": len(state.reusedArtifacts), + } + if len(state.artifactMetadata) == 1 { + for key, value := range state.artifactMetadata[0] { + metadata[key] = value + } + } + state.installManifestCollection() + return &StageResult{ + Logs: dedupeAndSortPaths(state.logs), + GeneratedConfigs: dedupeAndSortPaths(state.generatedConfigs), + Metadata: metadata, + AnalyzeState: state.projection(), + } +} + +func (state *analyzeExecutionState) installManifestCollection() { + m := state.execution.Manifest + if m == nil { + return + } + if m.Stages == nil { + m.Stages = make(map[string]*manifest.StageRecord) + } + record := m.Stages["analyze"] + if record == nil { + record = &manifest.StageRecord{Name: "analyze"} + m.Stages["analyze"] = record + } + record.AnalyzeStateVersion = manifest.AnalyzeStateContractVersion + record.AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(state.records) +} + +func (state *analyzeExecutionState) projection() *AnalyzeStateProjection { + return &AnalyzeStateProjection{Session: manifest.CloneAnalyzeArtifactCollection(state.records)} +} + +func analyzeArtifactRefs(records map[string]manifest.AnalyzeArtifactRecord) []artifacts.Ref { + keys := make([]string, 0, len(records)) + for key, record := range records { + if record.Status == manifest.AnalyzeArtifactCurrent && record.Output != nil { + keys = append(keys, key) + } + } + sortStringsByNormalizedArtifactKey(keys) + refs := make([]artifacts.Ref, 0, len(keys)) + for _, key := range keys { + record := records[key] + output := record.Output + producerRunID := strings.TrimSpace(output.ProducerRunID) + if producerRunID == "" { + producerRunID = strings.TrimSpace(record.ProducerRunID) + } + refs = append(refs, artifacts.Ref{ + Kind: output.Kind, + SourceID: output.SourceID, + RelativePath: output.LocalPath, + ArchivePath: output.ArchivePath, + RemoteKey: output.RemoteKey, + Checksum: output.Checksum, + Contract: cloneAnalyzeOutputContract(output.Contract), + ExternalProvenance: cloneAnalyzeExternalProvenance(output.ExternalProvenance), + ProducerRunID: producerRunID, + }) + } + return refs +} + +func sortStringsByNormalizedArtifactKey(keys []string) { + sort.Strings(keys) +} + +func cloneAnalyzeExternalProvenance(value *artifactmodel.ExternalProvenance) *artifactmodel.ExternalProvenance { + if value == nil { + return nil + } + cloned := *value + return &cloned +} diff --git a/internal/stage/analyze_failure_test.go b/internal/stage/analyze_failure_test.go index e60c6ff..0b44294 100644 --- a/internal/stage/analyze_failure_test.go +++ b/internal/stage/analyze_failure_test.go @@ -13,7 +13,7 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/manifest" ) -func TestAnalyzeAdapterFailureProjectsCompletedAndFailedArtifacts(t *testing.T) { +func TestAnalyzeAdapterFailureRecordsCompletedAndFailedArtifacts(t *testing.T) { for _, failAt := range []int{1, 2, 3} { t.Run(map[int]string{1: "first", 2: "middle", 3: "last"}[failAt], func(t *testing.T) { env, m, _ := setupAnalyzeEnv(t) @@ -26,12 +26,13 @@ func TestAnalyzeAdapterFailureProjectsCompletedAndFailedArtifacts(t *testing.T) if !errors.Is(err, cause) { t.Fatalf("Run() error = %v, want injected cause", err) } - if result == nil || result.AnalyzeState == nil { - t.Fatal("failure result has no analyze projection") + if result == nil || m.Stages["analyze"] == nil { + t.Fatal("failure did not expose analyze manifest state") } + records := m.Stages["analyze"].AnalyzeArtifacts keys := []string{"alpha", "beta", "gamma"} for index, key := range keys { - record, exists := result.AnalyzeState.Session[key] + record, exists := records[key] switch { case index < failAt-1: if !exists || record.Status != manifest.AnalyzeArtifactCurrent || record.Output == nil { @@ -54,11 +55,10 @@ func TestAnalyzeAdapterFailureProjectsCompletedAndFailedArtifacts(t *testing.T) func TestAnalyzeMiddleFailureStalesDependentsAndPreservesUnrelatedCurrent(t *testing.T) { env, m, _ := setupAnalyzeEnv(t) env.Config.Pipeline.Scriptorium.Artifacts = chainedAnalyzeArtifacts() - first, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } - installAnalyzeProjection(m, first.AnalyzeState) unrelated := m.Stages["analyze"].AnalyzeArtifacts["unrelated"] stale := m.Stages["analyze"].AnalyzeArtifacts["alpha"] stale.Status = manifest.AnalyzeArtifactStale @@ -68,36 +68,37 @@ func TestAnalyzeMiddleFailureStalesDependentsAndPreservesUnrelatedCurrent(t *tes env.SelectedArtifactKeys = []string{"gamma"} cause := errors.New("middle artifact failed") env.Scriptorium = &indexedAnalyzeFailureRunner{FailAt: 2, Err: cause} - - result, err := (analyzeStage{}).Run(context.Background(), env, m) - if !errors.Is(err, cause) { - t.Fatalf("Run() error = %v", err) - } - if result.AnalyzeState.Session["alpha"].Status != manifest.AnalyzeArtifactCurrent { - t.Fatalf("completed prerequisite = %#v", result.AnalyzeState.Session["alpha"]) - } - if result.AnalyzeState.Session["beta"].Status != manifest.AnalyzeArtifactFailed { - t.Fatalf("failed middle = %#v", result.AnalyzeState.Session["beta"]) - } - if target := result.AnalyzeState.Session["gamma"]; target.Status != manifest.AnalyzeArtifactStale || target.Output != nil { - t.Fatalf("dependent target = %#v, want stale", target) - } - if got := result.AnalyzeState.Session["unrelated"]; got.Status != manifest.AnalyzeArtifactCurrent || - got.Fingerprint != unrelated.Fingerprint || got.Output == nil || got.Output.Checksum != unrelated.Output.Checksum { - t.Fatalf("unrelated record = %#v, want preserved %#v", got, unrelated) - } - if result.AnalyzeState.Session["gamma"].Status != manifest.AnalyzeArtifactStale { - t.Fatalf("dependent target state = %#v, want stale", result.AnalyzeState.Session["gamma"]) - } if old := m.Stages["analyze"].AnalyzeArtifacts["beta"]; old.Output == nil { t.Fatal("fixture did not retain old canonical evidence before failure") } - if failed := result.AnalyzeState.Session["beta"]; failed.Output != nil { + + _, err = (analyzeStage{}).Run(context.Background(), env, m) + if !errors.Is(err, cause) { + t.Fatalf("Run() error = %v", err) + } + records := m.Stages["analyze"].AnalyzeArtifacts + if records["alpha"].Status != manifest.AnalyzeArtifactCurrent { + t.Fatalf("completed prerequisite = %#v", records["alpha"]) + } + if records["beta"].Status != manifest.AnalyzeArtifactFailed { + t.Fatalf("failed middle = %#v", records["beta"]) + } + if target := records["gamma"]; target.Status != manifest.AnalyzeArtifactStale || target.Output != nil { + t.Fatalf("dependent target = %#v, want stale", target) + } + if got := records["unrelated"]; got.Status != manifest.AnalyzeArtifactCurrent || + got.Fingerprint != unrelated.Fingerprint || got.Output == nil || got.Output.Checksum != unrelated.Output.Checksum { + t.Fatalf("unrelated record = %#v, want preserved %#v", got, unrelated) + } + if records["gamma"].Status != manifest.AnalyzeArtifactStale { + t.Fatalf("dependent target state = %#v, want stale", records["gamma"]) + } + if failed := records["beta"]; failed.Output != nil { t.Fatal("old canonical bytes made failed artifact current") } } -func TestAnalyzeInvalidOutputReturnsFailedProjection(t *testing.T) { +func TestAnalyzeInvalidOutputRecordsFailure(t *testing.T) { env, m, _ := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) @@ -105,17 +106,17 @@ func TestAnalyzeInvalidOutputReturnsFailedProjection(t *testing.T) { m.RunID = "run-invalid-output" env.Scriptorium = invalidAnalyzeOutputRunner{} - result, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err == nil { t.Fatal("Run() error = nil") } - record := result.AnalyzeState.Session["session_recap"] + record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"] if record.Status != manifest.AnalyzeArtifactFailed || record.Output != nil { t.Fatalf("failed record = %#v", record) } } -func TestAnalyzeMaterializationFailureReturnsFailedProjection(t *testing.T) { +func TestAnalyzeMaterializationFailureRecordsFailure(t *testing.T) { env, m, _ := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID) writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) @@ -125,11 +126,11 @@ func TestAnalyzeMaterializationFailureReturnsFailedProjection(t *testing.T) { canonical := filepath.Join(paths.ArtifactsDir, "session_recap.md") env.ArtifactStore = &failingAnalyzeArtifactStore{Store: env.ArtifactStore, FailPath: canonical, Err: cause} - result, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if !errors.Is(err, cause) { t.Fatalf("Run() error = %v, want write cause", err) } - record := result.AnalyzeState.Session["session_recap"] + record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"] if record.Status != manifest.AnalyzeArtifactFailed || record.Output != nil { t.Fatalf("failed record = %#v", record) } diff --git a/internal/stage/analyze_incremental_execution_test.go b/internal/stage/analyze_incremental_execution_test.go index 38f02c9..6a9f89a 100644 --- a/internal/stage/analyze_incremental_execution_test.go +++ b/internal/stage/analyze_incremental_execution_test.go @@ -19,7 +19,7 @@ func TestAnalyzeReusesCurrentArtifactWithoutScriptorium(t *testing.T) { if err != nil { t.Fatal(err) } - installAnalyzeProjection(m, first.AnalyzeState) + assertAnalyzeProjectionMatchesManifest(t, m, first.AnalyzeState) if len(fake.RunRequests) != 1 { t.Fatalf("initial requests = %d, want 1", len(fake.RunRequests)) } @@ -35,8 +35,8 @@ func TestAnalyzeReusesCurrentArtifactWithoutScriptorium(t *testing.T) { if got := second.Metadata["reused_current"]; !reflect.DeepEqual(got, []string{"session_recap"}) { t.Fatalf("reused current = %#v, want session_recap", got) } - if second.AnalyzeState.Session["session_recap"].Status != manifest.AnalyzeArtifactCurrent { - t.Fatalf("session state = %#v", second.AnalyzeState.Session) + if m.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent { + t.Fatalf("session state = %#v", m.Stages["analyze"].AnalyzeArtifacts) } } @@ -46,11 +46,10 @@ func TestAnalyzePartialForcePreservesUnrelatedCurrentRecord(t *testing.T) { writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) addAnalyzeDependentArtifact(env) - first, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } - installAnalyzeProjection(m, first.AnalyzeState) if len(fake.RunRequests) != 2 { t.Fatalf("initial requests = %d, want 2", len(fake.RunRequests)) } @@ -60,17 +59,17 @@ func TestAnalyzePartialForcePreservesUnrelatedCurrentRecord(t *testing.T) { env.Force = true secondRunner := &orderedScriptoriumRunner{RunBody: "scriptorium noop/fake run artifact\n"} env.Scriptorium = secondRunner - second, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err = (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } if got := secondRunner.Calls; !reflect.DeepEqual(got, []string{"run"}) { t.Fatalf("calls = %#v, want one forced target run", got) } - if got := second.AnalyzeState.Session["player_handout"]; !reflect.DeepEqual(got, priorDependent) { + if got := m.Stages["analyze"].AnalyzeArtifacts["player_handout"]; !reflect.DeepEqual(got, priorDependent) { t.Fatalf("unrelated dependent changed = %#v, want %#v", got, priorDependent) } - if _, current := second.AnalyzeState.Session["player_handout"]; !current { + if _, current := m.Stages["analyze"].AnalyzeArtifacts["player_handout"]; !current { t.Fatal("unselected dependent was removed from session state") } } @@ -81,18 +80,17 @@ func TestAnalyzeExposesReusedPrerequisiteToScheduledDependent(t *testing.T) { writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) addAnalyzeDependentArtifact(env) - first, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } - installAnalyzeProjection(m, first.AnalyzeState) dependent := env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] dependent.PromptID = "dnd.player_handout.revised" env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = dependent env.SelectedArtifactKeys = []string{"player_handout"} fake.RunRequests = nil - second, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err = (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } @@ -102,9 +100,9 @@ func TestAnalyzeExposesReusedPrerequisiteToScheduledDependent(t *testing.T) { if got := fake.RunRequests[0].InputPaths["recap"]; got != filepath.Join(paths.ArtifactsDir, "session_recap.md") { t.Fatalf("reused prerequisite input = %q", got) } - if second.AnalyzeState.Session["session_recap"].Status != manifest.AnalyzeArtifactCurrent || - second.AnalyzeState.Session["player_handout"].Status != manifest.AnalyzeArtifactCurrent { - t.Fatalf("session records = %#v, want reused prerequisite and produced dependent", second.AnalyzeState.Session) + if m.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent || + m.Stages["analyze"].AnalyzeArtifacts["player_handout"].Status != manifest.AnalyzeArtifactCurrent { + t.Fatalf("session records = %#v, want reused prerequisite and produced dependent", m.Stages["analyze"].AnalyzeArtifacts) } } @@ -114,25 +112,24 @@ func TestAnalyzeChangedOutputStalesUnselectedDependents(t *testing.T) { writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) addAnalyzeDependentArtifact(env) - first, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } - installAnalyzeProjection(m, first.AnalyzeState) env.SelectedArtifactKeys = []string{"session_recap"} env.Force = true env.Scriptorium = &orderedScriptoriumRunner{RunBody: "changed recap\n"} - second, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err = (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } - dependent := second.AnalyzeState.Session["player_handout"] + dependent := m.Stages["analyze"].AnalyzeArtifacts["player_handout"] if dependent.Status != manifest.AnalyzeArtifactStale || dependent.Output != nil || dependent.OutputSize != 0 { t.Fatalf("dependent record = %#v, want stale without output", dependent) } - if second.AnalyzeState.Session["player_handout"].Status != manifest.AnalyzeArtifactStale { - t.Fatalf("dependent state = %#v, want stale", second.AnalyzeState.Session["player_handout"]) + if m.Stages["analyze"].AnalyzeArtifacts["player_handout"].Status != manifest.AnalyzeArtifactStale { + t.Fatalf("dependent state = %#v, want stale", m.Stages["analyze"].AnalyzeArtifacts["player_handout"]) } } @@ -149,11 +146,10 @@ func TestAnalyzeRebuildsNestedStalePrerequisitesInDependencyOrder(t *testing.T) }, } - first, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } - installAnalyzeProjection(m, first.AnalyzeState) if len(fake.RunRequests) != 2 { t.Fatalf("initial enabled requests = %d, want 2", len(fake.RunRequests)) } @@ -165,7 +161,7 @@ func TestAnalyzeRebuildsNestedStalePrerequisitesInDependencyOrder(t *testing.T) env.SelectedArtifactKeys = []string{"quest_log"} fake.RunRequests = nil - second, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err = (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } @@ -178,8 +174,8 @@ func TestAnalyzeRebuildsNestedStalePrerequisitesInDependencyOrder(t *testing.T) t.Fatalf("prompt order = %#v, want %#v", gotPrompts, wantPrompts) } for _, key := range []string{"session_recap", "player_handout", "quest_log"} { - if second.AnalyzeState.Session[key].Status != manifest.AnalyzeArtifactCurrent { - t.Fatalf("%s state = %#v, want current", key, second.AnalyzeState.Session[key]) + if m.Stages["analyze"].AnalyzeArtifacts[key].Status != manifest.AnalyzeArtifactCurrent { + t.Fatalf("%s state = %#v, want current", key, m.Stages["analyze"].AnalyzeArtifacts[key]) } } } @@ -207,7 +203,7 @@ func TestAnalyzeLegacyExecutionPromotesOnlyEffectiveArtifacts(t *testing.T) { Outputs: []manifest.ArtifactRecord{{Kind: "player_handout", LocalPath: filepath.Join(paths.ArtifactsDir, "player_handout.md")}}, } - result, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } @@ -218,11 +214,11 @@ func TestAnalyzeLegacyExecutionPromotesOnlyEffectiveArtifacts(t *testing.T) { if !reflect.DeepEqual(gotPrompts, test.wantPrompts) { t.Fatalf("prompts = %#v, want %#v", gotPrompts, test.wantPrompts) } - if len(result.AnalyzeState.Session) != len(test.wantPrompts) { - t.Fatalf("session records = %#v, want only regenerated effective artifacts", result.AnalyzeState.Session) + if len(m.Stages["analyze"].AnalyzeArtifacts) != len(test.wantPrompts) { + t.Fatalf("session records = %#v, want only regenerated effective artifacts", m.Stages["analyze"].AnalyzeArtifacts) } if len(test.selected) > 0 { - if _, promoted := result.AnalyzeState.Session["player_handout"]; promoted { + if _, promoted := m.Stages["analyze"].AnalyzeArtifacts["player_handout"]; promoted { t.Fatal("legacy unselected canonical output was promoted") } } @@ -240,10 +236,14 @@ func addAnalyzeDependentArtifact(env *Env) { } } -func installAnalyzeProjection(m *manifest.Manifest, projection *AnalyzeStateProjection) { - if m.Stages["analyze"] == nil { - m.Stages["analyze"] = &manifest.StageRecord{Name: "analyze"} +func assertAnalyzeProjectionMatchesManifest( + t *testing.T, + m *manifest.Manifest, + projection *AnalyzeStateProjection, +) { + t.Helper() + if projection == nil || m.Stages["analyze"] == nil || + !reflect.DeepEqual(projection.Session, m.Stages["analyze"].AnalyzeArtifacts) { + t.Fatalf("projection = %#v, manifest records = %#v", projection, m.Stages["analyze"]) } - m.Stages["analyze"].AnalyzeStateVersion = manifest.AnalyzeStateContractVersion - m.Stages["analyze"].AnalyzeArtifacts = manifest.CloneAnalyzeArtifactCollection(projection.Session) } diff --git a/internal/stage/analyze_plan.go b/internal/stage/analyze_plan.go index 198720a..e0e0990 100644 --- a/internal/stage/analyze_plan.go +++ b/internal/stage/analyze_plan.go @@ -40,7 +40,7 @@ type analyzeWorkPlan struct { ReusedCurrent []analyzePlanItem Invalidated []analyzePlanItem Removed []analyzePlanItem - ProjectedRecords map[string]manifest.AnalyzeArtifactRecord + StartingRecords map[string]manifest.AnalyzeArtifactRecord } // planAnalyzeWork turns selection and reconciliation into one deterministic, @@ -87,9 +87,9 @@ func planAnalyzeWork( sort.Slice(removed, func(i, j int) bool { return removed[i].Key < removed[j].Key }) plan := analyzeWorkPlan{ - ExplicitTargets: append([]string(nil), targets...), - Removed: removed, - ProjectedRecords: projectedAnalyzeRecords(scriptoriumCfg.Artifacts, reconciliation), + ExplicitTargets: append([]string(nil), targets...), + Removed: removed, + StartingRecords: reconciledAnalyzeRecords(scriptoriumCfg.Artifacts, reconciliation), } invalidated := make(map[string]struct{}) for _, state := range reconciliation.Ordered { @@ -142,7 +142,7 @@ func planAnalyzeWork( plan.Invalidated = append(plan.Invalidated, item) invalidated[key] = struct{}{} } - staleProjectedAnalyzeRecord(plan.ProjectedRecords, key) + staleAnalyzeRecord(plan.StartingRecords, key) } continue } @@ -183,11 +183,11 @@ func analyzeTargetDependencyClosure( return closure, nil } -func projectedAnalyzeRecords( +func reconciledAnalyzeRecords( configured map[string]config.ScriptoriumArtifactConfig, reconciliation analyzeReconciliation, ) map[string]manifest.AnalyzeArtifactRecord { - projected := make(map[string]manifest.AnalyzeArtifactRecord) + records := make(map[string]manifest.AnalyzeArtifactRecord) for _, item := range reconciliation.Ordered { if item.Stored == nil || item.Reason == analyzeReconciliationRemoved { continue @@ -202,15 +202,15 @@ func projectedAnalyzeRecords( ); err != nil { continue } - projected[item.Key] = record + records[item.Key] = record if item.Reason != analyzeReconciliationCurrent && record.Status == manifest.AnalyzeArtifactCurrent { - staleProjectedAnalyzeRecord(projected, item.Key) + staleAnalyzeRecord(records, item.Key) } } - return manifest.CloneAnalyzeArtifactCollection(projected) + return manifest.CloneAnalyzeArtifactCollection(records) } -func staleProjectedAnalyzeRecord(records map[string]manifest.AnalyzeArtifactRecord, key string) { +func staleAnalyzeRecord(records map[string]manifest.AnalyzeArtifactRecord, key string) { record, ok := records[key] if !ok || record.Status != manifest.AnalyzeArtifactCurrent { return diff --git a/internal/stage/analyze_plan_test.go b/internal/stage/analyze_plan_test.go index c0c0e79..b07c164 100644 --- a/internal/stage/analyze_plan_test.go +++ b/internal/stage/analyze_plan_test.go @@ -203,7 +203,7 @@ func TestPlanAnalyzeWorkRejectsUnknownSelectionAndInvalidGraphs(t *testing.T) { } } -func TestPlanAnalyzeWorkProjectionPreservesOnlyValidConfiguredAuthority(t *testing.T) { +func TestPlanAnalyzeWorkStartingRecordsPreserveOnlyValidConfiguredAuthority(t *testing.T) { configured := map[string]config.ScriptoriumArtifactConfig{ "target": {Enabled: true}, "unrelated": {Enabled: true}, "invalid_unselected": {Enabled: true}, } @@ -228,14 +228,14 @@ func TestPlanAnalyzeWorkProjectionPreservesOnlyValidConfiguredAuthority(t *testi if err != nil { t.Fatal(err) } - if got := plan.ProjectedRecords["unrelated"].Status; got != manifest.AnalyzeArtifactCurrent { + if got := plan.StartingRecords["unrelated"].Status; got != manifest.AnalyzeArtifactCurrent { t.Fatalf("unrelated status = %q, want current", got) } - if got := plan.ProjectedRecords["invalid_unselected"].Status; got != manifest.AnalyzeArtifactStale { + if got := plan.StartingRecords["invalid_unselected"].Status; got != manifest.AnalyzeArtifactStale { t.Fatalf("invalid unselected status = %q, want stale", got) } - if _, exists := plan.ProjectedRecords["removed"]; exists { - t.Fatal("removed record survived projected authority") + if _, exists := plan.StartingRecords["removed"]; exists { + t.Fatal("removed record survived reconciled authority") } assertAnalyzePlanKeys(t, "invalidated", plan.Invalidated, []string{"invalid_unselected"}) assertAnalyzePlanKeys(t, "removed", plan.Removed, []string{"removed"}) @@ -256,8 +256,8 @@ func TestPlanAnalyzeWorkNonResumablePartialSelectionDoesNotPromoteUnselectedOutp t.Fatal(err) } assertAnalyzePlanKeys(t, "execution", plan.ExecutionOrder, []string{"target"}) - if len(plan.ProjectedRecords) != 0 { - t.Fatalf("non-resumable projection = %#v, want no current records", plan.ProjectedRecords) + if len(plan.StartingRecords) != 0 { + t.Fatalf("non-resumable starting records = %#v, want no current records", plan.StartingRecords) } } diff --git a/internal/stage/analyze_resume_test.go b/internal/stage/analyze_resume_test.go index 8ad9939..3020e43 100644 --- a/internal/stage/analyze_resume_test.go +++ b/internal/stage/analyze_resume_test.go @@ -165,11 +165,10 @@ func TestAnalyzeResumeSemanticChangesRemainArtifactGranular(t *testing.T) { "transcript": {Source: "narratio.transcript.final_trimmed", Required: true}, }, } - result, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } - installAnalyzeProjection(m, result.AnalyzeState) m.Stages["analyze"].Status = manifest.StatusSucceeded test.mutate(env.Config.Pipeline.Scriptorium) @@ -210,8 +209,8 @@ func TestAnalyzeStaleAggregateRestoresSuccessWithoutAdapterWork(t *testing.T) { if got, _ := result.Metadata["executed_artifacts"].([]string); len(got) != 0 { t.Fatalf("executed artifacts = %#v, want none", got) } - if result.AnalyzeState.Session["session_recap"].Status != manifest.AnalyzeArtifactCurrent { - t.Fatalf("session projection = %#v, want current recap", result.AnalyzeState.Session) + if m.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent { + t.Fatalf("session records = %#v, want current recap", m.Stages["analyze"].AnalyzeArtifacts) } } @@ -223,11 +222,10 @@ func currentAnalyzeFixture(t *testing.T, dependent bool) (*Env, *manifest.Manife if dependent { addAnalyzeDependentArtifact(env) } - result, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatal(err) } - installAnalyzeProjection(m, result.AnalyzeState) m.Stages["analyze"].Status = manifest.StatusSucceeded return env, m, len(fake.RunRequests) } diff --git a/internal/stage/analyze_test.go b/internal/stage/analyze_test.go index ed564e4..76a3e4d 100644 --- a/internal/stage/analyze_test.go +++ b/internal/stage/analyze_test.go @@ -62,8 +62,8 @@ func TestAnalyzeGeneratesSessionRecapFromTrimmedTranscript(t *testing.T) { t.Fatalf("session_id var = %q, want sticky narratio session id", req.Vars["session_id"]) } - if result.AnalyzeState == nil || result.AnalyzeState.Session["session_recap"].Status != manifest.AnalyzeArtifactCurrent { - t.Fatalf("analyze state = %#v, want current session_recap", result.AnalyzeState) + if m.Stages["analyze"].AnalyzeArtifacts["session_recap"].Status != manifest.AnalyzeArtifactCurrent { + t.Fatalf("analyze state = %#v, want current session_recap", m.Stages["analyze"].AnalyzeArtifacts) } if len(result.Logs) != 2 { t.Fatalf("logs = %#v, want stdout+stderr logs", result.Logs) @@ -306,7 +306,7 @@ func TestAnalyzeUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) { writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) setCurrentBuiltInEvidence(t, m, "trim", artifacts.TranscriptOutputKindFinalTrimmed, artifacts.ArtifactTranscriptFinalTrimmed, filepath.Join(paths.TranscriptsDir, "final.trimmed.json")) - result, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatalf("Run() error = %v", err) } @@ -316,8 +316,8 @@ func TestAnalyzeUsesRunLocalPathsAndMaterializesCanonical(t *testing.T) { if !strings.Contains(fake.RunRequests[0].OutputPath, filepath.Join("runs", m.RunID, "analyze", "outputs")) { t.Fatalf("run output path = %q, want run-local path", fake.RunRequests[0].OutputPath) } - if result.AnalyzeState == nil || result.AnalyzeState.Session["session_recap"].Output == nil { - t.Fatalf("analyze state = %#v, want session recap output evidence", result.AnalyzeState) + if m.Stages["analyze"].AnalyzeArtifacts["session_recap"].Output == nil { + t.Fatalf("analyze state = %#v, want session recap output evidence", m.Stages["analyze"].AnalyzeArtifacts) } if err := requireNonEmptyFile(filepath.Join(paths.ArtifactsDir, "session_recap.md"), "materialized output"); err != nil { t.Fatalf("canonical output = %v", err) @@ -676,7 +676,7 @@ func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) { } env.SelectedArtifactKeys = []string{"player_handout"} - result, err := (analyzeStage{}).Run(context.Background(), env, m) + _, err := (analyzeStage{}).Run(context.Background(), env, m) if err != nil { t.Fatalf("Run() error = %v", err) } @@ -686,8 +686,8 @@ func TestAnalyzeAppliesSelectedArtifactsFilter(t *testing.T) { if fake.RunRequests[0].PromptID != "dnd.player_handout" { t.Fatalf("prompt id = %q, want dnd.player_handout", fake.RunRequests[0].PromptID) } - if result.AnalyzeState == nil || len(result.AnalyzeState.Session) != 1 || result.AnalyzeState.Session["player_handout"].Status != manifest.AnalyzeArtifactCurrent { - t.Fatalf("analyze state = %#v, want only current player_handout", result.AnalyzeState) + if len(m.Stages["analyze"].AnalyzeArtifacts) != 1 || m.Stages["analyze"].AnalyzeArtifacts["player_handout"].Status != manifest.AnalyzeArtifactCurrent { + t.Fatalf("analyze state = %#v, want only current player_handout", m.Stages["analyze"].AnalyzeArtifacts) } } @@ -1391,7 +1391,7 @@ func TestAnalyzeRecordsRefsAndMetadata(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - record, ok := result.AnalyzeState.Session["session_recap"] + record, ok := m.Stages["analyze"].AnalyzeArtifacts["session_recap"] if !ok || record.Output == nil || record.Output.LocalPath != "artifacts/session_recap.md" { t.Fatalf("analyze output record = %#v, want session recap evidence", record) } @@ -1409,6 +1409,47 @@ func TestAnalyzeRecordsRefsAndMetadata(t *testing.T) { } } +func TestAnalyzeArtifactRefsPreserveCurrentRecordIdentityInKeyOrder(t *testing.T) { + contract := &artifactmodel.ContractMetadata{ + MediaType: "text/markdown", SchemaID: "narratio.recap", SchemaVersion: "1", + } + records := map[string]manifest.AnalyzeArtifactRecord{ + "zeta": { + Key: "zeta", Status: manifest.AnalyzeArtifactCurrent, ProducerRunID: "record-run", + Output: &manifest.ArtifactRecord{ + Kind: "scriptorium_artifact", SourceID: "narratio.artifact.zeta", + LocalPath: "artifacts/zeta.md", Checksum: strings.Repeat("a", 64), Contract: contract, + }, + }, + "alpha": { + Key: "alpha", Status: manifest.AnalyzeArtifactCurrent, ProducerRunID: "record-fallback", + Output: &manifest.ArtifactRecord{ + Kind: "scriptorium_artifact", SourceID: "narratio.artifact.alpha", + LocalPath: "artifacts/alpha.md", Checksum: strings.Repeat("b", 64), + Contract: contract, ProducerRunID: "output-run", + }, + }, + "stale": { + Key: "stale", Status: manifest.AnalyzeArtifactStale, + }, + } + + refs := analyzeArtifactRefs(records) + if len(refs) != 2 || refs[0].SourceID != "narratio.artifact.alpha" || refs[1].SourceID != "narratio.artifact.zeta" { + t.Fatalf("refs = %#v, want alpha then zeta current records", refs) + } + if refs[0].ProducerRunID != "output-run" || refs[1].ProducerRunID != "record-run" { + t.Fatalf("producer run ids = [%q %q], want output and record identities", refs[0].ProducerRunID, refs[1].ProducerRunID) + } + if refs[0].RelativePath != "artifacts/alpha.md" || refs[0].Checksum != strings.Repeat("b", 64) || + refs[0].Contract == nil || *refs[0].Contract != *contract { + t.Fatalf("alpha ref = %#v, want preserved output evidence", refs[0]) + } + if refs[0].Contract == contract { + t.Fatal("artifact ref contract aliases manifest record") + } +} + func TestAnalyzeHandlesAdapterError(t *testing.T) { env, m, fake := setupAnalyzeEnv(t) paths := sessionPathsForEnv(env, m.SessionID)