Integrate extraction lifecycle and resume validation
This commit is contained in:
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -74,6 +75,23 @@ type captureNotariusStage struct {
|
||||
captured *bool
|
||||
}
|
||||
|
||||
type resumeCheckingStage struct {
|
||||
name string
|
||||
validation stage.ResumeValidation
|
||||
validateErr error
|
||||
runs *int
|
||||
}
|
||||
|
||||
func (s resumeCheckingStage) Name() string { return s.name }
|
||||
func (s resumeCheckingStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||
func (s resumeCheckingStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||
*s.runs++
|
||||
return &stage.StageResult{}, nil
|
||||
}
|
||||
func (s resumeCheckingStage) ValidateResume(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (stage.ResumeValidation, error) {
|
||||
return s.validation, s.validateErr
|
||||
}
|
||||
|
||||
func (s captureNotariusStage) Name() string { return "extract" }
|
||||
func (s captureNotariusStage) Declares() stage.IODecl { return stage.IODecl{} }
|
||||
func (s captureNotariusStage) Run(_ context.Context, env *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
|
||||
@@ -381,7 +399,7 @@ func TestExecuteStagesPublishSkipsRequiredUnselectedConfiguredOutput(t *testing.
|
||||
manifestPath := manifestPathFor(cfg)
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
seed.Campaign = cfg.Session.Campaign
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render"} {
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render"} {
|
||||
seed.MarkStageSucceeded(stageName, time.Now().UTC(), nil)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||
@@ -440,8 +458,8 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if len(summary.StageNames) != 10 || len(summary.Executed) != 10 || len(summary.Skipped) != 0 {
|
||||
t.Fatalf("summary = %#v, want all 10 executed", summary)
|
||||
if len(summary.StageNames) != 11 || len(summary.Executed) != 11 || len(summary.Skipped) != 1 || summary.Skipped[0] != "extract" {
|
||||
t.Fatalf("summary = %#v, want full plan with disabled extraction self-skip", summary)
|
||||
}
|
||||
|
||||
store := &manifest.LocalStore{}
|
||||
@@ -450,11 +468,17 @@ func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
|
||||
t.Fatalf("Load manifest error = %v", err)
|
||||
}
|
||||
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze", "publish", "notify"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze", "publish", "notify"} {
|
||||
sr := m.Stages[name]
|
||||
if sr == nil {
|
||||
t.Fatalf("missing stage record %q", name)
|
||||
}
|
||||
if name == "extract" {
|
||||
if sr.Status != manifest.StatusSkipped || sr.Error == nil || sr.Error.Message != "notarius_disabled" {
|
||||
t.Fatalf("extract stage = %#v, want disabled skip", sr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if sr.Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("stage %q status = %q, want %q", name, sr.Status, manifest.StatusSucceeded)
|
||||
}
|
||||
@@ -605,6 +629,91 @@ func TestExecuteStagesSkipSucceededWhenNotForced(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesUsesOptionalResumeValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
validation stage.ResumeValidation
|
||||
wantRuns int
|
||||
wantSkipped int
|
||||
}{
|
||||
{name: "resumable", validation: stage.Resumable(), wantSkipped: 1},
|
||||
{name: "rerun", validation: stage.NonResumable("durable output changed"), 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("checked", time.Now().UTC(), nil)
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
runs := 0
|
||||
candidate := resumeCheckingStage{name: "checked", validation: test.validation, runs: &runs}
|
||||
summary, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if runs != test.wantRuns || len(summary.Skipped) != test.wantSkipped {
|
||||
t.Fatalf("runs = %d summary = %#v", runs, summary)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesNonResumableResultRerunsSucceededDownstream(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
seed.MarkStageSucceeded("extract", time.Now().UTC(), nil)
|
||||
seed.MarkStageSucceeded("render", time.Now().UTC(), nil)
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
extractRuns, renderRuns := 0, 0
|
||||
stages := []stage.Stage{
|
||||
resumeCheckingStage{name: "extract", validation: stage.NonResumable("checksum changed"), runs: &extractRuns},
|
||||
countingStage{name: "render", runs: &renderRuns},
|
||||
}
|
||||
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
if extractRuns != 1 || renderRuns != 1 || len(summary.Executed) != 2 || len(summary.Skipped) != 0 {
|
||||
t.Fatalf("extract runs=%d render runs=%d summary=%#v", extractRuns, renderRuns, summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesResumeValidationErrorPreservesSucceededRecord(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
store := &manifest.LocalStore{}
|
||||
seed := manifest.New(cfg.Session.SessionID, time.Now().UTC())
|
||||
seed.MarkStageSucceeded("checked", time.Now().UTC(), []manifest.ArtifactRecord{{Kind: "kept", LocalPath: "kept.json"}})
|
||||
seed.Stages["checked"].Metadata = map[string]any{"kept": true}
|
||||
if err := store.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
t.Fatalf("Save() error = %v", err)
|
||||
}
|
||||
before, err := json.Marshal(seed.Stages["checked"])
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(before) error = %v", err)
|
||||
}
|
||||
runs := 0
|
||||
candidate := resumeCheckingStage{name: "checked", validateErr: errors.New("inspection unavailable"), runs: &runs}
|
||||
if _, err := executeStages(context.Background(), cfg, []stage.Stage{candidate}, RunOptions{}); err == nil || !strings.Contains(err.Error(), "inspection unavailable") {
|
||||
t.Fatalf("executeStages() error = %v", err)
|
||||
}
|
||||
loaded, err := store.Load(context.Background(), manifestPathFor(cfg))
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
after, err := json.Marshal(loaded.Stages["checked"])
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal(after) error = %v", err)
|
||||
}
|
||||
if string(after) != string(before) || runs != 0 {
|
||||
t.Fatalf("succeeded record changed: before=%s after=%s runs=%d", before, after, runs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteStagesForceRerunsSucceeded(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
manifestPath := manifestPathFor(cfg)
|
||||
@@ -639,7 +748,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
|
||||
store := &manifest.LocalStore{}
|
||||
|
||||
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "publish", "notify"} {
|
||||
for _, stageName := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "publish", "notify"} {
|
||||
existing.MarkStageSucceeded(stageName, time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
|
||||
}
|
||||
existing.MarkStageFailed("analyze", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), "previous analyze failure")
|
||||
@@ -670,7 +779,7 @@ func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testin
|
||||
if loaded.Stages["polish"] == nil || loaded.Stages["polish"].Status != manifest.StatusSucceeded {
|
||||
t.Fatalf("polish status = %#v, want succeeded", loaded.Stages["polish"])
|
||||
}
|
||||
for _, stageName := range []string{"normalize", "trim", "render", "publish", "notify"} {
|
||||
for _, stageName := range []string{"normalize", "trim", "extract", "render", "publish", "notify"} {
|
||||
if loaded.Stages[stageName] == nil || loaded.Stages[stageName].Status != manifest.StatusStale {
|
||||
t.Fatalf("%s status = %#v, want stale", stageName, loaded.Stages[stageName])
|
||||
}
|
||||
@@ -1214,7 +1323,7 @@ func TestAdapterBackedStageFailureMarksManifestFailed(t *testing.T) {
|
||||
seed.S3Bucket = "my-dnd-archive"
|
||||
seed.S3SessionPrefix = "dnd/campaigns/" + cfg.Session.Campaign + "/sessions/" + cfg.Session.SessionID + "/"
|
||||
seed.S3RunPrefix = seed.S3SessionPrefix + "runs/" + runID + "/"
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "analyze"} {
|
||||
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "extract", "render", "analyze"} {
|
||||
seed.MarkStageSucceeded(name, time.Now().UTC(), nil)
|
||||
}
|
||||
if err := tc.env.ManifestStore.Save(context.Background(), manifestPathFor(cfg), seed); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user