Add semantic configuration resume evidence

This commit is contained in:
2026-08-30 12:44:26 +00:00
parent b97b12da7f
commit 82cb53e107
13 changed files with 761 additions and 37 deletions

View File

@@ -65,17 +65,19 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
return err
}
for _, selectedStage := range stages {
semanticConfig, err := currentStageSemanticConfig(selectedStage, stageEnv)
if err != nil {
return fmt.Errorf("plan: fingerprint semantic configuration for stage %q: %w", selectedStage.Name(), err)
}
action := decideStageAction(selectedStage, model, request.Force)
var validation *stage.ResumeValidation
if validator, ok := selectedStage.(stage.ResumeValidator); ok &&
(action == stageActionSkip || selectedStage.Name() == "analyze") {
checked, validationErr := validator.ValidateResume(ctx, stageEnv, model)
if action == stageActionSkip {
checked, validationErr := evaluateStageResume(ctx, selectedStage, stageEnv, model, semanticConfig)
if validationErr != nil {
return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr)
}
checked = checked.Normalized()
validation = &checked
if action == stageActionSkip && !checked.Resumable {
validation = checked
if checked != nil && !checked.Resumable {
at := time.Now().UTC()
model.MarkStageStale(selectedStage.Name(), at, checked.Reason)
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(
@@ -86,6 +88,16 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
action = stageActionRun
}
}
if action == stageActionRun && selectedStage.Name() == "analyze" {
if validator, ok := selectedStage.(stage.ResumeValidator); ok {
checked, validationErr := validator.ValidateResume(ctx, stageEnv, model)
if validationErr != nil {
return fmt.Errorf("plan: validate resume for stage %q: %w", selectedStage.Name(), validationErr)
}
checked = checked.Normalized()
validation = &checked
}
}
if action == stageActionRun {
runCount++
} else {
@@ -100,7 +112,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
}
}
if action == stageActionRun {
if err := modelPlannedStageRun(model, selectedStage, cfg, request.Force); err != nil {
if err := modelPlannedStageRun(model, selectedStage, cfg, request.Force, semanticConfig); err != nil {
return fmt.Errorf("plan: model stage %q: %w", selectedStage.Name(), err)
}
}
@@ -129,7 +141,13 @@ func cloneManifestForPlan(source *manifest.Manifest, cfg *config.Config) (*manif
return &cloned, nil
}
func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, cfg *config.Config, force bool) error {
func modelPlannedStageRun(
model *manifest.Manifest,
selectedStage stage.Stage,
cfg *config.Config,
force bool,
semanticConfig *manifest.SemanticConfigFingerprint,
) error {
prior := capturePriorStageOutcome(model, selectedStage.Name())
at := time.Now().UTC()
model.MarkStageRunning(selectedStage.Name(), at)
@@ -142,6 +160,7 @@ func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, c
}
if reason := plannedSelfSkipReason(selectedStage.Name(), cfg); reason != "" {
model.MarkStageSkipped(selectedStage.Name(), at, reason)
setSessionStageSemanticConfig(model, selectedStage.Name(), semanticConfig)
if !prior.isSameSelfSkip(reason) {
_, err := invalidateDependentSucceededStagesWithReason(
model, selectedStage.Name(), at, staleReasonSelfSkip,
@@ -151,6 +170,7 @@ func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, c
return nil
}
model.MarkStageSucceeded(selectedStage.Name(), at, nil)
setSessionStageSemanticConfig(model, selectedStage.Name(), semanticConfig)
if !prior.exists || prior.status != manifest.StatusSucceeded {
if _, err := invalidateDependentSucceededStagesWithReason(
model, selectedStage.Name(), at, staleReasonChangedResult,

View File

@@ -259,38 +259,42 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
for _, s := range stages {
stageEnv.Force = opts.Force
runNames = append(runNames, s.Name())
semanticConfig, err := currentStageSemanticConfig(s, stageEnv)
if err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("fingerprint semantic configuration for stage %q: %w", s.Name(), err),
)
}
action := decideStageAction(s, m, opts.Force)
if action == stageActionSkip {
if validator, ok := s.(stage.ResumeValidator); ok {
validation, err := validator.ValidateResume(ctx, stageEnv, m)
if err != nil {
validation, err := evaluateStageResume(ctx, s, stageEnv, m, semanticConfig)
if err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("validate resume for stage %q: %w", s.Name(), err),
)
}
if validation != nil && !validation.Resumable {
staleAt := nowUTC()
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
if _, err := invalidateDependentSucceededStagesWithReason(
m, s.Name(), staleAt, staleReasonNotResumable,
); err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("validate resume for stage %q: %w", s.Name(), err),
fmt.Errorf("invalidate dependents after resume validation for stage %q: %w", s.Name(), err),
)
}
validation = validation.Normalized()
if !validation.Resumable {
staleAt := nowUTC()
m.MarkStageStale(s.Name(), staleAt, validation.Reason)
if _, err := invalidateDependentSucceededStagesWithReason(
m, s.Name(), staleAt, staleReasonNotResumable,
); err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("invalidate dependents after resume validation for stage %q: %w", s.Name(), err),
)
}
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("save manifest after resume validation for stage %q: %w", s.Name(), err),
)
}
env.Logger.Info("stage result is not resumable", "stage", s.Name(), "reason", validation.Reason)
action = stageActionRun
if err := env.ManifestStore.Save(ctx, manifestPath, m); err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
fmt.Errorf("save manifest after resume validation for stage %q: %w", s.Name(), err),
)
}
env.Logger.Info("stage result is not resumable", "stage", s.Name(), "reason", validation.Reason)
action = stageActionRun
}
}
@@ -299,6 +303,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
skipAt := nowUTC()
runManifest.SetStageAction(s.Name(), manifest.RunStageActionSkip, skipAt)
runManifest.MarkStageSkipped(s.Name(), skipAt, "already_succeeded")
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
return nil, persistTerminalFailure(
ctx, env.ManifestStore, manifestPath, m, runManifestStore, runManifestPath, runManifest,
@@ -380,6 +385,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
skippedAt := nowUTC()
m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
applyStageResultToManifest(m, s.Name(), result)
setSessionStageSemanticConfig(m, s.Name(), semanticConfig)
if !priorOutcome.isSameSelfSkip(result.SkipReason) {
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip); err != nil {
return nil, persistTerminalFailure(
@@ -396,6 +402,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
}
runManifest.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason)
applyStageResultToRunManifest(runManifest, s.Name(), result)
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
identity.applyToRunManifest(runManifest, manifestPath)
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
return nil, persistTerminalFailure(
@@ -418,6 +425,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
m.MarkStageSucceeded(s.Name(), succeededAt, sessionOutputs)
applyAnalyzeProjection(m, runManifest, analyzeProjection)
applyStageResultToManifest(m, s.Name(), result)
setSessionStageSemanticConfig(m, s.Name(), semanticConfig)
if !priorOutcome.exists || priorOutcome.status != manifest.StatusSucceeded {
if _, err := invalidateDependentSucceededStagesWithReason(m, s.Name(), succeededAt, staleReasonChangedResult); err != nil {
return nil, persistTerminalFailure(
@@ -445,6 +453,7 @@ func executePlan(ctx context.Context, cfg *config.Config, plan BoundedPlan, opts
}
runManifest.MarkStageSucceeded(s.Name(), succeededAt, runOutputs)
applyStageResultToRunManifest(runManifest, s.Name(), result)
setRunStageSemanticConfig(runManifest, s.Name(), semanticConfig)
identity.applyToRunManifest(runManifest, manifestPath)
if err := runManifestStore.SaveRun(ctx, runManifestPath, runManifest); err != nil {
return nil, persistTerminalFailure(

View File

@@ -0,0 +1,95 @@
package app
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func currentStageSemanticConfig(selected stage.Stage, env *stage.Env) (*manifest.SemanticConfigFingerprint, error) {
provider, ok := selected.(stage.SemanticConfigFingerprinter)
if !ok {
return nil, nil
}
record, err := provider.SemanticConfigFingerprint(env)
if err != nil {
return nil, err
}
if err := record.Validate(); err != nil {
return nil, fmt.Errorf("invalid current semantic configuration fingerprint: %w", err)
}
return cloneSemanticConfig(&record), nil
}
func validateStageSemanticResume(selected stage.Stage, m *manifest.Manifest, current *manifest.SemanticConfigFingerprint) stage.ResumeValidation {
if current == nil {
return stage.Resumable()
}
var persisted *manifest.SemanticConfigFingerprint
if m != nil && m.Stages != nil && m.Stages[selected.Name()] != nil {
persisted = m.Stages[selected.Name()].SemanticConfig
}
if persisted == nil {
return stage.NonResumable("semantic configuration evidence is missing; rerun the stage to establish current evidence")
}
if err := persisted.Validate(); err != nil {
return stage.NonResumable("persisted semantic configuration evidence is malformed; rerun the stage")
}
if persisted.Version != current.Version {
return stage.NonResumable("semantic configuration contract version changed; rerun the stage")
}
if persisted.Digest != current.Digest {
return stage.NonResumable("result-affecting configuration changed; rerun the stage")
}
return stage.Resumable()
}
func evaluateStageResume(
ctx context.Context,
selected stage.Stage,
env *stage.Env,
m *manifest.Manifest,
current *manifest.SemanticConfigFingerprint,
) (*stage.ResumeValidation, error) {
semantic := validateStageSemanticResume(selected, m, current).Normalized()
if !semantic.Resumable {
return &semantic, nil
}
validator, ok := selected.(stage.ResumeValidator)
if !ok {
if current == nil {
return nil, nil
}
return &semantic, nil
}
validation, err := validator.ValidateResume(ctx, env, m)
if err != nil {
return nil, err
}
validation = validation.Normalized()
return &validation, nil
}
func setSessionStageSemanticConfig(m *manifest.Manifest, name string, value *manifest.SemanticConfigFingerprint) {
if m == nil || m.Stages == nil || m.Stages[name] == nil {
return
}
m.Stages[name].SemanticConfig = cloneSemanticConfig(value)
}
func setRunStageSemanticConfig(m *manifest.RunManifest, name string, value *manifest.SemanticConfigFingerprint) {
if m == nil || m.Stages == nil || m.Stages[name] == nil {
return
}
m.Stages[name].SemanticConfig = cloneSemanticConfig(value)
}
func cloneSemanticConfig(value *manifest.SemanticConfigFingerprint) *manifest.SemanticConfigFingerprint {
if value == nil {
return nil
}
copy := *value
return &copy
}

View File

@@ -0,0 +1,305 @@
package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
type semanticStage struct {
name string
fingerprint manifest.SemanticConfigFingerprint
fingerprintErr error
result *stage.StageResult
runErr error
runs *int
}
func (s semanticStage) Name() string { return s.name }
func (s semanticStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
if s.runs != nil {
*s.runs++
}
return s.result, s.runErr
}
func (s semanticStage) SemanticConfigFingerprint(_ *stage.Env) (manifest.SemanticConfigFingerprint, error) {
return s.fingerprint, s.fingerprintErr
}
type semanticResumeCheckingStage struct {
semanticStage
validation stage.ResumeValidation
validationCalls *int
}
func (s semanticResumeCheckingStage) ValidateResume(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (stage.ResumeValidation, error) {
if s.validationCalls != nil {
*s.validationCalls++
}
return s.validation, nil
}
func TestSemanticResumeComparison(t *testing.T) {
current := semanticFingerprint(1, "a")
selected := semanticStage{name: "render", fingerprint: current}
for _, test := range []struct {
name string
persisted *manifest.SemanticConfigFingerprint
resumable bool
want string
}{
{name: "matching", persisted: &current, resumable: true},
{name: "missing", want: "missing"},
{name: "version", persisted: fingerprintPointer(semanticFingerprint(2, "a")), want: "version"},
{name: "digest", persisted: fingerprintPointer(semanticFingerprint(1, "b")), want: "configuration changed"},
{name: "malformed", persisted: &manifest.SemanticConfigFingerprint{Version: 1, Digest: "bad"}, want: "malformed"},
} {
t.Run(test.name, func(t *testing.T) {
model := manifest.New("session", time.Now().UTC())
model.MarkStageSucceeded("render", time.Now().UTC(), nil)
model.Stages["render"].SemanticConfig = test.persisted
got := validateStageSemanticResume(selected, model, &current)
if got.Resumable != test.resumable || (test.want != "" && !strings.Contains(got.Reason, test.want)) {
t.Fatalf("validation = %#v, want resumable=%v reason %q", got, test.resumable, test.want)
}
})
}
}
func TestExecuteStagesSemanticEvidenceLifecycle(t *testing.T) {
for _, test := range []struct {
name string
persisted *manifest.SemanticConfigFingerprint
force bool
wantRuns int
wantSkipped int
}{
{name: "matching skips", persisted: fingerprintPointer(semanticFingerprint(1, "current")), wantSkipped: 1},
{name: "legacy missing reruns", wantRuns: 1},
{name: "version reruns", persisted: fingerprintPointer(semanticFingerprint(2, "current")), wantRuns: 1},
{name: "digest reruns", persisted: fingerprintPointer(semanticFingerprint(1, "old")), wantRuns: 1},
{name: "force reruns matching", persisted: fingerprintPointer(semanticFingerprint(1, "current")), force: true, wantRuns: 1},
} {
t.Run(test.name, func(t *testing.T) {
cfg := testConfig(t)
store := &manifest.LocalStore{}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
seed.MarkStageSucceeded("render", time.Now().UTC(), nil)
seed.Stages["render"].SemanticConfig = test.persisted
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatal(err)
}
runs := 0
current := semanticFingerprint(1, "current")
candidate := semanticStage{name: "render", fingerprint: current, result: &stage.StageResult{}, runs: &runs}
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{Force: test.force})
if err != nil {
t.Fatal(err)
}
if runs != test.wantRuns || len(summary.Skipped) != test.wantSkipped {
t.Fatalf("runs=%d summary=%#v", runs, summary)
}
loaded, err := store.Load(context.Background(), summary.ManifestPath)
if err != nil {
t.Fatal(err)
}
if loaded.Stages["render"].SemanticConfig == nil || !loaded.Stages["render"].SemanticConfig.Equal(current) {
t.Fatalf("session evidence = %#v", loaded.Stages["render"].SemanticConfig)
}
run, err := store.LoadRun(context.Background(), summary.RunManifestPath)
if err != nil {
t.Fatal(err)
}
if run.Stages["render"].SemanticConfig == nil || !run.Stages["render"].SemanticConfig.Equal(current) {
t.Fatalf("run evidence = %#v", run.Stages["render"].SemanticConfig)
}
})
}
}
func TestSemanticEvidenceRequiresBothChecksAndNeverPromotesFailure(t *testing.T) {
t.Run("semantic mismatch precedes resume validator", func(t *testing.T) {
cfg := testConfig(t)
store := &manifest.LocalStore{}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
seed.MarkStageSucceeded("render", time.Now().UTC(), nil)
seed.Stages["render"].SemanticConfig = fingerprintPointer(semanticFingerprint(1, "old"))
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatal(err)
}
runs, validations := 0, 0
candidate := semanticResumeCheckingStage{
semanticStage: semanticStage{name: "render", fingerprint: semanticFingerprint(1, "new"), result: &stage.StageResult{}, runs: &runs},
validation: stage.Resumable(), validationCalls: &validations,
}
if _, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{}); err != nil {
t.Fatal(err)
}
if runs != 1 || validations != 0 {
t.Fatalf("runs=%d validations=%d", runs, validations)
}
})
t.Run("matching semantic evidence still requires validator", func(t *testing.T) {
cfg := testConfig(t)
store := &manifest.LocalStore{}
current := semanticFingerprint(1, "same")
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
seed.MarkStageSucceeded("render", time.Now().UTC(), nil)
seed.Stages["render"].SemanticConfig = &current
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatal(err)
}
runs, validations := 0, 0
candidate := semanticResumeCheckingStage{
semanticStage: semanticStage{name: "render", fingerprint: current, result: &stage.StageResult{}, runs: &runs},
validation: stage.NonResumable("output changed"), validationCalls: &validations,
}
if _, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{}); err != nil {
t.Fatal(err)
}
if runs != 1 || validations != 1 {
t.Fatalf("runs=%d validations=%d", runs, validations)
}
})
t.Run("failed execution has no promoted evidence", func(t *testing.T) {
cfg := testConfig(t)
runs := 0
candidate := semanticStage{
name: "render", fingerprint: semanticFingerprint(1, "new"), runErr: errors.New("render failed"), runs: &runs,
}
_, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
if err == nil {
t.Fatal("executeStages() error = nil")
}
loaded, loadErr := (&manifest.LocalStore{}).Load(context.Background(), manifestPathFor(cfg))
if loadErr != nil {
t.Fatal(loadErr)
}
if loaded.Stages["render"].SemanticConfig != nil || runs != 1 {
t.Fatalf("failed evidence=%#v runs=%d", loaded.Stages["render"].SemanticConfig, runs)
}
})
}
func TestIntentionalStageSkipPersistsSemanticEvidence(t *testing.T) {
cfg := testConfig(t)
current := semanticFingerprint(1, "disabled")
runs := 0
candidate := semanticStage{
name: "render", fingerprint: current, runs: &runs,
result: &stage.StageResult{Disposition: stage.StageDispositionSkipped, SkipReason: "render disabled"},
}
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
if err != nil {
t.Fatal(err)
}
store := &manifest.LocalStore{}
session, err := store.Load(context.Background(), summary.ManifestPath)
if err != nil {
t.Fatal(err)
}
run, err := store.LoadRun(context.Background(), summary.RunManifestPath)
if err != nil {
t.Fatal(err)
}
if runs != 1 || session.Stages["render"].SemanticConfig == nil || run.Stages["render"].SemanticConfig == nil ||
!session.Stages["render"].SemanticConfig.Equal(current) || !run.Stages["render"].SemanticConfig.Equal(current) {
t.Fatalf("runs=%d session=%#v run=%#v", runs, session.Stages["render"], run.Stages["render"])
}
}
func TestInvalidCurrentSemanticEvidenceStopsBeforeExecution(t *testing.T) {
cfg := testConfig(t)
runs := 0
candidate := semanticStage{
name: "render", fingerprint: manifest.SemanticConfigFingerprint{Version: 1, Digest: "invalid"}, runs: &runs,
}
_, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{Force: true})
if err == nil || !strings.Contains(err.Error(), "invalid current semantic configuration fingerprint") || runs != 0 {
t.Fatalf("error=%v runs=%d", err, runs)
}
}
func TestReadOnlySemanticResumeDecisionMutatesOnlyPlanModel(t *testing.T) {
cfg := testConfig(t)
original := manifest.New(cfg.Session.SessionID, time.Now().UTC())
original.MarkStageSucceeded("render", time.Now().UTC(), nil)
original.MarkStageSucceeded("extract", time.Now().UTC(), nil)
original.MarkStageSucceeded("analyze", time.Now().UTC(), nil)
original.Stages["render"].SemanticConfig = fingerprintPointer(semanticFingerprint(1, "old"))
model, err := cloneManifestForPlan(original, cfg)
if err != nil {
t.Fatal(err)
}
selected := semanticStage{name: "render", fingerprint: semanticFingerprint(1, "new")}
current, err := currentStageSemanticConfig(selected, &stage.Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
validation, err := evaluateStageResume(context.Background(), selected, &stage.Env{Config: cfg}, model, current)
if err != nil {
t.Fatal(err)
}
if validation == nil || validation.Resumable {
t.Fatalf("validation = %#v, want planned rerun", validation)
}
at := time.Now().UTC()
model.MarkStageStale("render", at, validation.Reason)
if _, err := invalidateDependentSucceededStagesWithReason(model, "render", at, staleReasonNotResumable); err != nil {
t.Fatal(err)
}
if model.Stages["render"].Status != manifest.StatusStale || model.Stages["analyze"].Status != manifest.StatusStale {
t.Fatalf("model render=%q analyze=%q", model.Stages["render"].Status, model.Stages["analyze"].Status)
}
if original.Stages["render"].Status != manifest.StatusSucceeded || original.Stages["analyze"].Status != manifest.StatusSucceeded {
t.Fatalf("authoritative manifest mutated: render=%q analyze=%q", original.Stages["render"].Status, original.Stages["analyze"].Status)
}
}
func TestSemanticMismatchInvalidatesOnlyFixedDependents(t *testing.T) {
cfg := testConfig(t)
store := &manifest.LocalStore{}
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
for _, name := range []string{"render", "extract", "analyze", "publish", "notify"} {
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
seed.Stages["render"].SemanticConfig = fingerprintPointer(semanticFingerprint(1, "old"))
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
t.Fatal(err)
}
runs := 0
candidate := semanticStage{name: "render", fingerprint: semanticFingerprint(1, "new"), result: &stage.StageResult{}, runs: &runs}
if _, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{}); err != nil {
t.Fatal(err)
}
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
if err != nil {
t.Fatal(err)
}
if loaded.Stages["render"].Status != manifest.StatusSucceeded || loaded.Stages["extract"].Status != manifest.StatusSucceeded {
t.Fatalf("render=%q extract=%q", loaded.Stages["render"].Status, loaded.Stages["extract"].Status)
}
for _, name := range []string{"analyze", "publish", "notify"} {
if loaded.Stages[name].Status != manifest.StatusStale {
t.Fatalf("%s status = %q, want stale", name, loaded.Stages[name].Status)
}
}
}
func semanticFingerprint(version int, seed string) manifest.SemanticConfigFingerprint {
digest := sha256.Sum256([]byte(seed))
return manifest.SemanticConfigFingerprint{Version: version, Digest: hex.EncodeToString(digest[:])}
}
func fingerprintPointer(value manifest.SemanticConfigFingerprint) *manifest.SemanticConfigFingerprint {
return &value
}