Add versioned analysis fingerprint reconciliation
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
344
internal/stage/analyze_fingerprint.go
Normal file
344
internal/stage/analyze_fingerprint.go
Normal 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,
|
||||
}
|
||||
}
|
||||
312
internal/stage/analyze_fingerprint_test.go
Normal file
312
internal/stage/analyze_fingerprint_test.go
Normal 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
|
||||
}
|
||||
201
internal/stage/analyze_reconciliation.go
Normal file
201
internal/stage/analyze_reconciliation.go
Normal 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 ©
|
||||
}
|
||||
242
internal/stage/analyze_reconciliation_test.go
Normal file
242
internal/stage/analyze_reconciliation_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user