Add semantic configuration resume evidence
This commit is contained in:
@@ -55,6 +55,7 @@ type StageRecord struct {
|
||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||
Error *ErrorRecord `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
SemanticConfig *SemanticConfigFingerprint `json:"semantic_config,omitempty"`
|
||||
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||
}
|
||||
@@ -177,6 +178,7 @@ func (s *StageRecord) clearResultDetails() {
|
||||
s.Logs = nil
|
||||
s.GeneratedConfigs = nil
|
||||
s.Metadata = nil
|
||||
s.SemanticConfig = nil
|
||||
}
|
||||
|
||||
func (m *Manifest) ensureStage(name string, at time.Time) *StageRecord {
|
||||
|
||||
@@ -34,6 +34,7 @@ type RunStageRecord struct {
|
||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||
Error *ErrorRecord `json:"error,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
SemanticConfig *SemanticConfigFingerprint `json:"semantic_config,omitempty"`
|
||||
AnalyzeStateVersion int `json:"analyze_state_version,omitempty"`
|
||||
AnalyzeArtifacts map[string]AnalyzeArtifactRecord `json:"analyze_artifacts,omitempty"`
|
||||
}
|
||||
|
||||
32
internal/manifest/semantic_fingerprint.go
Normal file
32
internal/manifest/semantic_fingerprint.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var lowercaseSHA256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
|
||||
// SemanticConfigFingerprint identifies the versioned, result-affecting
|
||||
// configuration observed by one pipeline stage.
|
||||
type SemanticConfigFingerprint struct {
|
||||
Version int `json:"version"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// Validate checks the durable fingerprint contract.
|
||||
func (f SemanticConfigFingerprint) Validate() error {
|
||||
if f.Version <= 0 {
|
||||
return fmt.Errorf("version must be positive")
|
||||
}
|
||||
if !lowercaseSHA256Pattern.MatchString(f.Digest) {
|
||||
return fmt.Errorf("digest must be a lowercase SHA-256 value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Equal reports whether two valid fingerprint records identify the same
|
||||
// semantic configuration contract and payload.
|
||||
func (f SemanticConfigFingerprint) Equal(other SemanticConfigFingerprint) bool {
|
||||
return f.Version == other.Version && f.Digest == other.Digest
|
||||
}
|
||||
80
internal/manifest/semantic_fingerprint_test.go
Normal file
80
internal/manifest/semantic_fingerprint_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testSemanticDigest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
func TestSemanticConfigFingerprintRoundTripsAndLegacyRecordsRemainReadable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := &LocalStore{}
|
||||
dir := t.TempDir()
|
||||
fingerprint := &SemanticConfigFingerprint{Version: 3, Digest: testSemanticDigest}
|
||||
|
||||
session := New("session", time.Now().UTC())
|
||||
session.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
||||
session.Stages["prepare"].SemanticConfig = fingerprint
|
||||
sessionPath := filepath.Join(dir, "session.json")
|
||||
if err := store.Save(ctx, sessionPath, session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loadedSession, err := store.Load(ctx, sessionPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loadedSession.Stages["prepare"].SemanticConfig == nil || !loadedSession.Stages["prepare"].SemanticConfig.Equal(*fingerprint) {
|
||||
t.Fatalf("session fingerprint = %#v", loadedSession.Stages["prepare"].SemanticConfig)
|
||||
}
|
||||
|
||||
run := NewRun("session", "campaign", "run", false, []string{"prepare"}, time.Now().UTC())
|
||||
run.MarkStageSucceeded("prepare", time.Now().UTC(), nil)
|
||||
run.Stages["prepare"].SemanticConfig = fingerprint
|
||||
runPath := filepath.Join(dir, "run.json")
|
||||
if err := store.SaveRun(ctx, runPath, run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loadedRun, err := store.LoadRun(ctx, runPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loadedRun.Stages["prepare"].SemanticConfig == nil || !loadedRun.Stages["prepare"].SemanticConfig.Equal(*fingerprint) {
|
||||
t.Fatalf("run fingerprint = %#v", loadedRun.Stages["prepare"].SemanticConfig)
|
||||
}
|
||||
|
||||
legacyPath := filepath.Join(dir, "legacy.json")
|
||||
legacy := `{"session_id":"session","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","stages":{"prepare":{"name":"prepare","status":"succeeded","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}}`
|
||||
if err := os.WriteFile(legacyPath, []byte(legacy), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyManifest, err := store.Load(ctx, legacyPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacyManifest.Stages["prepare"].SemanticConfig != nil {
|
||||
t.Fatalf("legacy fingerprint = %#v, want nil", legacyManifest.Stages["prepare"].SemanticConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticConfigFingerprintValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value SemanticConfigFingerprint
|
||||
want string
|
||||
}{
|
||||
{name: "zero version", value: SemanticConfigFingerprint{Digest: testSemanticDigest}, want: "positive"},
|
||||
{name: "uppercase digest", value: SemanticConfigFingerprint{Version: 1, Digest: strings.ToUpper(testSemanticDigest)}, want: "lowercase"},
|
||||
{name: "short digest", value: SemanticConfigFingerprint{Version: 1, Digest: "abcd"}, want: "lowercase"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := test.value.Validate(); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,11 @@ func validateLoadedManifest(m *Manifest) error {
|
||||
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||
return fmt.Errorf("stages.%s: %w", name, err)
|
||||
}
|
||||
if stage.SemanticConfig != nil {
|
||||
if err := stage.SemanticConfig.Validate(); err != nil {
|
||||
return fmt.Errorf("stages.%s.semantic_config: %w", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -343,6 +348,11 @@ func validateLoadedRunManifest(m *RunManifest) error {
|
||||
if err := validateAnalyzeStageState(name, stage.AnalyzeStateVersion, stage.AnalyzeArtifacts); err != nil {
|
||||
return fmt.Errorf("stages.%s: %w", name, err)
|
||||
}
|
||||
if stage.SemanticConfig != nil {
|
||||
if err := stage.SemanticConfig.Validate(); err != nil {
|
||||
return fmt.Errorf("stages.%s.semantic_config: %w", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user