From c32e0c401f72f8f6979ce875cf3cfc36f2a5c838 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 29 Aug 2026 19:18:28 +0000 Subject: [PATCH] Add versioned analysis fingerprint reconciliation --- docs/config.md | 2 +- docs/internal/stage-analyze.md | 25 +- docs/roadmap/implementation.md | 2 + internal/config/scriptorium_test.go | 22 +- internal/config/validate.go | 48 ++- internal/stage/analyze_fingerprint.go | 344 ++++++++++++++++++ internal/stage/analyze_fingerprint_test.go | 312 ++++++++++++++++ internal/stage/analyze_reconciliation.go | 201 ++++++++++ internal/stage/analyze_reconciliation_test.go | 242 ++++++++++++ 9 files changed, 1177 insertions(+), 21 deletions(-) create mode 100644 internal/stage/analyze_fingerprint.go create mode 100644 internal/stage/analyze_fingerprint_test.go create mode 100644 internal/stage/analyze_reconciliation.go create mode 100644 internal/stage/analyze_reconciliation_test.go diff --git a/docs/config.md b/docs/config.md index be9f8ed..c1d9099 100644 --- a/docs/config.md +++ b/docs/config.md @@ -310,7 +310,7 @@ For each `pipeline.scriptorium.artifacts.`: | Field | Type | Required | Rule | | --- | --- | --- | --- | | `enabled` | bool | No | `false` if omitted | -| `depends_on[]` | list[string] | No | must reference configured artifact keys; no self-reference; enabled graph must be acyclic | +| `depends_on[]` | list[string] | No | must reference configured artifact keys; no self-reference; configured graph must be acyclic | | `render_debug` | bool | No | per-artifact override | | `prompt_id` | string | Conditional | required when artifact is enabled | | `profile_id` | string | No | empty | diff --git a/docs/internal/stage-analyze.md b/docs/internal/stage-analyze.md index 554abce..ac787f1 100644 --- a/docs/internal/stage-analyze.md +++ b/docs/internal/stage-analyze.md @@ -59,6 +59,20 @@ Supported source families: configured-artifact evidence. Other resolved inputs are hashed as confined regular files with streaming reads and the central resolved-artifact size limit. +- owns a versioned SHA-256 fingerprint contract with one fixed-field canonical + JSON payload and no map serialization. Configured artifacts are fingerprinted + in deterministic dependency order. +- fingerprints the normalized artifact key, prompt and profile identifiers, + normalized Scriptorium executable and config logical identities, effective + render-debug behavior, session-relative output identity, sorted dependency + keys, ordered input declarations and semantic identities, validated current + dependency-output identities, and sorted effective Scriptorium variables + (including Narratio's sticky session variable). +- provides read-only reconciliation that classifies each configured record as + current, stale, missing, failed, legacy, or otherwise non-resumable, and + separately identifies manifest records removed from current configuration. + A record is current only when its fingerprint version and value match and its + configured output still passes manifest-authoritative evidence validation. - resolves previous-session sources from local `previous/` cache only. - runs optional render-debug, then artifact execution. - validates non-empty output files and materializes canonical outputs. @@ -79,6 +93,11 @@ Supported source families: - `analyze` performs no remote storage calls for previous-session source resolution. - input-identity resolution is read-only: it does not invoke adapters, materialize outputs, update status, or create run records. +- fingerprints exclude timeouts, retries, timestamps, producer and Narratio run + IDs, absolute executable/config/workspace roots, diagnostic locations, and + executable or private transitive configuration contents. A change that is + visible only inside Scriptorium—such as a file privately loaded by its config + path—requires an explicit forced regeneration. - output provenance and metadata are deterministic per execution. ## Related Contracts And Tests @@ -89,4 +108,8 @@ Supported source families: - [Scriptorium](../integrations/scriptorium.md) owns the subprocess contract. - Implementation and tests: `internal/stage/analyze.go`, `internal/stage/analyze_input_identity.go`, `internal/stage/analyze_test.go`, - `internal/stage/analyze_input_identity_test.go` + `internal/stage/analyze_input_identity_test.go`, + `internal/stage/analyze_fingerprint.go`, + `internal/stage/analyze_fingerprint_test.go`, + `internal/stage/analyze_reconciliation.go`, and + `internal/stage/analyze_reconciliation_test.go` diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 111b226..7ba3288 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -491,6 +491,8 @@ producer runs. ## Stage 10 — Versioned Analysis Fingerprints And Reconciliation +**Status: Completed** + ### Goal Classify configured artifacts as current or requiring work from one deterministic diff --git a/internal/config/scriptorium_test.go b/internal/config/scriptorium_test.go index 9a7be2c..6c8e3ec 100644 --- a/internal/config/scriptorium_test.go +++ b/internal/config/scriptorium_test.go @@ -451,7 +451,7 @@ func TestScriptoriumLoadAndValidate(t *testing.T) { wantValidateErr: "pipeline.scriptorium.artifacts.session_recap.depends_on must not include itself", }, { - name: "enabled dependency cycle fails validation", + name: "configured dependency cycle fails validation", scriptoriumYAML: `scriptorium: binary: scriptorium artifacts: @@ -476,7 +476,25 @@ func TestScriptoriumLoadAndValidate(t *testing.T) { source: narratio.artifact.artifact_a required: true `, - wantValidateErr: "pipeline.scriptorium.artifacts enabled dependencies must not contain cycles", + wantValidateErr: "pipeline.scriptorium.artifacts dependencies must not contain cycles", + }, + { + name: "disabled dependency cycle fails validation", + scriptoriumYAML: `scriptorium: + binary: scriptorium + artifacts: + artifact_a: + enabled: false + depends_on: + - artifact_b + output_path: artifacts/a.md + artifact_b: + enabled: false + depends_on: + - artifact_a + output_path: artifacts/b.md +`, + wantValidateErr: "pipeline.scriptorium.artifacts dependencies must not contain cycles", }, { name: "artifact source typo fails validation", diff --git a/internal/config/validate.go b/internal/config/validate.go index fae1e48..be6db2c 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -761,7 +761,7 @@ func validateScriptorium(cfg *ScriptoriumConfig, notarius *NotariusConfig) error } } - if err := validateEnabledArtifactDependencyCycles(cfg.Artifacts); err != nil { + if err := ValidateScriptoriumArtifactDependencies(cfg.Artifacts); err != nil { return err } @@ -983,38 +983,47 @@ func validatePathWithinRoot(fieldName, value, root string) error { return fmt.Errorf("%s must be under %s/", fieldName, normalizedRoot) } -func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArtifactConfig) error { +// ValidateScriptoriumArtifactDependencies validates the configured dependency +// graph independently of execution selection. Disabled artifacts remain valid +// prerequisites for explicit selections and therefore participate in cycles. +func ValidateScriptoriumArtifactDependencies(artifacts map[string]ScriptoriumArtifactConfig) error { if len(artifacts) == 0 { return nil } - enabled := make(map[string]struct{}, len(artifacts)) graph := make(map[string][]string, len(artifacts)) for name, cfg := range artifacts { - if !cfg.Enabled { - continue - } - enabled[name] = struct{}{} - } - for name, cfg := range artifacts { - if !cfg.Enabled { - continue + if !artifactpolicy.IsConfiguredKey(name) { + return fmt.Errorf("pipeline.scriptorium.artifacts keys must match ^[a-z][a-z0-9_]*$") } + seen := make(map[string]struct{}, len(cfg.DependsOn)) for _, dep := range cfg.DependsOn { trimmedDep := strings.TrimSpace(dep) - if _, ok := enabled[trimmedDep]; ok { - graph[name] = append(graph[name], trimmedDep) + if trimmedDep == "" { + return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on entries must be non-empty", name) } + if _, ok := artifacts[trimmedDep]; !ok { + return fmt.Errorf("pipeline.scriptorium.artifacts.%s dependency %q is not configured", name, dep) + } + if trimmedDep == name { + return fmt.Errorf("pipeline.scriptorium.artifacts.%s.depends_on must not include itself", name) + } + if _, duplicate := seen[trimmedDep]; duplicate { + continue + } + seen[trimmedDep] = struct{}{} + graph[name] = append(graph[name], trimmedDep) } + sort.Strings(graph[name]) } - visiting := make(map[string]bool, len(enabled)) - visited := make(map[string]bool, len(enabled)) + visiting := make(map[string]bool, len(artifacts)) + visited := make(map[string]bool, len(artifacts)) var visit func(node string) error visit = func(node string) error { if visiting[node] { - return fmt.Errorf("pipeline.scriptorium.artifacts enabled dependencies must not contain cycles") + return fmt.Errorf("pipeline.scriptorium.artifacts dependencies must not contain cycles") } if visited[node] { return nil @@ -1030,7 +1039,12 @@ func validateEnabledArtifactDependencyCycles(artifacts map[string]ScriptoriumArt return nil } - for node := range enabled { + nodes := make([]string, 0, len(artifacts)) + for node := range artifacts { + nodes = append(nodes, node) + } + sort.Strings(nodes) + for _, node := range nodes { if err := visit(node); err != nil { return err } diff --git a/internal/stage/analyze_fingerprint.go b/internal/stage/analyze_fingerprint.go new file mode 100644 index 0000000..dde221f --- /dev/null +++ b/internal/stage/analyze_fingerprint.go @@ -0,0 +1,344 @@ +package stage + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + + "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/pathsafe" +) + +type analyzeFingerprintCandidate struct { + Key string + Fingerprint string + Inputs resolvedAnalyzeInputs + DependencyOutputs []analyzeDependencyOutputIdentity + Err error +} + +type analyzeFingerprintSet struct { + Ordered []analyzeFingerprintCandidate +} + +func (s analyzeFingerprintSet) Lookup(key string) (analyzeFingerprintCandidate, bool) { + for _, candidate := range s.Ordered { + if candidate.Key == key { + return candidate, true + } + } + return analyzeFingerprintCandidate{}, false +} + +type analyzeDependencyOutputIdentity struct { + Key string + SourceID string + LogicalID string + Contract analyzeInputContract + Checksum string + Size int64 +} + +// analyzeFingerprintPayload is the canonical serialization contract used only +// as SHA-256 input. It contains slices and fixed-field structs, never maps. +type analyzeFingerprintPayload struct { + Version int `json:"version"` + Scriptorium analyzeFingerprintScriptorium `json:"scriptorium"` + Artifact analyzeFingerprintArtifact `json:"artifact"` + Inputs []analyzeFingerprintInput `json:"inputs"` + DependencyOutputs []analyzeFingerprintDependencyOutput `json:"dependency_outputs"` + Variables []analyzeFingerprintVariable `json:"variables"` +} + +type analyzeFingerprintScriptorium struct { + ExecutableIdentity string `json:"executable_identity"` + ConfigIdentity string `json:"config_identity"` +} + +type analyzeFingerprintArtifact struct { + Key string `json:"key"` + PromptID string `json:"prompt_id"` + ProfileID string `json:"profile_id"` + EffectiveRenderDebug bool `json:"effective_render_debug"` + OutputIdentity string `json:"output_identity"` + Dependencies []string `json:"dependencies"` +} + +type analyzeFingerprintInput struct { + Name string `json:"name"` + SourceID string `json:"source_id"` + Required bool `json:"required"` + Present bool `json:"present"` + LogicalID string `json:"logical_id"` + Contract analyzeFingerprintContract `json:"contract"` + Checksum string `json:"checksum"` + Size int64 `json:"size"` +} + +type analyzeFingerprintDependencyOutput struct { + Key string `json:"key"` + SourceID string `json:"source_id"` + LogicalID string `json:"logical_id"` + Contract analyzeFingerprintContract `json:"contract"` + Checksum string `json:"checksum"` + Size int64 `json:"size"` +} + +type analyzeFingerprintContract struct { + OutputKind string `json:"output_kind"` + ManifestKind string `json:"manifest_kind"` + MediaType string `json:"media_type"` + SchemaID string `json:"schema_id"` + SchemaVersion string `json:"schema_version"` + ModuleKey string `json:"module_key"` +} + +type analyzeFingerprintVariable struct { + Name string `json:"name"` + Value string `json:"value"` +} + +func computeAnalyzeFingerprints( + scriptoriumCfg *config.ScriptoriumConfig, + execution analyzeExecutionContext, +) (analyzeFingerprintSet, error) { + if scriptoriumCfg == nil { + return analyzeFingerprintSet{}, nil + } + if err := config.ValidateScriptoriumArtifactDependencies(scriptoriumCfg.Artifacts); err != nil { + return analyzeFingerprintSet{}, err + } + order, err := orderConfiguredAnalyzeArtifacts(scriptoriumCfg.Artifacts) + if err != nil { + return analyzeFingerprintSet{}, err + } + result := analyzeFingerprintSet{Ordered: make([]analyzeFingerprintCandidate, 0, len(order))} + for _, key := range order { + candidate := analyzeFingerprintCandidate{Key: key} + candidate.Fingerprint, candidate.Inputs, candidate.DependencyOutputs, candidate.Err = + computeAnalyzeArtifactFingerprint(key, scriptoriumCfg, execution) + result.Ordered = append(result.Ordered, candidate) + } + return result, nil +} + +func computeAnalyzeArtifactFingerprint( + key string, + scriptoriumCfg *config.ScriptoriumConfig, + execution analyzeExecutionContext, +) (string, resolvedAnalyzeInputs, []analyzeDependencyOutputIdentity, error) { + artifactCfg, ok := scriptoriumCfg.Artifacts[key] + if !ok { + return "", resolvedAnalyzeInputs{}, nil, fmt.Errorf("configured artifact %q is not defined", key) + } + inputs, err := resolveAnalyzeInputIdentities(artifactCfg.Inputs, execution) + if err != nil { + return "", resolvedAnalyzeInputs{}, nil, err + } + dependencies := normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn) + dependencyOutputs := make([]analyzeDependencyOutputIdentity, 0, len(dependencies)) + for _, dependency := range dependencies { + sourceID := artifacts.ConfiguredArtifactSourceID(dependency) + identity, _, _, resolveErr := resolveAnalyzeInputIdentity( + dependency, + config.ScriptoriumInputConfig{Source: sourceID, Required: true}, + execution, + ) + if resolveErr != nil { + return "", inputs, dependencyOutputs, fmt.Errorf("resolve dependency %q: %w", dependency, resolveErr) + } + dependencyOutputs = append(dependencyOutputs, analyzeDependencyOutputIdentity{ + Key: dependency, SourceID: identity.SourceID, LogicalID: identity.LogicalID, + Contract: identity.Contract, Checksum: identity.Checksum, Size: identity.Size, + }) + } + + variables, err := effectiveAnalyzeFingerprintVariables(artifactCfg.Vars, execution) + if err != nil { + return "", inputs, dependencyOutputs, err + } + outputIdentity, err := normalizedAnalyzeOutputIdentity(artifactCfg.OutputPath) + if err != nil { + return "", inputs, dependencyOutputs, err + } + payload := analyzeFingerprintPayload{ + Version: manifest.AnalyzeFingerprintContractVersion, + Scriptorium: analyzeFingerprintScriptorium{ + ExecutableIdentity: normalizedAnalyzeExternalPathIdentity(scriptoriumCfg.Binary), + ConfigIdentity: normalizedAnalyzeExternalPathIdentity(scriptoriumCfg.ConfigPath), + }, + Artifact: analyzeFingerprintArtifact{ + Key: key, PromptID: artifactCfg.PromptID, ProfileID: artifactCfg.ProfileID, + EffectiveRenderDebug: resolveRenderDebugEnabled(scriptoriumCfg.RenderDebug, artifactCfg.RenderDebug), + OutputIdentity: outputIdentity, Dependencies: dependencies, + }, + Inputs: canonicalAnalyzeFingerprintInputs(inputs.Ordered), + DependencyOutputs: canonicalAnalyzeFingerprintDependencies(dependencyOutputs), + Variables: variables, + } + fingerprint, err := hashAnalyzeFingerprintPayload(payload) + if err != nil { + return "", inputs, dependencyOutputs, fmt.Errorf("serialize analysis fingerprint for %q: %w", key, err) + } + return fingerprint, inputs, dependencyOutputs, nil +} + +func hashAnalyzeFingerprintPayload(payload analyzeFingerprintPayload) (string, error) { + serialized, err := json.Marshal(payload) + if err != nil { + return "", err + } + digest := sha256.Sum256(serialized) + return hex.EncodeToString(digest[:]), nil +} + +func orderConfiguredAnalyzeArtifacts( + configured map[string]config.ScriptoriumArtifactConfig, +) ([]string, error) { + indegree := make(map[string]int, len(configured)) + edges := make(map[string][]string, len(configured)) + for key := range configured { + indegree[key] = 0 + } + for key, artifactCfg := range configured { + for _, dependency := range normalizedAnalyzeDependencyKeys(artifactCfg.DependsOn) { + if _, ok := configured[dependency]; !ok { + return nil, fmt.Errorf("configured artifact %q depends on unknown artifact %q", key, dependency) + } + edges[dependency] = append(edges[dependency], key) + indegree[key]++ + } + } + for key := range edges { + sort.Strings(edges[key]) + } + ready := make([]string, 0, len(indegree)) + for key, degree := range indegree { + if degree == 0 { + ready = append(ready, key) + } + } + sort.Strings(ready) + order := make([]string, 0, len(indegree)) + for len(ready) > 0 { + key := ready[0] + ready = ready[1:] + order = append(order, key) + for _, dependent := range edges[key] { + indegree[dependent]-- + if indegree[dependent] == 0 { + ready = append(ready, dependent) + sort.Strings(ready) + } + } + } + if len(order) != len(configured) { + return nil, fmt.Errorf("pipeline.scriptorium.artifacts dependencies must not contain cycles") + } + return order, nil +} + +func normalizedAnalyzeDependencyKeys(values []string) []string { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + seen[trimmed] = struct{}{} + } + } + result := make([]string, 0, len(seen)) + for value := range seen { + result = append(result, value) + } + sort.Strings(result) + return result +} + +func normalizedAnalyzeExternalPathIdentity(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + cleaned := filepath.Clean(trimmed) + if filepath.IsAbs(cleaned) { + return filepath.Base(cleaned) + } + return filepath.ToSlash(cleaned) +} + +func normalizedAnalyzeOutputIdentity(value string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", nil + } + normalized, err := pathsafe.NormalizeRelativeDestination(filepath.ToSlash(trimmed)) + if err != nil { + return "", fmt.Errorf("normalize configured output identity %q: %w", value, err) + } + return normalized, nil +} + +func effectiveAnalyzeFingerprintVariables( + configured map[string]any, + execution analyzeExecutionContext, +) ([]analyzeFingerprintVariable, error) { + if execution.Env == nil || execution.Env.Config == nil { + return nil, fmt.Errorf("analysis fingerprint requires resolved stage configuration") + } + variables, err := buildScriptoriumVars(configured, execution.Env.Config.Session) + if err != nil { + return nil, err + } + variables = withScriptoriumStickySessionVar(variables, execution.SessionID) + keys := make([]string, 0, len(variables)) + for key := range variables { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]analyzeFingerprintVariable, 0, len(keys)) + for _, key := range keys { + result = append(result, analyzeFingerprintVariable{Name: key, Value: variables[key]}) + } + return result, nil +} + +func canonicalAnalyzeFingerprintInputs(values []analyzeInputIdentity) []analyzeFingerprintInput { + result := make([]analyzeFingerprintInput, 0, len(values)) + for _, value := range values { + result = append(result, analyzeFingerprintInput{ + Name: value.Name, SourceID: value.SourceID, Required: value.Required, + Present: value.Present, LogicalID: value.LogicalID, + Contract: canonicalAnalyzeFingerprintContract(value.Contract), + Checksum: value.Checksum, Size: value.Size, + }) + } + return result +} + +func canonicalAnalyzeFingerprintDependencies( + values []analyzeDependencyOutputIdentity, +) []analyzeFingerprintDependencyOutput { + result := make([]analyzeFingerprintDependencyOutput, 0, len(values)) + for _, value := range values { + result = append(result, analyzeFingerprintDependencyOutput{ + Key: value.Key, SourceID: value.SourceID, LogicalID: value.LogicalID, + Contract: canonicalAnalyzeFingerprintContract(value.Contract), + Checksum: value.Checksum, Size: value.Size, + }) + } + return result +} + +func canonicalAnalyzeFingerprintContract(value analyzeInputContract) analyzeFingerprintContract { + return analyzeFingerprintContract{ + OutputKind: value.OutputKind, ManifestKind: value.ManifestKind, + MediaType: value.MediaType, SchemaID: value.SchemaID, + SchemaVersion: value.SchemaVersion, ModuleKey: value.ModuleKey, + } +} diff --git a/internal/stage/analyze_fingerprint_test.go b/internal/stage/analyze_fingerprint_test.go new file mode 100644 index 0000000..25b320f --- /dev/null +++ b/internal/stage/analyze_fingerprint_test.go @@ -0,0 +1,312 @@ +package stage + +import ( + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestAnalyzeFingerprintCanonicalPayloadSensitivity(t *testing.T) { + tests := []struct { + name string + mutate func(*analyzeFingerprintPayload) + }{ + {name: "version", mutate: func(p *analyzeFingerprintPayload) { p.Version++ }}, + {name: "scriptorium executable", mutate: func(p *analyzeFingerprintPayload) { p.Scriptorium.ExecutableIdentity = "other" }}, + {name: "scriptorium config", mutate: func(p *analyzeFingerprintPayload) { p.Scriptorium.ConfigIdentity = "other.yml" }}, + {name: "artifact key", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.Key = "other" }}, + {name: "prompt", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.PromptID = "prompt.changed" }}, + {name: "profile", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.ProfileID = "profile.changed" }}, + {name: "render debug", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.EffectiveRenderDebug = !p.Artifact.EffectiveRenderDebug }}, + {name: "output identity", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.OutputIdentity = "artifacts/changed.md" }}, + {name: "dependency keys", mutate: func(p *analyzeFingerprintPayload) { p.Artifact.Dependencies[0] = "other" }}, + {name: "input name", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Name = "other" }}, + {name: "input source", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].SourceID = "narratio.transcript.final" }}, + {name: "input required", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Required = !p.Inputs[0].Required }}, + {name: "input presence", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Present = !p.Inputs[0].Present }}, + {name: "input logical identity", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].LogicalID = "other" }}, + {name: "input output kind", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.OutputKind = "other" }}, + {name: "input manifest kind", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.ManifestKind = "other" }}, + {name: "input media type", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.MediaType = "text/plain" }}, + {name: "input schema id", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.SchemaID = "other" }}, + {name: "input schema version", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.SchemaVersion = "2" }}, + {name: "input module key", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Contract.ModuleKey = "other" }}, + {name: "input checksum", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Checksum = strings.Repeat("b", 64) }}, + {name: "input size", mutate: func(p *analyzeFingerprintPayload) { p.Inputs[0].Size++ }}, + {name: "dependency key", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Key = "other" }}, + {name: "dependency source", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].SourceID = "narratio.artifact.other" }}, + {name: "dependency logical identity", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].LogicalID = "other" }}, + {name: "dependency output kind", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.OutputKind = "other" }}, + {name: "dependency manifest kind", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.ManifestKind = "other" }}, + {name: "dependency media type", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.MediaType = "text/plain" }}, + {name: "dependency schema id", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.SchemaID = "other" }}, + {name: "dependency schema version", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.SchemaVersion = "2" }}, + {name: "dependency module key", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Contract.ModuleKey = "other" }}, + {name: "dependency checksum", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Checksum = strings.Repeat("c", 64) }}, + {name: "dependency size", mutate: func(p *analyzeFingerprintPayload) { p.DependencyOutputs[0].Size++ }}, + {name: "variable name", mutate: func(p *analyzeFingerprintPayload) { p.Variables[0].Name = "other" }}, + {name: "variable value", mutate: func(p *analyzeFingerprintPayload) { p.Variables[0].Value = "other" }}, + } + + baseline, err := hashAnalyzeFingerprintPayload(analyzeFingerprintPayloadFixture()) + if err != nil { + t.Fatal(err) + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + payload := analyzeFingerprintPayloadFixture() + tt.mutate(&payload) + got, err := hashAnalyzeFingerprintPayload(payload) + if err != nil { + t.Fatal(err) + } + if got == baseline { + t.Fatalf("fingerprint did not change after %s mutation", tt.name) + } + }) + } +} + +func TestComputeAnalyzeFingerprintsIsDeterministicAcrossIncidentalDifferences(t *testing.T) { + baseline := computeFingerprintFixture(t, fingerprintFixtureOptions{ + producerRunID: "run-a", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", + topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC(), reverseMaps: false, + }) + tests := []struct { + name string + options fingerprintFixtureOptions + }{ + {name: "workspace relocation", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC()}}, + {name: "map insertion", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC(), reverseMaps: true}}, + {name: "producer run", options: fingerprintFixtureOptions{producerRunID: "run-b", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC()}}, + {name: "timestamp", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(999, 0).UTC()}}, + {name: "timeouts", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "/opt/a/scriptorium", configPath: "/etc/a/scriptorium.yml", topTimeout: "8m", artifactTimeout: "9m", updatedAt: time.Unix(100, 0).UTC()}}, + {name: "absolute executable and config paths", options: fingerprintFixtureOptions{producerRunID: "run-a", binary: "/srv/b/scriptorium", configPath: "/srv/b/scriptorium.yml", topTimeout: "1m", artifactTimeout: "2m", updatedAt: time.Unix(100, 0).UTC()}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := computeFingerprintFixture(t, tt.options); got != baseline { + t.Fatalf("fingerprint = %q, want stable %q", got, baseline) + } + }) + } +} + +func TestComputeAnalyzeFingerprintsTracksEffectiveConfiguration(t *testing.T) { + baseline := computeFingerprintFixture(t, fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n"}) + tests := []struct { + name string + options fingerprintFixtureOptions + }{ + {name: "prompt", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", promptID: "prompt.changed"}}, + {name: "profile", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", profileID: "profile.changed"}}, + {name: "render debug", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", renderDebug: true}}, + {name: "output", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", outputPath: "artifacts/changed.md"}}, + {name: "effective variable", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", label: "changed"}}, + {name: "executable identity", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", binary: "/opt/bin/other"}}, + {name: "config identity", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", configPath: "/etc/scriptorium/other.yml"}}, + {name: "dependency bytes", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "other\n"}}, + {name: "optional input appears", options: fingerprintFixtureOptions{producerRunID: "run-a", dependencyBody: "notes\n", includePlayers: true}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := computeFingerprintFixture(t, tt.options); got == baseline { + t.Fatalf("fingerprint did not change for %s", tt.name) + } + }) + } +} + +func TestComputeAnalyzeFingerprintsOrdersDependenciesAndRejectsInvalidGraphs(t *testing.T) { + env, m, _ := setupAnalyzeEnv(t) + writeAnalyzeFile(t, filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) + env.Config.Pipeline.Scriptorium.Artifacts = map[string]config.ScriptoriumArtifactConfig{ + "zeta": {DependsOn: []string{"middle"}}, + "alpha": {}, + "middle": {DependsOn: []string{"alpha"}}, + } + fingerprints, err := computeAnalyzeFingerprints(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m)) + if err != nil { + t.Fatal(err) + } + got := make([]string, 0, len(fingerprints.Ordered)) + for _, candidate := range fingerprints.Ordered { + got = append(got, candidate.Key) + } + if want := []string{"alpha", "middle", "zeta"}; !reflect.DeepEqual(got, want) { + t.Fatalf("order = %#v, want %#v", got, want) + } + + tests := []struct { + name string + configured map[string]config.ScriptoriumArtifactConfig + }{ + {name: "unknown", configured: map[string]config.ScriptoriumArtifactConfig{"alpha": {DependsOn: []string{"missing"}}}}, + {name: "cycle", configured: map[string]config.ScriptoriumArtifactConfig{ + "alpha": {DependsOn: []string{"beta"}}, "beta": {DependsOn: []string{"alpha"}}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env.Config.Pipeline.Scriptorium.Artifacts = tt.configured + _, err := computeAnalyzeFingerprints(env.Config.Pipeline.Scriptorium, analyzeExecutionContext{}) + if err == nil { + t.Fatal("computeAnalyzeFingerprints() error = nil, want invalid graph") + } + }) + } +} + +func analyzeFingerprintPayloadFixture() analyzeFingerprintPayload { + contract := analyzeFingerprintContract{ + OutputKind: "notarius_lane", ManifestKind: "lane", MediaType: "application/json", + SchemaID: "encounters", SchemaVersion: "1", ModuleKey: "encounters", + } + return analyzeFingerprintPayload{ + Version: manifest.AnalyzeFingerprintContractVersion, + Scriptorium: analyzeFingerprintScriptorium{ + ExecutableIdentity: "scriptorium", ConfigIdentity: "config.yml", + }, + Artifact: analyzeFingerprintArtifact{ + Key: "session_recap", PromptID: "dnd.session_recap", ProfileID: "local", + EffectiveRenderDebug: true, OutputIdentity: "artifacts/session_recap.md", + Dependencies: []string{"source_notes"}, + }, + Inputs: []analyzeFingerprintInput{{ + Name: "encounters", SourceID: "narratio.extraction.encounters", Required: true, Present: true, + LogicalID: "narratio.extraction.encounters", Contract: contract, + Checksum: strings.Repeat("a", 64), Size: 42, + }}, + DependencyOutputs: []analyzeFingerprintDependencyOutput{{ + Key: "source_notes", SourceID: "narratio.artifact.source_notes", LogicalID: "narratio.artifact.source_notes", + Contract: analyzeFingerprintContract{OutputKind: "scriptorium_artifact", MediaType: "text/markdown", SchemaID: "narratio.source_notes", SchemaVersion: "1"}, + Checksum: strings.Repeat("d", 64), Size: 12, + }}, + Variables: []analyzeFingerprintVariable{{Name: "label", Value: "recap"}}, + } +} + +type fingerprintFixtureOptions struct { + producerRunID string + binary string + configPath string + topTimeout string + artifactTimeout string + updatedAt time.Time + reverseMaps bool + dependencyBody string + promptID string + profileID string + renderDebug bool + outputPath string + label string + includePlayers bool +} + +func computeFingerprintFixture(t *testing.T, options fingerprintFixtureOptions) string { + t.Helper() + env, m, _ := setupAnalyzeEnv(t) + if options.producerRunID == "" { + options.producerRunID = "run-a" + } + if options.dependencyBody == "" { + options.dependencyBody = "notes\n" + } + if options.promptID == "" { + options.promptID = "dnd.session_recap" + } + if options.profileID == "" { + options.profileID = "local-quality" + } + if options.outputPath == "" { + options.outputPath = "artifacts/session_recap.md" + } + if options.label == "" { + options.label = "recap" + } + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) + dependencyPath := filepath.Join(paths.ArtifactsDir, "source_notes.md") + writeAnalyzeFile(t, dependencyPath, options.dependencyBody) + env.Config.Pipeline.Scriptorium.Binary = options.binary + env.Config.Pipeline.Scriptorium.ConfigPath = options.configPath + env.Config.Pipeline.Scriptorium.Timeout = options.topTimeout + env.Config.Pipeline.Scriptorium.RenderDebug = options.renderDebug + env.Config.Pipeline.Scriptorium.Artifacts["source_notes"] = config.ScriptoriumArtifactConfig{ + Enabled: false, OutputPath: "artifacts/source_notes.md", + } + inputs := map[string]config.ScriptoriumInputConfig{} + vars := map[string]any{} + if options.reverseMaps { + inputs["players"] = config.ScriptoriumInputConfig{Source: "narratio.input.players", Required: false} + inputs["notes"] = config.ScriptoriumInputConfig{Source: artifacts.ConfiguredArtifactSourceID("source_notes"), Required: true} + inputs["transcript"] = config.ScriptoriumInputConfig{Source: "narratio.transcript.final_trimmed", Required: true} + vars["session_date"] = true + vars["label"] = options.label + } else { + inputs["transcript"] = config.ScriptoriumInputConfig{Source: "narratio.transcript.final_trimmed", Required: true} + inputs["notes"] = config.ScriptoriumInputConfig{Source: artifacts.ConfiguredArtifactSourceID("source_notes"), Required: true} + inputs["players"] = config.ScriptoriumInputConfig{Source: "narratio.input.players", Required: false} + vars["label"] = options.label + vars["session_date"] = true + } + if options.includePlayers { + recordPreparedAnalyzeInput(t, m, "narratio.input.players", filepath.Join(paths.InputsDir, "players.yml"), "players:\n - Hrank\n") + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = config.ScriptoriumArtifactConfig{ + Enabled: true, DependsOn: []string{"source_notes"}, PromptID: options.promptID, + ProfileID: options.profileID, OutputPath: options.outputPath, Timeout: options.artifactTimeout, + Inputs: inputs, Vars: vars, + } + setCurrentAnalyzeEvidence(t, m, "source_notes", "artifacts/source_notes.md", dependencyPath) + dependencyRecord := m.Stages["analyze"].AnalyzeArtifacts["source_notes"] + dependencyRecord.ProducerRunID = options.producerRunID + dependencyRecord.Output.ProducerRunID = options.producerRunID + if !options.updatedAt.IsZero() { + dependencyRecord.UpdatedAt = options.updatedAt + } + m.Stages["analyze"].AnalyzeArtifacts["source_notes"] = dependencyRecord + m.RunID = options.producerRunID + + fingerprints, err := computeAnalyzeFingerprints(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m)) + if err != nil { + t.Fatal(err) + } + candidate, ok := fingerprints.Lookup("session_recap") + if !ok { + t.Fatal("session_recap fingerprint missing") + } + if candidate.Err != nil { + t.Fatalf("session_recap fingerprint error = %v", candidate.Err) + } + return candidate.Fingerprint +} + +func setAnalyzeRecordFingerprint(t *testing.T, m *manifest.Manifest, key, fingerprint string) { + t.Helper() + record := m.Stages["analyze"].AnalyzeArtifacts[key] + record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion + record.Fingerprint = fingerprint + m.Stages["analyze"].AnalyzeArtifacts[key] = record +} + +func updateAnalyzeEvidenceBytes(t *testing.T, m *manifest.Manifest, key, path, body, producerRunID string) { + t.Helper() + writeAnalyzeFile(t, path, body) + checksum, err := artifacts.SHA256File(path) + if err != nil { + t.Fatal(err) + } + record := m.Stages["analyze"].AnalyzeArtifacts[key] + record.Output.Checksum = checksum + record.OutputSize = int64(len(body)) + record.ProducerRunID = producerRunID + record.Output.ProducerRunID = producerRunID + m.Stages["analyze"].AnalyzeArtifacts[key] = record +} diff --git a/internal/stage/analyze_reconciliation.go b/internal/stage/analyze_reconciliation.go new file mode 100644 index 0000000..4d53cf1 --- /dev/null +++ b/internal/stage/analyze_reconciliation.go @@ -0,0 +1,201 @@ +package stage + +import ( + "fmt" + "sort" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +// analyzeReconciliationReason is the typed resume classification for one +// configured artifact or one manifest record removed from configuration. +type analyzeReconciliationReason string + +const ( + analyzeReconciliationCurrent analyzeReconciliationReason = "current" + analyzeReconciliationStale analyzeReconciliationReason = "stale" + analyzeReconciliationMissing analyzeReconciliationReason = "missing" + analyzeReconciliationFailed analyzeReconciliationReason = "failed" + analyzeReconciliationLegacy analyzeReconciliationReason = "legacy" + analyzeReconciliationRemoved analyzeReconciliationReason = "removed" + analyzeReconciliationNonResumable analyzeReconciliationReason = "non_resumable" +) + +type analyzeArtifactReconciliation struct { + Key string + Reason analyzeReconciliationReason + Detail string + ExpectedFingerprint string + Stored *manifest.AnalyzeArtifactRecord +} + +type analyzeReconciliation struct { + Ordered []analyzeArtifactReconciliation +} + +func (r analyzeReconciliation) Lookup(key string) (analyzeArtifactReconciliation, bool) { + for _, item := range r.Ordered { + if item.Key == key { + return item, true + } + } + return analyzeArtifactReconciliation{}, false +} + +// reconcileAnalyzeArtifacts compares current configuration and semantic input +// identities with manifest-owned output evidence without mutating either. +func reconcileAnalyzeArtifacts( + scriptoriumCfg *config.ScriptoriumConfig, + execution analyzeExecutionContext, +) (analyzeReconciliation, error) { + fingerprints, err := computeAnalyzeFingerprints(scriptoriumCfg, execution) + if err != nil { + return analyzeReconciliation{}, err + } + if scriptoriumCfg == nil { + return analyzeReconciliation{}, nil + } + stageRecord := analyzeManifestStageRecord(execution.Manifest) + result := analyzeReconciliation{Ordered: make([]analyzeArtifactReconciliation, 0, len(fingerprints.Ordered))} + for _, candidate := range fingerprints.Ordered { + artifactCfg := scriptoriumCfg.Artifacts[candidate.Key] + result.Ordered = append(result.Ordered, reconcileAnalyzeArtifact( + candidate, + artifactCfg, + stageRecord, + execution, + )) + } + + if stageRecord != nil && len(stageRecord.AnalyzeArtifacts) > 0 { + removed := make([]string, 0) + for key := range stageRecord.AnalyzeArtifacts { + if _, configured := scriptoriumCfg.Artifacts[key]; !configured { + removed = append(removed, key) + } + } + sort.Strings(removed) + for _, key := range removed { + record := stageRecord.AnalyzeArtifacts[key] + result.Ordered = append(result.Ordered, analyzeArtifactReconciliation{ + Key: key, Reason: analyzeReconciliationRemoved, + Detail: "artifact is no longer present in current configuration", + Stored: cloneAnalyzeReconciliationRecord(record), + }) + } + } + return result, nil +} + +func reconcileAnalyzeArtifact( + candidate analyzeFingerprintCandidate, + configured config.ScriptoriumArtifactConfig, + stageRecord *manifest.StageRecord, + execution analyzeExecutionContext, +) analyzeArtifactReconciliation { + result := analyzeArtifactReconciliation{ + Key: candidate.Key, ExpectedFingerprint: candidate.Fingerprint, + } + if stageRecord == nil { + result.Reason = analyzeReconciliationMissing + result.Detail = "analyze manifest record is absent" + return result + } + if stageRecord.Name != "analyze" { + result.Reason = analyzeReconciliationNonResumable + result.Detail = fmt.Sprintf("manifest stage name is %q", stageRecord.Name) + return result + } + if stageRecord.AnalyzeStateVersion == 0 { + result.Reason = analyzeReconciliationLegacy + result.Detail = "analyze manifest uses aggregate-only legacy state" + return result + } + if stageRecord.AnalyzeStateVersion != manifest.AnalyzeStateContractVersion { + result.Reason = analyzeReconciliationNonResumable + result.Detail = fmt.Sprintf("unsupported analyze state version %d", stageRecord.AnalyzeStateVersion) + return result + } + record, ok := stageRecord.AnalyzeArtifacts[candidate.Key] + if !ok { + result.Reason = analyzeReconciliationMissing + result.Detail = "configured artifact has no analyze manifest record" + return result + } + result.Stored = cloneAnalyzeReconciliationRecord(record) + if err := manifest.ValidateAnalyzeArtifactCollection( + stageRecord.AnalyzeStateVersion, + map[string]manifest.AnalyzeArtifactRecord{candidate.Key: record}, + ); err != nil { + result.Reason = analyzeReconciliationNonResumable + result.Detail = err.Error() + return result + } + switch record.Status { + case manifest.AnalyzeArtifactMissing: + result.Reason = analyzeReconciliationMissing + result.Detail = "stored artifact status is missing" + return result + case manifest.AnalyzeArtifactFailed: + result.Reason = analyzeReconciliationFailed + result.Detail = "stored artifact status is failed" + return result + case manifest.AnalyzeArtifactStale: + result.Reason = analyzeReconciliationStale + result.Detail = "stored artifact status is stale" + return result + case manifest.AnalyzeArtifactUnselected: + result.Reason = analyzeReconciliationNonResumable + result.Detail = "stored artifact was not evaluated" + return result + case manifest.AnalyzeArtifactCurrent: + default: + result.Reason = analyzeReconciliationNonResumable + result.Detail = fmt.Sprintf("stored artifact status %q is unsupported", record.Status) + return result + } + if record.FingerprintVersion != manifest.AnalyzeFingerprintContractVersion { + result.Reason = analyzeReconciliationNonResumable + result.Detail = fmt.Sprintf("unsupported fingerprint version %d", record.FingerprintVersion) + return result + } + if candidate.Err != nil { + result.Reason = analyzeReconciliationNonResumable + result.Detail = candidate.Err.Error() + return result + } + evidence := artifacts.InspectAnalyzeEvidence( + execution.Paths, + execution.Manifest, + candidate.Key, + artifacts.ConfiguredArtifactDefinition{Enabled: configured.Enabled, OutputPath: configured.OutputPath}, + ) + if evidence.State != artifacts.AnalyzeEvidenceCurrent { + result.Reason = analyzeReconciliationStale + result.Detail = evidence.Reason + return result + } + if record.Fingerprint != candidate.Fingerprint { + result.Reason = analyzeReconciliationStale + result.Detail = "stored fingerprint differs from current semantic inputs" + return result + } + result.Reason = analyzeReconciliationCurrent + result.Detail = "stored fingerprint and output evidence are current" + return result +} + +func analyzeManifestStageRecord(m *manifest.Manifest) *manifest.StageRecord { + if m == nil { + return nil + } + return m.Stages["analyze"] +} + +func cloneAnalyzeReconciliationRecord(record manifest.AnalyzeArtifactRecord) *manifest.AnalyzeArtifactRecord { + cloned := manifest.CloneAnalyzeArtifactCollection(map[string]manifest.AnalyzeArtifactRecord{record.Key: record}) + copy := cloned[record.Key] + return © +} diff --git a/internal/stage/analyze_reconciliation_test.go b/internal/stage/analyze_reconciliation_test.go new file mode 100644 index 0000000..65b850f --- /dev/null +++ b/internal/stage/analyze_reconciliation_test.go @@ -0,0 +1,242 @@ +package stage + +import ( + "path/filepath" + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/narratio/internal/artifacts" + "gitea.maximumdirect.net/eric/narratio/internal/config" + "gitea.maximumdirect.net/eric/narratio/internal/manifest" +) + +func TestReconcileAnalyzeArtifactsClassifiesStoredState(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, *Env, *manifest.Manifest) + want analyzeReconciliationReason + }{ + {name: "current", want: analyzeReconciliationCurrent}, + { + name: "tampered output", want: analyzeReconciliationStale, + mutate: func(t *testing.T, env *Env, m *manifest.Manifest) { + path := filepath.Join(sessionPathsForEnv(env, m.SessionID).ArtifactsDir, "session_recap.md") + writeAnalyzeFile(t, path, "tampered\n") + }, + }, + { + name: "optional input transition", want: analyzeReconciliationStale, + mutate: func(t *testing.T, env *Env, m *manifest.Manifest) { + path := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "players.yml") + recordPreparedAnalyzeInput(t, m, "narratio.input.players", path, "players:\n - Hrank\n") + }, + }, + { + name: "legacy record", want: analyzeReconciliationLegacy, + mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) { + m.Stages["analyze"].AnalyzeStateVersion = 0 + m.Stages["analyze"].AnalyzeArtifacts = nil + }, + }, + { + name: "version mismatch", want: analyzeReconciliationNonResumable, + mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) { + record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"] + record.FingerprintVersion = manifest.AnalyzeFingerprintContractVersion + 1 + m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = record + }, + }, + { + name: "stale status", want: analyzeReconciliationStale, + mutate: setAnalyzeReconciliationStatus(manifest.AnalyzeArtifactStale), + }, + { + name: "missing status", want: analyzeReconciliationMissing, + mutate: setAnalyzeReconciliationStatus(manifest.AnalyzeArtifactMissing), + }, + { + name: "failed status", want: analyzeReconciliationFailed, + mutate: setAnalyzeReconciliationStatus(manifest.AnalyzeArtifactFailed), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env, m := currentAnalyzeReconciliationFixture(t) + if tt.mutate != nil { + tt.mutate(t, env, m) + } + reconciled, err := reconcileAnalyzeArtifacts( + env.Config.Pipeline.Scriptorium, + newAnalyzeIdentityExecution(t, env, m), + ) + if err != nil { + t.Fatal(err) + } + item, ok := reconciled.Lookup("session_recap") + if !ok { + t.Fatal("session_recap reconciliation missing") + } + if item.Reason != tt.want { + t.Fatalf("reason = %q (%s), want %q", item.Reason, item.Detail, tt.want) + } + }) + } +} + +func TestReconcileAnalyzeArtifactsClassifiesMissingAndRemovedConfiguration(t *testing.T) { + t.Run("missing record", func(t *testing.T) { + env, m, _ := setupAnalyzeEnv(t) + writeAnalyzeFile(t, filepath.Join(sessionPathsForEnv(env, m.SessionID).TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) + m.Stages["analyze"] = &manifest.StageRecord{ + Name: "analyze", Status: manifest.StatusSucceeded, + AnalyzeStateVersion: manifest.AnalyzeStateContractVersion, + AnalyzeArtifacts: map[string]manifest.AnalyzeArtifactRecord{}, + } + reconciled, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m)) + if err != nil { + t.Fatal(err) + } + item, _ := reconciled.Lookup("session_recap") + if item.Reason != analyzeReconciliationMissing { + t.Fatalf("reason = %q, want missing", item.Reason) + } + }) + + t.Run("removed config", func(t *testing.T) { + env, m := currentAnalyzeReconciliationFixture(t) + delete(env.Config.Pipeline.Scriptorium.Artifacts, "session_recap") + reconciled, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m)) + if err != nil { + t.Fatal(err) + } + item, ok := reconciled.Lookup("session_recap") + if !ok || item.Reason != analyzeReconciliationRemoved { + t.Fatalf("removed reconciliation = %#v, %v", item, ok) + } + }) +} + +func TestReconcileAnalyzeArtifactsTracksDependencyOutputIdentity(t *testing.T) { + tests := []struct { + name string + mutate func(*testing.T, *manifest.Manifest, string) + wantDependent analyzeReconciliationReason + }{ + { + name: "changed dependency bytes", wantDependent: analyzeReconciliationStale, + mutate: func(t *testing.T, m *manifest.Manifest, path string) { + updateAnalyzeEvidenceBytes(t, m, "source_notes", path, "changed notes\n", "run-b") + }, + }, + { + name: "byte-identical upstream replacement", wantDependent: analyzeReconciliationCurrent, + mutate: func(t *testing.T, m *manifest.Manifest, path string) { + updateAnalyzeEvidenceBytes(t, m, "source_notes", path, "notes\n", "run-b") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + env, m, dependencyPath := currentAnalyzeDependencyReconciliationFixture(t) + tt.mutate(t, m, dependencyPath) + reconciled, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m)) + if err != nil { + t.Fatal(err) + } + dependency, _ := reconciled.Lookup("source_notes") + if dependency.Reason != analyzeReconciliationCurrent { + t.Fatalf("dependency reason = %q (%s), want current", dependency.Reason, dependency.Detail) + } + dependent, _ := reconciled.Lookup("session_recap") + if dependent.Reason != tt.wantDependent { + t.Fatalf("dependent reason = %q (%s), want %q", dependent.Reason, dependent.Detail, tt.wantDependent) + } + }) + } +} + +func TestReconcileAnalyzeArtifactsIsReadOnly(t *testing.T) { + env, m := currentAnalyzeReconciliationFixture(t) + before := manifest.CloneAnalyzeArtifactCollection(m.Stages["analyze"].AnalyzeArtifacts) + beforeUpdatedAt := m.UpdatedAt + + if _, err := reconcileAnalyzeArtifacts(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m)); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, m.Stages["analyze"].AnalyzeArtifacts) || !m.UpdatedAt.Equal(beforeUpdatedAt) { + t.Fatal("reconciliation mutated manifest state") + } +} + +func currentAnalyzeReconciliationFixture(t *testing.T) (*Env, *manifest.Manifest) { + t.Helper() + env, m, _ := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) + artifact := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + artifact.Inputs["players"] = config.ScriptoriumInputConfig{Source: "narratio.input.players", Required: false} + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = artifact + + fingerprints, err := computeAnalyzeFingerprints(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m)) + if err != nil { + t.Fatal(err) + } + candidate, _ := fingerprints.Lookup("session_recap") + if candidate.Err != nil { + t.Fatal(candidate.Err) + } + outputPath := filepath.Join(paths.ArtifactsDir, "session_recap.md") + writeAnalyzeFile(t, outputPath, "recap\n") + setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", outputPath) + setAnalyzeRecordFingerprint(t, m, "session_recap", candidate.Fingerprint) + return env, m +} + +func currentAnalyzeDependencyReconciliationFixture(t *testing.T) (*Env, *manifest.Manifest, string) { + t.Helper() + env, m, _ := setupAnalyzeEnv(t) + paths := sessionPathsForEnv(env, m.SessionID) + writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`) + dependencyPath := filepath.Join(paths.ArtifactsDir, "source_notes.md") + writeAnalyzeFile(t, dependencyPath, "notes\n") + env.Config.Pipeline.Scriptorium.Artifacts["source_notes"] = config.ScriptoriumArtifactConfig{ + Enabled: false, OutputPath: "artifacts/source_notes.md", + } + child := env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] + child.DependsOn = []string{"source_notes"} + child.Inputs["notes"] = config.ScriptoriumInputConfig{ + Source: artifacts.ConfiguredArtifactSourceID("source_notes"), Required: true, + } + env.Config.Pipeline.Scriptorium.Artifacts["session_recap"] = child + setCurrentAnalyzeEvidence(t, m, "source_notes", "artifacts/source_notes.md", dependencyPath) + + fingerprints, err := computeAnalyzeFingerprints(env.Config.Pipeline.Scriptorium, newAnalyzeIdentityExecution(t, env, m)) + if err != nil { + t.Fatal(err) + } + dependencyFingerprint, _ := fingerprints.Lookup("source_notes") + childFingerprint, _ := fingerprints.Lookup("session_recap") + if dependencyFingerprint.Err != nil || childFingerprint.Err != nil { + t.Fatalf("fingerprint errors: dependency=%v child=%v", dependencyFingerprint.Err, childFingerprint.Err) + } + setAnalyzeRecordFingerprint(t, m, "source_notes", dependencyFingerprint.Fingerprint) + childOutput := filepath.Join(paths.ArtifactsDir, "session_recap.md") + writeAnalyzeFile(t, childOutput, "recap\n") + setCurrentAnalyzeEvidence(t, m, "session_recap", "artifacts/session_recap.md", childOutput) + setAnalyzeRecordFingerprint(t, m, "session_recap", childFingerprint.Fingerprint) + return env, m, dependencyPath +} + +func setAnalyzeReconciliationStatus(status manifest.AnalyzeArtifactStatus) func(*testing.T, *Env, *manifest.Manifest) { + return func(_ *testing.T, _ *Env, m *manifest.Manifest) { + record := m.Stages["analyze"].AnalyzeArtifacts["session_recap"] + record.Status = status + record.Output = nil + record.OutputSize = 0 + if status == manifest.AnalyzeArtifactFailed { + record.Error = "generation failed" + } + m.Stages["analyze"].AnalyzeArtifacts["session_recap"] = record + } +}