Complete downstream semantic resume coverage
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
@@ -109,6 +110,86 @@ func TestAnalyzeResumeValidationRejectsChangedInputsTamperedOutputsAndLegacyStat
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeResumeSemanticChangesRemainArtifactGranular(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*config.ScriptoriumConfig)
|
||||
wantWork []string
|
||||
}{
|
||||
{name: "profile selected model", mutate: func(cfg *config.ScriptoriumConfig) {
|
||||
artifact := cfg.Artifacts["player_handout"]
|
||||
artifact.ProfileID = "production-model"
|
||||
cfg.Artifacts["player_handout"] = artifact
|
||||
}, wantWork: []string{"player_handout", "campaign_brief"}},
|
||||
{name: "variable", mutate: func(cfg *config.ScriptoriumConfig) {
|
||||
artifact := cfg.Artifacts["player_handout"]
|
||||
artifact.Vars["audience"] = "players"
|
||||
cfg.Artifacts["player_handout"] = artifact
|
||||
}, wantWork: []string{"player_handout", "campaign_brief"}},
|
||||
{name: "dependency", mutate: func(cfg *config.ScriptoriumConfig) {
|
||||
artifact := cfg.Artifacts["player_handout"]
|
||||
artifact.DependsOn = nil
|
||||
cfg.Artifacts["player_handout"] = artifact
|
||||
}, wantWork: []string{"player_handout", "campaign_brief"}},
|
||||
{name: "input", mutate: func(cfg *config.ScriptoriumConfig) {
|
||||
artifact := cfg.Artifacts["player_handout"]
|
||||
artifact.Inputs["recap"] = config.ScriptoriumInputConfig{
|
||||
Source: "narratio.transcript.final_trimmed", Required: true,
|
||||
}
|
||||
cfg.Artifacts["player_handout"] = artifact
|
||||
}, wantWork: []string{"player_handout", "campaign_brief"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
env, m, _ := setupAnalyzeEnv(t)
|
||||
paths := sessionPathsForEnv(env, m.SessionID)
|
||||
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
|
||||
addAnalyzeDependentArtifact(env)
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: true, DependsOn: []string{"session_recap"}, PromptID: "dnd.player_handout",
|
||||
ProfileID: "testing-model", OutputPath: "artifacts/player_handout.md",
|
||||
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||
"recap": {Source: "narratio.artifact.session_recap", Required: true},
|
||||
},
|
||||
Vars: map[string]any{"audience": "party"},
|
||||
}
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["campaign_brief"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: true, DependsOn: []string{"player_handout"}, PromptID: "dnd.campaign_brief",
|
||||
OutputPath: "artifacts/campaign_brief.md",
|
||||
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||
"handout": {Source: "narratio.artifact.player_handout", Required: true},
|
||||
},
|
||||
}
|
||||
env.Config.Pipeline.Scriptorium.Artifacts["unrelated"] = config.ScriptoriumArtifactConfig{
|
||||
Enabled: true, PromptID: "dnd.unrelated", OutputPath: "artifacts/unrelated.md",
|
||||
Inputs: map[string]config.ScriptoriumInputConfig{
|
||||
"transcript": {Source: "narratio.transcript.final_trimmed", Required: true},
|
||||
},
|
||||
}
|
||||
result, err := (analyzeStage{}).Run(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installAnalyzeProjection(m, result.AnalyzeState)
|
||||
m.Stages["analyze"].Status = manifest.StatusSucceeded
|
||||
|
||||
test.mutate(env.Config.Pipeline.Scriptorium)
|
||||
validation, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if validation.Resumable || !resumeArtifactKeysEqual(validation.Analyze.ExecutionOrder, test.wantWork) {
|
||||
t.Fatalf("validation = %#v, want only %v", validation, test.wantWork)
|
||||
}
|
||||
for _, reused := range validation.Analyze.ReusedCurrent {
|
||||
if reused.Key == "unrelated" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("unrelated artifact was not preserved as current")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeStaleAggregateRestoresSuccessWithoutAdapterWork(t *testing.T) {
|
||||
env, m, _ := currentAnalyzeFixture(t, false)
|
||||
m.Stages["analyze"].Status = manifest.StatusStale
|
||||
|
||||
@@ -141,7 +141,7 @@ func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
return nil, fmt.Errorf("extract: materialize Notarius reference snapshots: %w", err)
|
||||
}
|
||||
|
||||
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, notariusConfig, timeout, workingDirectory, input, references.Identities)
|
||||
fingerprint, err := extractionFingerprint(notariusConfig, input, references.Identities)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract: build configuration fingerprint: %w", err)
|
||||
}
|
||||
@@ -394,21 +394,14 @@ type fingerprintOutput struct {
|
||||
}
|
||||
|
||||
type fingerprintDocument struct {
|
||||
Binary string `json:"binary"`
|
||||
ConfigPath string `json:"config_path"`
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
Timeout string `json:"timeout"`
|
||||
WorkingDirectory string `json:"working_directory"`
|
||||
Input artifacts.ExtractionInputIdentity `json:"input"`
|
||||
References []extractReferenceIdentity `json:"references"`
|
||||
Outputs []fingerprintOutput `json:"outputs"`
|
||||
PipelineID string `json:"pipeline_id"`
|
||||
Input artifacts.ExtractionInputIdentity `json:"input"`
|
||||
References []extractReferenceIdentity `json:"references"`
|
||||
Outputs []fingerprintOutput `json:"outputs"`
|
||||
}
|
||||
|
||||
func extractionFingerprint(
|
||||
binary, configPath string,
|
||||
cfg *config.NotariusConfig,
|
||||
timeout time.Duration,
|
||||
workingDirectory string,
|
||||
input artifacts.ExtractionInputIdentity,
|
||||
references []extractReferenceIdentity,
|
||||
) (string, error) {
|
||||
@@ -436,9 +429,7 @@ func extractionFingerprint(
|
||||
return sortedReferences[i].Path < sortedReferences[j].Path
|
||||
})
|
||||
payload, err := json.Marshal(fingerprintDocument{
|
||||
Binary: binary, ConfigPath: configPath, PipelineID: cfg.PipelineID,
|
||||
Timeout: timeout.String(), WorkingDirectory: workingDirectory, Input: input,
|
||||
References: sortedReferences, Outputs: outputs,
|
||||
PipelineID: cfg.PipelineID, Input: input, References: sortedReferences, Outputs: outputs,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
@@ -21,22 +20,6 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
|
||||
if m == nil {
|
||||
return ResumeValidation{}, fmt.Errorf("extract resume: session manifest is required")
|
||||
}
|
||||
timeout, err := time.ParseDuration(strings.TrimSpace(cfg.Timeout))
|
||||
if err != nil || timeout <= 0 {
|
||||
return ResumeValidation{}, fmt.Errorf("extract resume: invalid Notarius timeout %q", cfg.Timeout)
|
||||
}
|
||||
resolvedBinary, err := resolveExecutable(cfg.Binary)
|
||||
if err != nil {
|
||||
return ResumeValidation{}, fmt.Errorf("extract resume: resolve Notarius binary: %w", err)
|
||||
}
|
||||
configPath, err := absolutePath(cfg.ConfigPath)
|
||||
if err != nil {
|
||||
return ResumeValidation{}, fmt.Errorf("extract resume: resolve Notarius config path: %w", err)
|
||||
}
|
||||
workingDirectory, err := absolutePath(cfg.WorkingDirectory)
|
||||
if err != nil {
|
||||
return ResumeValidation{}, fmt.Errorf("extract resume: resolve Notarius working directory: %w", err)
|
||||
}
|
||||
sessionID := strings.TrimSpace(m.SessionID)
|
||||
if sessionID == "" {
|
||||
sessionID = strings.TrimSpace(env.Config.Session.SessionID)
|
||||
@@ -53,7 +36,7 @@ func (extractStage) ValidateResume(_ context.Context, env *Env, m *manifest.Mani
|
||||
if err != nil {
|
||||
return ResumeValidation{}, fmt.Errorf("extract resume: resolve Notarius references: %w", err)
|
||||
}
|
||||
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, cfg, timeout, workingDirectory, input, references.Identities)
|
||||
fingerprint, err := extractionFingerprint(cfg, input, references.Identities)
|
||||
if err != nil {
|
||||
return ResumeValidation{}, fmt.Errorf("extract resume: build configuration fingerprint: %w", err)
|
||||
}
|
||||
|
||||
@@ -530,11 +530,11 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
|
||||
{Selector: "zeta", SourceID: artifactpolicy.SourceInputPlayers, Path: "inputs/players.yml", Checksum: "players-checksum", SizeBytes: 12},
|
||||
{Selector: "alpha", SourceID: artifactpolicy.SourceInputParty, Path: "inputs/party.yml", Checksum: "party-checksum", SizeBytes: 34},
|
||||
}
|
||||
one, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", first, time.Minute, "/work", input, references)
|
||||
one, err := extractionFingerprint(first, input, references)
|
||||
if err != nil {
|
||||
t.Fatalf("extractionFingerprint(first) error = %v", err)
|
||||
}
|
||||
two, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input, []extractReferenceIdentity{references[1], references[0]})
|
||||
two, err := extractionFingerprint(second, input, []extractReferenceIdentity{references[1], references[0]})
|
||||
if err != nil {
|
||||
t.Fatalf("extractionFingerprint(second) error = %v", err)
|
||||
}
|
||||
@@ -554,7 +554,7 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
changedInput := input
|
||||
test.mutate(&changedInput)
|
||||
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", changedInput, references)
|
||||
changed, err := extractionFingerprint(second, changedInput, references)
|
||||
if err != nil {
|
||||
t.Fatalf("extractionFingerprint(changed input) error = %v", err)
|
||||
}
|
||||
@@ -576,7 +576,7 @@ func TestExtractionFingerprintIsIndependentOfOutputMapOrder(t *testing.T) {
|
||||
t.Run("reference "+test.name, func(t *testing.T) {
|
||||
changedReferences := append([]extractReferenceIdentity(nil), references...)
|
||||
test.mutate(&changedReferences[0])
|
||||
changed, err := extractionFingerprint("/bin/notarius", "/etc/notarius.yml", second, time.Minute, "/work", input, changedReferences)
|
||||
changed, err := extractionFingerprint(second, input, changedReferences)
|
||||
if err != nil {
|
||||
t.Fatalf("extractionFingerprint(changed reference) error = %v", err)
|
||||
}
|
||||
|
||||
217
internal/stage/semantic_contracts_delivery.go
Normal file
217
internal/stage/semantic_contracts_delivery.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"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/notariusref"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
extractSemanticConfigVersion = 1
|
||||
publishSemanticConfigVersion = 1
|
||||
notifySemanticConfigVersion = 1
|
||||
)
|
||||
|
||||
type extractSemanticConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
PipelineID string `json:"pipeline_id,omitempty"`
|
||||
References []extractSemanticReference `json:"references"`
|
||||
Outputs []extractSemanticOutput `json:"outputs"`
|
||||
}
|
||||
|
||||
type extractSemanticReference struct {
|
||||
Selector string `json:"selector"`
|
||||
SourceID string `json:"source_id"`
|
||||
}
|
||||
|
||||
type extractSemanticOutput struct {
|
||||
Key string `json:"key"`
|
||||
SourceID string `json:"source_id"`
|
||||
LaneID string `json:"lane_id"`
|
||||
MediaType string `json:"media_type"`
|
||||
SchemaID string `json:"schema_id"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ModuleKey string `json:"module_key,omitempty"`
|
||||
}
|
||||
|
||||
type publishSemanticConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
UploadRun bool `json:"upload_run"`
|
||||
Rules []publishSemanticRule `json:"rules"`
|
||||
StaticLocks []publishSemanticLock `json:"static_locks"`
|
||||
Destination publishSemanticDestination `json:"destination"`
|
||||
}
|
||||
|
||||
type publishSemanticRule struct {
|
||||
Source string `json:"source"`
|
||||
Dest string `json:"dest"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
type publishSemanticLock struct {
|
||||
Source string `json:"source"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type publishSemanticDestination struct {
|
||||
Backend string `json:"backend"`
|
||||
Bucket string `json:"bucket,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
RootPrefix string `json:"root_prefix,omitempty"`
|
||||
}
|
||||
|
||||
type notifySemanticConfig struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
func (extractStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) {
|
||||
if env == nil || env.Config == nil || env.Config.Pipeline == nil {
|
||||
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("extract semantic configuration requires resolved pipeline config")
|
||||
}
|
||||
cfg := env.Config.Pipeline.Notarius
|
||||
payload := extractSemanticConfig{References: []extractSemanticReference{}, Outputs: []extractSemanticOutput{}}
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
return FingerprintSemanticConfig(extractSemanticConfigVersion, payload)
|
||||
}
|
||||
payload.Enabled = true
|
||||
payload.PipelineID = strings.TrimSpace(cfg.PipelineID)
|
||||
for rawSelector, rawSourceID := range cfg.References {
|
||||
selector, err := notariusref.NormalizeSelector(rawSelector)
|
||||
if err != nil {
|
||||
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("extract semantic reference selector %q: %w", rawSelector, err)
|
||||
}
|
||||
payload.References = append(payload.References, extractSemanticReference{
|
||||
Selector: selector,
|
||||
SourceID: strings.TrimSpace(rawSourceID),
|
||||
})
|
||||
}
|
||||
sort.Slice(payload.References, func(i, j int) bool {
|
||||
if payload.References[i].Selector != payload.References[j].Selector {
|
||||
return payload.References[i].Selector < payload.References[j].Selector
|
||||
}
|
||||
return payload.References[i].SourceID < payload.References[j].SourceID
|
||||
})
|
||||
keys := make([]string, 0, len(cfg.Outputs))
|
||||
for key := range cfg.Outputs {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
output := cfg.Outputs[key]
|
||||
payload.Outputs = append(payload.Outputs, extractSemanticOutput{
|
||||
Key: strings.TrimSpace(key), SourceID: artifacts.ExtractionArtifactSourceID(strings.TrimSpace(key)),
|
||||
LaneID: strings.TrimSpace(output.LaneID), MediaType: strings.TrimSpace(output.MediaType),
|
||||
SchemaID: strings.TrimSpace(output.SchemaID), SchemaVersion: strings.TrimSpace(output.SchemaVersion),
|
||||
ModuleKey: strings.TrimSpace(output.ModuleKey),
|
||||
})
|
||||
}
|
||||
return FingerprintSemanticConfig(extractSemanticConfigVersion, payload)
|
||||
}
|
||||
|
||||
func (publishStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) {
|
||||
if env == nil || env.Config == nil || env.Config.Pipeline == nil {
|
||||
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("publish semantic configuration requires resolved pipeline config")
|
||||
}
|
||||
pipeline := env.Config.Pipeline
|
||||
payload := publishSemanticConfig{
|
||||
Rules: []publishSemanticRule{}, StaticLocks: []publishSemanticLock{},
|
||||
Destination: publishSemanticDestination{Backend: strings.ToLower(strings.TrimSpace(pipeline.Storage.Backend))},
|
||||
}
|
||||
if payload.Destination.Backend == "" {
|
||||
payload.Destination.Backend = config.StorageBackendLocal
|
||||
}
|
||||
if pipeline.Storage.S3 != nil {
|
||||
payload.Destination.Bucket = strings.TrimSpace(pipeline.Storage.S3.Bucket)
|
||||
payload.Destination.Region = strings.TrimSpace(pipeline.Storage.S3.Region)
|
||||
payload.Destination.Endpoint = strings.TrimSpace(pipeline.Storage.S3.Endpoint)
|
||||
payload.Destination.RootPrefix = strings.Trim(strings.TrimSpace(pipeline.Storage.S3.RootPrefix), "/")
|
||||
}
|
||||
cfg := pipeline.Publish
|
||||
if cfg == nil {
|
||||
return FingerprintSemanticConfig(publishSemanticConfigVersion, payload)
|
||||
}
|
||||
payload.Enabled = cfg.Enabled == nil || *cfg.Enabled
|
||||
payload.UploadRun = cfg.UploadRun == nil || *cfg.UploadRun
|
||||
configured, extractions := publishSemanticSources(pipeline)
|
||||
for _, rule := range cfg.Outputs {
|
||||
source, err := artifactpolicy.ValidatePublishSourceWithExtractions(rule.Source, configured, extractions)
|
||||
if err != nil {
|
||||
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("publish semantic source %q: %w", rule.Source, err)
|
||||
}
|
||||
dest, err := artifactpolicy.ResolvePublishedDestinationWithExtractions(
|
||||
source.ID, rule.Dest, configured, extractions,
|
||||
)
|
||||
if err != nil {
|
||||
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("publish semantic destination for %q: %w", source.ID, err)
|
||||
}
|
||||
dest, err = pathsafe.NormalizeRelativeDestination(dest)
|
||||
if err != nil {
|
||||
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("publish semantic destination for %q: %w", source.ID, err)
|
||||
}
|
||||
payload.Rules = append(payload.Rules, publishSemanticRule{
|
||||
Source: source.ID, Dest: dest, Required: rule.Required == nil || *rule.Required,
|
||||
})
|
||||
}
|
||||
sort.Slice(payload.Rules, func(i, j int) bool {
|
||||
if payload.Rules[i].Source != payload.Rules[j].Source {
|
||||
return payload.Rules[i].Source < payload.Rules[j].Source
|
||||
}
|
||||
return payload.Rules[i].Dest < payload.Rules[j].Dest
|
||||
})
|
||||
for _, lock := range cfg.Locks {
|
||||
source, err := artifactpolicy.ValidatePublishSourceWithExtractions(lock.Source, configured, extractions)
|
||||
if err != nil {
|
||||
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("publish semantic lock source %q: %w", lock.Source, err)
|
||||
}
|
||||
payload.StaticLocks = append(payload.StaticLocks, publishSemanticLock{
|
||||
Source: source.ID,
|
||||
Reason: strings.TrimSpace(lock.Reason),
|
||||
})
|
||||
}
|
||||
sort.Slice(payload.StaticLocks, func(i, j int) bool {
|
||||
return payload.StaticLocks[i].Source < payload.StaticLocks[j].Source
|
||||
})
|
||||
return FingerprintSemanticConfig(publishSemanticConfigVersion, payload)
|
||||
}
|
||||
|
||||
func (s placeholderStage) SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error) {
|
||||
if s.name != "notify" {
|
||||
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("stage %q has no semantic configuration contract", s.name)
|
||||
}
|
||||
if env == nil || env.Config == nil || env.Config.Pipeline == nil {
|
||||
return manifest.SemanticConfigFingerprint{}, fmt.Errorf("notify semantic configuration requires resolved pipeline config")
|
||||
}
|
||||
mode := strings.ToLower(strings.TrimSpace(env.Config.Pipeline.Notification.Mode))
|
||||
if mode == "" {
|
||||
mode = config.DefaultNotificationMode
|
||||
}
|
||||
return FingerprintSemanticConfig(notifySemanticConfigVersion, notifySemanticConfig{Mode: mode})
|
||||
}
|
||||
|
||||
func publishSemanticSources(pipeline *config.PipelineConfig) (map[string]string, map[string]struct{}) {
|
||||
configured := map[string]string{}
|
||||
if pipeline.Scriptorium != nil {
|
||||
for key, artifact := range pipeline.Scriptorium.Artifacts {
|
||||
configured[key] = artifact.OutputPath
|
||||
}
|
||||
}
|
||||
extractions := map[string]struct{}{}
|
||||
if pipeline.Notarius != nil {
|
||||
for key := range pipeline.Notarius.Outputs {
|
||||
extractions[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
return configured, extractions
|
||||
}
|
||||
|
||||
var _ SemanticConfigFingerprinter = extractStage{}
|
||||
var _ SemanticConfigFingerprinter = publishStage{}
|
||||
var _ SemanticConfigFingerprinter = placeholderStage{name: "notify"}
|
||||
233
internal/stage/semantic_contracts_delivery_test.go
Normal file
233
internal/stage/semantic_contracts_delivery_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||
)
|
||||
|
||||
func TestExtractSemanticConfigSensitivityAndOperationalExclusions(t *testing.T) {
|
||||
baseline := downstreamSemanticEnv()
|
||||
want, err := (extractStage{}).SemanticConfigFingerprint(baseline)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*Env)
|
||||
}{
|
||||
{name: "enablement", mutate: func(env *Env) { env.Config.Pipeline.Notarius.Enabled = false }},
|
||||
{name: "pipeline", mutate: func(env *Env) { env.Config.Pipeline.Notarius.PipelineID = "campaign-v2" }},
|
||||
{name: "reference selector", mutate: func(env *Env) {
|
||||
env.Config.Pipeline.Notarius.References = map[string]string{"party_v2": artifactpolicy.SourceInputParty}
|
||||
}},
|
||||
{name: "reference source", mutate: func(env *Env) {
|
||||
env.Config.Pipeline.Notarius.References = map[string]string{"party": artifactpolicy.SourceInputPlayers}
|
||||
}},
|
||||
{name: "lane", mutate: func(env *Env) {
|
||||
output := env.Config.Pipeline.Notarius.Outputs["encounters"]
|
||||
output.LaneID = "encounters-v2"
|
||||
env.Config.Pipeline.Notarius.Outputs["encounters"] = output
|
||||
}},
|
||||
{name: "contract", mutate: func(env *Env) {
|
||||
output := env.Config.Pipeline.Notarius.Outputs["encounters"]
|
||||
output.SchemaVersion = "2"
|
||||
env.Config.Pipeline.Notarius.Outputs["encounters"] = output
|
||||
}},
|
||||
{name: "output identity", mutate: func(env *Env) {
|
||||
env.Config.Pipeline.Notarius.Outputs["characters"] = config.NotariusOutputConfig{
|
||||
LaneID: "characters", MediaType: "application/json", SchemaID: "characters", SchemaVersion: "1",
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run("semantic "+test.name, func(t *testing.T) {
|
||||
env := downstreamSemanticEnv()
|
||||
test.mutate(env)
|
||||
got, err := (extractStage{}).SemanticConfigFingerprint(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want.Equal(got) {
|
||||
t.Fatalf("semantic change %q retained fingerprint", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
operational := downstreamSemanticEnv()
|
||||
operational.Config.Pipeline.Notarius.Binary = "/opt/notarius-v2"
|
||||
operational.Config.Pipeline.Notarius.Timeout = "2h"
|
||||
operational.Config.Pipeline.Notarius.WorkingDirectory = "/var/tmp/notarius"
|
||||
operational.Config.Pipeline.Notarius.ConfigPath = "/etc/notarius/private-v2.yml"
|
||||
operational.Config.Pipeline.Workspace.Root = "/relocated/workspace"
|
||||
got, err := (extractStage{}).SemanticConfigFingerprint(operational)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !want.Equal(got) {
|
||||
t.Fatalf("operational settings changed fingerprint: got %#v want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResumeValidationExcludesOperationalSettings(t *testing.T) {
|
||||
env, m, _ := setupExtractEnv(t)
|
||||
seedSucceededExtractResult(t, env, m)
|
||||
env.Config.Pipeline.Notarius.Binary = "/not/installed"
|
||||
env.Config.Pipeline.Notarius.ConfigPath = "/not/present/private.yml"
|
||||
env.Config.Pipeline.Notarius.Timeout = "not-a-duration"
|
||||
env.Config.Pipeline.Notarius.WorkingDirectory = "/not/present/work"
|
||||
|
||||
validation, err := (extractStage{}).ValidateResume(context.Background(), env, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !validation.Resumable {
|
||||
t.Fatalf("operational change validation = %#v, want reusable durable evidence", validation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishSemanticConfigSensitivityAndOperationalExclusions(t *testing.T) {
|
||||
want, err := (publishStage{}).SemanticConfigFingerprint(downstreamSemanticEnv())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*Env)
|
||||
}{
|
||||
{name: "enablement", mutate: func(env *Env) { env.Config.Pipeline.Publish.Enabled = boolPointer(false) }},
|
||||
{name: "run upload", mutate: func(env *Env) { env.Config.Pipeline.Publish.UploadRun = boolPointer(false) }},
|
||||
{name: "source", mutate: func(env *Env) { env.Config.Pipeline.Publish.Outputs[0].Source = "narratio.transcript.final_markdown" }},
|
||||
{name: "destination", mutate: func(env *Env) { env.Config.Pipeline.Publish.Outputs[0].Dest = "published/other.json" }},
|
||||
{name: "required", mutate: func(env *Env) { env.Config.Pipeline.Publish.Outputs[0].Required = boolPointer(false) }},
|
||||
{name: "static lock", mutate: func(env *Env) { env.Config.Pipeline.Publish.Locks = nil }},
|
||||
{name: "backend", mutate: func(env *Env) { env.Config.Pipeline.Storage.Backend = config.StorageBackendLocal }},
|
||||
{name: "bucket", mutate: func(env *Env) { env.Config.Pipeline.Storage.S3.Bucket = "other-bucket" }},
|
||||
{name: "region", mutate: func(env *Env) { env.Config.Pipeline.Storage.S3.Region = "eu-west-1" }},
|
||||
{name: "endpoint", mutate: func(env *Env) { env.Config.Pipeline.Storage.S3.Endpoint = "https://objects-v2.example" }},
|
||||
{name: "root prefix", mutate: func(env *Env) { env.Config.Pipeline.Storage.S3.RootPrefix = "archive-v2" }},
|
||||
} {
|
||||
t.Run("semantic "+test.name, func(t *testing.T) {
|
||||
env := downstreamSemanticEnv()
|
||||
test.mutate(env)
|
||||
got, err := (publishStage{}).SemanticConfigFingerprint(env)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want.Equal(got) {
|
||||
t.Fatalf("semantic change %q retained fingerprint", test.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
operational := downstreamSemanticEnv()
|
||||
operational.Config.Pipeline.Storage.S3.AccessKeyIDEnv = "OTHER_ACCESS_KEY"
|
||||
operational.Config.Pipeline.Storage.S3.SecretKeyEnv = "OTHER_SECRET_KEY"
|
||||
operational.Config.Pipeline.Storage.S3.ForcePathStyle = true
|
||||
operational.Config.Pipeline.Workspace.Root = "/relocated/workspace"
|
||||
got, err := (publishStage{}).SemanticConfigFingerprint(operational)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !want.Equal(got) {
|
||||
t.Fatalf("operational settings changed fingerprint: got %#v want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliverySemanticConfigCanonicalOrderingAndNotifyMode(t *testing.T) {
|
||||
first := downstreamSemanticEnv()
|
||||
second := downstreamSemanticEnv()
|
||||
second.Config.Pipeline.Publish.Outputs[0], second.Config.Pipeline.Publish.Outputs[1] =
|
||||
second.Config.Pipeline.Publish.Outputs[1], second.Config.Pipeline.Publish.Outputs[0]
|
||||
second.Config.Pipeline.Publish.Locks[0], second.Config.Pipeline.Publish.Locks[1] =
|
||||
second.Config.Pipeline.Publish.Locks[1], second.Config.Pipeline.Publish.Locks[0]
|
||||
firstPublish, err := (publishStage{}).SemanticConfigFingerprint(first)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPublish, err := (publishStage{}).SemanticConfigFingerprint(second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !firstPublish.Equal(secondPublish) {
|
||||
t.Fatalf("publish ordering changed fingerprint: first=%#v second=%#v", firstPublish, secondPublish)
|
||||
}
|
||||
|
||||
defaultNotify := downstreamSemanticEnv()
|
||||
defaultNotify.Config.Pipeline.Notification.Mode = ""
|
||||
explicitNotify := downstreamSemanticEnv()
|
||||
explicitNotify.Config.Pipeline.Notification.Mode = config.DefaultNotificationMode
|
||||
defaultFingerprint, err := (placeholderStage{name: "notify"}).SemanticConfigFingerprint(defaultNotify)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitFingerprint, err := (placeholderStage{name: "notify"}).SemanticConfigFingerprint(explicitNotify)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !defaultFingerprint.Equal(explicitFingerprint) {
|
||||
t.Fatalf("default notify mode differs from explicit default")
|
||||
}
|
||||
explicitNotify.Config.Pipeline.Notification.Mode = "webhook"
|
||||
changed, err := (placeholderStage{name: "notify"}).SemanticConfigFingerprint(explicitNotify)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if defaultFingerprint.Equal(changed) {
|
||||
t.Fatal("notify mode change retained fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalStageSemanticReuseCoverage(t *testing.T) {
|
||||
for _, candidate := range All() {
|
||||
if candidate.Name() == "analyze" {
|
||||
if _, ok := candidate.(SemanticConfigFingerprinter); ok {
|
||||
t.Fatal("analyze must retain artifact-level fingerprint authority")
|
||||
}
|
||||
if _, ok := candidate.(ResumeValidator); !ok {
|
||||
t.Fatal("analyze must provide artifact-level resume validation")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, ok := candidate.(SemanticConfigFingerprinter); !ok {
|
||||
t.Fatalf("canonical stage %q has no semantic reuse contract", candidate.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downstreamSemanticEnv() *Env {
|
||||
return &Env{Config: &config.Config{
|
||||
Pipeline: &config.PipelineConfig{
|
||||
Workspace: config.WorkspaceConfig{Root: "/workspace"},
|
||||
Storage: config.StorageConfig{Backend: config.StorageBackendS3, S3: &config.StorageS3Config{
|
||||
Bucket: "campaign-bucket", RootPrefix: "narratio", Region: "us-east-1",
|
||||
Endpoint: "https://objects.example", AccessKeyIDEnv: "ACCESS_KEY", SecretKeyEnv: "SECRET_KEY",
|
||||
}},
|
||||
Notarius: &config.NotariusConfig{
|
||||
Enabled: true, Binary: "notarius", ConfigPath: "/etc/notarius/private.yml",
|
||||
PipelineID: "campaign", Timeout: "1h", WorkingDirectory: "/tmp/notarius",
|
||||
References: map[string]string{"party": artifactpolicy.SourceInputParty},
|
||||
Outputs: map[string]config.NotariusOutputConfig{
|
||||
"encounters": {
|
||||
LaneID: "encounters", MediaType: "application/json", SchemaID: "encounters",
|
||||
SchemaVersion: "1", ModuleKey: "encounters",
|
||||
},
|
||||
},
|
||||
},
|
||||
Publish: &config.PublishConfig{
|
||||
Enabled: boolPointer(true), UploadRun: boolPointer(true),
|
||||
Outputs: []config.PublishOutputRule{
|
||||
{Source: "narratio.transcript.final_trimmed", Dest: "transcripts/final.trimmed.json", Required: boolPointer(true)},
|
||||
{Source: "narratio.transcript.final_trimmed_markdown", Dest: "transcripts/final.trimmed.md", Required: boolPointer(true)},
|
||||
},
|
||||
Locks: []config.PublishLockRule{
|
||||
{Source: "narratio.transcript.final_trimmed", Reason: "curated"},
|
||||
{Source: "narratio.transcript.final_trimmed_markdown", Reason: "approved"},
|
||||
},
|
||||
},
|
||||
Notification: config.NotificationConfig{Mode: config.DefaultNotificationMode},
|
||||
},
|
||||
Session: &config.SessionConfig{SessionID: "2026-08-30", Campaign: "campaign"},
|
||||
}}
|
||||
}
|
||||
Reference in New Issue
Block a user