Integrate extraction lifecycle and resume validation

This commit is contained in:
2026-08-10 00:14:46 +00:00
parent 1f16a85330
commit bba582b4ca
23 changed files with 883 additions and 50 deletions

View File

@@ -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 {