Add versioned analysis fingerprint reconciliation

This commit is contained in:
2026-08-29 19:18:28 +00:00
parent ab5751459a
commit c32e0c401f
9 changed files with 1177 additions and 21 deletions

View File

@@ -310,7 +310,7 @@ For each `pipeline.scriptorium.artifacts.<name>`:
| 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 |

View File

@@ -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`

View File

@@ -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

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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 &copy
}

View File

@@ -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
}
}