diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1228e45..1807268 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -34,7 +34,7 @@ implementation sequence. | Stage 13 | Complete | | Stage 14 | Complete | | Stage 15 | Complete | -| Stage 16 | Pending | +| Stage 16 | Complete | | Stage 17 | Pending | | Stage 18 | Pending | diff --git a/internal/app/extract_lifecycle_test.go b/internal/app/extract_lifecycle_test.go index f36c191..b7bc574 100644 --- a/internal/app/extract_lifecycle_test.go +++ b/internal/app/extract_lifecycle_test.go @@ -171,10 +171,20 @@ func TestExtractLifecycleForcedSelfSkipInvalidatesDownstream(t *testing.T) { func TestExtractLifecycleForcedFailureInvalidatesDownstream(t *testing.T) { cfg, env, runner := extractionLifecycleFixture(t, true) - runner.failuresRemaining = 1 - markLifecycleStageSucceeded(t, cfg, "analyze") plan, _ := BuildSingleStagePlan("extract") + first, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env}) + if err != nil { + t.Fatalf("initial executeStages() error = %v", err) + } + succeeded := loadLifecycleManifest(t, cfg).Stages["extract"] + if succeeded == nil || succeeded.Status != manifest.StatusSucceeded || len(succeeded.Outputs) == 0 || len(succeeded.Logs) == 0 || len(succeeded.Metadata) == 0 { + t.Fatalf("initial extraction result = %#v, want succeeded result details", succeeded) + } + + markLifecycleStageSucceeded(t, cfg, "analyze") + runner.failuresRemaining = 1 + if _, err := executeStages(context.Background(), cfg, plan, RunOptions{Env: env, Force: true}); err == nil { t.Fatal("executeStages() error = nil, want forced extraction failure") } @@ -185,6 +195,21 @@ func TestExtractLifecycleForcedFailureInvalidatesDownstream(t *testing.T) { if loaded.Stages["analyze"].Error == nil || loaded.Stages["analyze"].Error.Message != staleReasonForcedReplacement { t.Fatalf("analyze stale reason = %#v, want %q", loaded.Stages["analyze"].Error, staleReasonForcedReplacement) } + failed := loaded.Stages["extract"] + if len(failed.Outputs) != 0 || len(failed.Logs) != 0 || len(failed.GeneratedConfigs) != 0 || len(failed.Metadata) != 0 { + t.Fatalf("failed replacement inherited extraction result details: %#v", failed) + } + historical, err := (&manifest.LocalStore{}).LoadRun(context.Background(), first.RunManifestPath) + if err != nil { + t.Fatalf("LoadRun(initial) error = %v", err) + } + historicalExtract := historical.Stages["extract"] + if historicalExtract == nil || historicalExtract.Status != manifest.StatusSucceeded || len(historicalExtract.Outputs) == 0 || len(historicalExtract.Logs) == 0 || len(historicalExtract.Metadata) == 0 { + t.Fatalf("historical extraction result = %#v, want preserved succeeded details", historicalExtract) + } + if _, err := os.Stat(succeeded.Outputs[0].LocalPath); err != nil { + t.Fatalf("durable extraction output was not preserved: %v", err) + } } func TestExtractLifecycleRepeatedSelfSkipPreservesSucceededDownstream(t *testing.T) { diff --git a/internal/app/runner.go b/internal/app/runner.go index 0af4ab6..4fc9e48 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -248,7 +248,6 @@ func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage skipped = append(skipped, s.Name()) skippedAt := nowUTC() m.MarkStageSkipped(s.Name(), skippedAt, result.SkipReason) - clearStageResultDetails(m.Stages[s.Name()]) applyStageResultToManifest(m, s.Name(), result) if !priorOutcome.isSameSelfSkip(result.SkipReason) { invalidateDownstreamSucceededStagesWithReason(m, s.Name(), skippedAt, staleReasonSelfSkip) @@ -562,15 +561,6 @@ func applyStageResultToManifest(m *manifest.Manifest, stageName string, result * } } -func clearStageResultDetails(sr *manifest.StageRecord) { - if sr == nil { - return - } - sr.Logs = nil - sr.GeneratedConfigs = nil - sr.Metadata = nil -} - func ensureManifestIdentity(cfg *config.Config, m *manifest.Manifest, runID string) (bool, error) { if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil || m == nil { return false, nil diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go index 9c12cfe..eebeccd 100644 --- a/internal/app/runner_test.go +++ b/internal/app/runner_test.go @@ -742,6 +742,41 @@ func TestExecuteStagesForceRerunsSucceeded(t *testing.T) { } } +func TestExecuteStagesSuccessfulReplacementDoesNotInheritResultDetails(t *testing.T) { + cfg := testConfig(t) + manifestPath := manifestPathFor(cfg) + store := &manifest.LocalStore{} + + existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC)) + existing.MarkStageSucceeded("transcribe", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), []manifest.ArtifactRecord{{ + Kind: "transcript_raw", LocalPath: "transcripts/old.json", + }}) + existingStage := existing.Stages["transcribe"] + existingStage.Logs = []string{"logs/old.log"} + existingStage.GeneratedConfigs = []string{"generated/old.yaml"} + existingStage.Metadata = map[string]any{"old_result": true} + if err := store.Save(context.Background(), manifestPath, existing); err != nil { + t.Fatalf("Save manifest error = %v", err) + } + + runs := 0 + replacement := resultStage{name: "transcribe", result: &stage.StageResult{}, runs: &runs} + if _, err := executeStages(context.Background(), cfg, []stage.Stage{replacement}, RunOptions{Force: true}); err != nil { + t.Fatalf("executeStages() error = %v", err) + } + loaded, err := store.Load(context.Background(), manifestPath) + if err != nil { + t.Fatalf("Load manifest error = %v", err) + } + record := loaded.Stages["transcribe"] + if runs != 1 || record == nil || record.Status != manifest.StatusSucceeded { + t.Fatalf("runs = %d, record = %#v, want one successful replacement", runs, record) + } + if len(record.Outputs) != 0 || len(record.Logs) != 0 || len(record.GeneratedConfigs) != 0 || len(record.Metadata) != 0 { + t.Fatalf("replacement inherited result details: %#v", record) + } +} + func TestExecuteStagesForceSuccessInvalidatesDownstreamSucceededStages(t *testing.T) { cfg := testConfig(t) manifestPath := manifestPathFor(cfg) diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index dde661e..f9c9d9a 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -88,6 +88,7 @@ func New(sessionID string, now time.Time) *Manifest { // MarkStageRunning marks a stage as running and updates timestamps. func (m *Manifest) MarkStageRunning(name string, at time.Time) { s := m.ensureStage(name, at) + s.clearResultDetails() s.Status = StatusRunning s.StartedAt = timePtr(at) s.CompletedAt = nil @@ -110,6 +111,7 @@ func (m *Manifest) MarkStageSucceeded(name string, at time.Time, outputs []Artif // MarkStageFailed marks a stage as failed and records error metadata. func (m *Manifest) MarkStageFailed(name string, at time.Time, message string) { s := m.ensureStage(name, at) + s.clearResultDetails() s.Status = StatusFailed s.CompletedAt = timePtr(at) s.Error = &ErrorRecord{Message: strings.TrimSpace(message), At: timePtr(at)} @@ -121,10 +123,10 @@ func (m *Manifest) MarkStageFailed(name string, at time.Time, message string) { // MarkStageSkipped marks a stage as skipped and records the skip reason. func (m *Manifest) MarkStageSkipped(name string, at time.Time, reason string) { s := m.ensureStage(name, at) + s.clearResultDetails() s.Status = StatusSkipped s.CompletedAt = timePtr(at) s.Error = &ErrorRecord{Message: strings.TrimSpace(reason), Code: "skipped", At: timePtr(at)} - s.Outputs = nil s.UpdatedAt = at m.UpdatedAt = at } @@ -138,6 +140,13 @@ func (m *Manifest) MarkStageStale(name string, at time.Time, reason string) { m.UpdatedAt = at } +func (s *StageRecord) clearResultDetails() { + s.Outputs = nil + s.Logs = nil + s.GeneratedConfigs = nil + s.Metadata = nil +} + func (m *Manifest) ensureStage(name string, at time.Time) *StageRecord { if m.Stages == nil { m.Stages = map[string]*StageRecord{} diff --git a/internal/manifest/manifest_test.go b/internal/manifest/manifest_test.go index cafa9ef..b5ef364 100644 --- a/internal/manifest/manifest_test.go +++ b/internal/manifest/manifest_test.go @@ -69,11 +69,12 @@ func TestStageMarkHelpers(t *testing.T) { } } -func TestMarkStageRunningClearsPriorCompletionAndError(t *testing.T) { +func TestMarkStageRunningClearsPriorCompletionErrorAndResultDetails(t *testing.T) { m := New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) failedAt := time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC) m.MarkStageFailed("merge", failedAt, "boom") + setStageResultDetails(m.Stages["merge"]) runAt := failedAt.Add(1 * time.Minute) m.MarkStageRunning("merge", runAt) @@ -91,6 +92,7 @@ func TestMarkStageRunningClearsPriorCompletionAndError(t *testing.T) { if stage.Error != nil { t.Fatalf("error = %#v, want nil while running", stage.Error) } + requireStageResultDetailsCleared(t, stage) } func TestMarkStageSucceededClearsError(t *testing.T) { @@ -111,11 +113,24 @@ func TestMarkStageSucceededClearsError(t *testing.T) { } } -func TestMarkStageSkippedClearsEarlierOutputs(t *testing.T) { +func TestMarkStageFailedClearsEarlierResultDetails(t *testing.T) { m := New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) - m.MarkStageSucceeded("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), []ArtifactRecord{ - {Kind: "structured_data", SourceID: "narratio.example.characters", LocalPath: "artifacts/characters.json"}, - }) + m.MarkStageSucceeded("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) + setStageResultDetails(m.Stages["extract"]) + + m.MarkStageFailed("extract", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), "replacement failed") + + stage := m.Stages["extract"] + if stage == nil || stage.Status != StatusFailed { + t.Fatalf("stage = %#v, want failed", stage) + } + requireStageResultDetailsCleared(t, stage) +} + +func TestMarkStageSkippedClearsEarlierResultDetails(t *testing.T) { + m := New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + m.MarkStageSucceeded("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) + setStageResultDetails(m.Stages["extract"]) m.MarkStageSkipped("extract", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), "integration_disabled") @@ -126,7 +141,39 @@ func TestMarkStageSkippedClearsEarlierOutputs(t *testing.T) { if stage.Status != StatusSkipped { t.Fatalf("status = %q, want %q", stage.Status, StatusSkipped) } - if len(stage.Outputs) != 0 { - t.Fatalf("outputs = %#v, want cleared", stage.Outputs) + requireStageResultDetailsCleared(t, stage) +} + +func TestMarkStageStalePreservesResultDetails(t *testing.T) { + m := New("2026-05-03", time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)) + m.MarkStageSucceeded("extract", time.Date(2026, 5, 3, 10, 1, 0, 0, time.UTC), nil) + stage := m.Stages["extract"] + setStageResultDetails(stage) + + m.MarkStageStale("extract", time.Date(2026, 5, 3, 10, 2, 0, 0, time.UTC), "result is not resumable") + + if stage.Status != StatusStale { + t.Fatalf("status = %q, want %q", stage.Status, StatusStale) + } + if len(stage.Outputs) != 1 || len(stage.Logs) != 1 || len(stage.GeneratedConfigs) != 1 || len(stage.Metadata) != 1 { + t.Fatalf("result details were not preserved: %#v", stage) + } +} + +func setStageResultDetails(stage *StageRecord) { + stage.Outputs = []ArtifactRecord{{ + Kind: "structured_data", + SourceID: "narratio.example.characters", + LocalPath: "artifacts/characters.json", + }} + stage.Logs = []string{"logs/extract.log"} + stage.GeneratedConfigs = []string{"generated/extract.yaml"} + stage.Metadata = map[string]any{"bundle_path": "extract/results/run-1"} +} + +func requireStageResultDetailsCleared(t *testing.T, stage *StageRecord) { + t.Helper() + if len(stage.Outputs) != 0 || len(stage.Logs) != 0 || len(stage.GeneratedConfigs) != 0 || len(stage.Metadata) != 0 { + t.Fatalf("result details were not cleared: %#v", stage) } }