Add artifact-aware analyze resume planning

This commit is contained in:
2026-08-29 20:01:57 +00:00
parent 4e4e2b7d96
commit 3128bef20a
14 changed files with 594 additions and 58 deletions

View File

@@ -115,8 +115,8 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
if err != nil {
t.Fatalf("RunStage() error = %v", err)
}
if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") {
t.Fatalf("output = %q, want analyze skip without force", out.String())
if !strings.Contains(out.String(), "stage=analyze executed=1 skipped=0 force=false") {
t.Fatalf("output = %q, want legacy analyze evidence rebuilt without implying force", out.String())
}
}
@@ -144,8 +144,8 @@ func TestRunArtifactsWithSucceededAnalyzeSkipsUnlessForced(t *testing.T) {
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !strings.Contains(out.String(), "executed=1 skipped=11") {
t.Fatalf("output = %q, want all stages skipped", out.String())
if !strings.Contains(out.String(), "executed=4 skipped=8") {
t.Fatalf("output = %q, want extract reconsidered and legacy analyze plus delivery rebuilt", out.String())
}
}

View File

@@ -32,7 +32,7 @@ func TestExecuteValidCommands(t *testing.T) {
wantOut string
}{
{name: "run", args: []string{"run", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run: session 2026-05-03; executed=11 skipped=1; manifest="},
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "prepare: skip\ntranscribe: skip\nmerge: skip\npolish: skip\nnormalize: skip\ntrim: skip\nrender: skip\nextract: run\nanalyze: skip\npublish: skip\nnotify: skip"},
{name: "session plan", args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "analyze: skip\n targets: none\n prerequisites: none\n execute: none\n reuse: none\npublish: skip\nnotify: skip"},
{name: "session status", args: []string{"session", "status", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "Session: 2026-05-03"},
{name: "run-stage", args: []string{"run-stage", "polish", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, wantOut: "narratio run-stage: stage=polish executed=0 skipped=1 force=false; manifest="},
}

View File

@@ -2,19 +2,21 @@ package app
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"strings"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/logging"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
// Plan validates configuration, prepares the local workdir, and prints stage order.
// Plan validates configuration and prints a read-only execution preview.
func Plan(ctx context.Context, args []string, out io.Writer) error {
request, err := parseBoundedRunRequest("plan", args, out)
if err != nil {
@@ -33,7 +35,8 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
if err := config.Validate(cfg); err != nil {
return fmt.Errorf("plan: %w", err)
}
if _, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts); err != nil {
effective, err := resolveEffectiveArtifacts(cfg, request.SelectedArtifacts)
if err != nil {
return fmt.Errorf("plan: %w", err)
}
m, err := loadManifestIfPresent(ctx, cfg)
@@ -43,33 +46,64 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
if err := validateBoundedPrerequisites(request.Plan, m); err != nil {
return fmt.Errorf("plan: %w", err)
}
if _, err := loadSecretsFromConfig(cfg, logging.NewLogger(os.Stderr, slog.LevelInfo)); err != nil {
return fmt.Errorf("plan: %w", err)
}
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
paths, err := store.EnsureLayoutFor(cfg.Session.Campaign, cfg.Session.SessionID)
paths := store.SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
model, err := cloneManifestForPlan(m, cfg)
if err != nil {
return fmt.Errorf("plan: prepare workdir: %w", err)
return fmt.Errorf("plan: clone session state: %w", err)
}
stages := request.Plan.Stages()
decisions := decideStageActions(stages, m, request.Force)
stageEnv := &stage.Env{
Config: cfg, SelectedArtifactKeys: append([]string(nil), request.SelectedArtifacts...),
EffectiveArtifacts: effective, ArtifactStore: store, Force: request.Force,
}
runCount := 0
skipCount := 0
if _, err := fmt.Fprintf(out, "narratio session plan: workdir prepared at %s\n", paths.Root); err != nil {
if _, err := fmt.Fprintf(out, "narratio session plan: read-only workdir at %s\n", paths.Root); err != nil {
return err
}
for _, d := range decisions {
if d.Action == stageActionRun {
for _, selectedStage := range stages {
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 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 {
at := time.Now().UTC()
model.MarkStageStale(selectedStage.Name(), at, checked.Reason)
if _, invalidationErr := invalidateDependentSucceededStagesWithReason(
model, selectedStage.Name(), at, staleReasonNotResumable,
); invalidationErr != nil {
return fmt.Errorf("plan: model resume invalidation for stage %q: %w", selectedStage.Name(), invalidationErr)
}
action = stageActionRun
}
}
if action == stageActionRun {
runCount++
} else {
skipCount++
}
if _, err := fmt.Fprintf(out, "%s: %s\n", d.Stage.Name(), d.Action); err != nil {
if _, err := fmt.Fprintf(out, "%s: %s\n", selectedStage.Name(), action); err != nil {
return err
}
if validation != nil && validation.Analyze != nil {
if err := writeAnalyzePlanDetails(out, validation.Analyze); err != nil {
return err
}
}
if action == stageActionRun {
if err := modelPlannedStageRun(model, selectedStage, cfg, request.Force); err != nil {
return fmt.Errorf("plan: model stage %q: %w", selectedStage.Name(), err)
}
}
}
if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil {
return err
@@ -77,3 +111,102 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
return nil
}
func cloneManifestForPlan(source *manifest.Manifest, cfg *config.Config) (*manifest.Manifest, error) {
if source == nil {
created := manifest.New(cfg.Session.SessionID, time.Now().UTC())
created.Campaign = cfg.Session.Campaign
return created, nil
}
data, err := json.Marshal(source)
if err != nil {
return nil, err
}
var cloned manifest.Manifest
if err := json.Unmarshal(data, &cloned); err != nil {
return nil, err
}
return &cloned, nil
}
func modelPlannedStageRun(model *manifest.Manifest, selectedStage stage.Stage, cfg *config.Config, force bool) error {
prior := capturePriorStageOutcome(model, selectedStage.Name())
at := time.Now().UTC()
model.MarkStageRunning(selectedStage.Name(), at)
if force {
if _, err := invalidateDependentSucceededStagesWithReason(
model, selectedStage.Name(), at, staleReasonForcedReplacement,
); err != nil {
return err
}
}
if reason := plannedSelfSkipReason(selectedStage.Name(), cfg); reason != "" {
model.MarkStageSkipped(selectedStage.Name(), at, reason)
if !prior.isSameSelfSkip(reason) {
_, err := invalidateDependentSucceededStagesWithReason(
model, selectedStage.Name(), at, staleReasonSelfSkip,
)
return err
}
return nil
}
model.MarkStageSucceeded(selectedStage.Name(), at, nil)
if !prior.exists || prior.status != manifest.StatusSucceeded {
if _, err := invalidateDependentSucceededStagesWithReason(
model, selectedStage.Name(), at, staleReasonChangedResult,
); err != nil {
return err
}
}
return nil
}
func plannedSelfSkipReason(stageName string, cfg *config.Config) string {
if stageName == "extract" && cfg != nil && cfg.Pipeline != nil &&
(cfg.Pipeline.Notarius == nil || !cfg.Pipeline.Notarius.Enabled) {
return "notarius_disabled"
}
return ""
}
func writeAnalyzePlanDetails(out io.Writer, summary *stage.AnalyzeResumeSummary) error {
if summary == nil {
return nil
}
if _, err := fmt.Fprintf(out, " targets: %s\n", planStringList(summary.ExplicitTargets)); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " prerequisites: %s\n", planArtifactList(summary.PrerequisiteWork)); err != nil {
return err
}
if _, err := fmt.Fprintf(out, " execute: %s\n", planArtifactList(summary.ExecutionOrder)); err != nil {
return err
}
_, err := fmt.Fprintf(out, " reuse: %s\n", planArtifactList(summary.ReusedCurrent))
return err
}
func planStringList(values []string) string {
if len(values) == 0 {
return "none"
}
return strings.Join(values, ", ")
}
func planArtifactList(values []stage.AnalyzeResumeArtifact) string {
if len(values) == 0 {
return "none"
}
parts := make([]string, 0, len(values))
for _, value := range values {
detail := value.Role
if value.Reason != "" {
detail += ":" + value.Reason
}
if value.Forced {
detail += ":forced"
}
parts = append(parts, fmt.Sprintf("%s(%s)", value.Key, detail))
}
return strings.Join(parts, ", ")
}

View File

@@ -3,17 +3,22 @@ package app
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
func TestPlanDoesNotCreateWorkdir(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -24,8 +29,8 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
t.Fatalf("first Plan() error = %v", err)
}
got := out.String()
if !strings.Contains(got, "narratio session plan: workdir prepared at") {
t.Fatalf("first output = %q, want workdir prepared", got)
if !strings.Contains(got, "narratio session plan: read-only workdir at") {
t.Fatalf("first output = %q, want read-only workdir", got)
}
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
if !strings.Contains(got, name+": run") {
@@ -37,26 +42,16 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
}
sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
expectedDirs := []string{
sessionWorkdir,
filepath.Join(sessionWorkdir, "inputs"),
filepath.Join(sessionWorkdir, "audio"),
filepath.Join(sessionWorkdir, "transcripts", "raw"),
filepath.Join(sessionWorkdir, "transcripts", "trimmed"),
filepath.Join(sessionWorkdir, "artifacts"),
filepath.Join(sessionWorkdir, "config"),
filepath.Join(sessionWorkdir, "logs"),
}
for _, dir := range expectedDirs {
assertDir(t, dir)
if _, err := os.Stat(sessionWorkdir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("workdir stat error = %v, want absent", err)
}
out.Reset()
if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("second Plan() error = %v", err)
}
if !strings.Contains(out.String(), "narratio session plan: workdir prepared at") {
t.Fatalf("second output = %q, want workdir prepared", out.String())
if !strings.Contains(out.String(), "narratio session plan: read-only workdir at") {
t.Fatalf("second output = %q, want read-only workdir", out.String())
}
}
@@ -89,7 +84,7 @@ func TestPlanShowsRunAndSkipFromManifest(t *testing.T) {
}
}
func TestPlanFailsWhenConfiguredSecretsDirMissing(t *testing.T) {
func TestPlanDoesNotLoadConfiguredSecrets(t *testing.T) {
workspaceRoot := t.TempDir()
configDir := t.TempDir()
pipelinePath := filepath.Join(configDir, "pipeline.yml")
@@ -130,21 +125,125 @@ inputs:
var out bytes.Buffer
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "validate secrets env_dir") {
t.Fatalf("error = %q, want secrets validation error context", err.Error())
if err != nil {
t.Fatalf("Plan() error = %v, want missing runtime secrets ignored", err)
}
}
func assertDir(t *testing.T, path string) {
t.Helper()
info, err := os.Stat(path)
func TestPlanAndRunShareAnalyzeArtifactDecisionsWithoutPlanSideEffects(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err)
t.Fatalf("load config: %v", err)
}
if !info.IsDir() {
t.Fatalf("%q is not a directory", path)
analyze, err := stage.Select("analyze")
if err != nil {
t.Fatal(err)
}
fake := &scriptorium.FakeRunner{}
first, err := executeStages(context.Background(), cfg, []stage.Stage{analyze}, RunOptions{
Env: &Env{Scriptorium: fake},
})
if err != nil {
t.Fatalf("initial analyze: %v", err)
}
if len(fake.RunRequests) != 2 {
t.Fatalf("initial adapter requests = %d, want 2", len(fake.RunRequests))
}
store := &manifest.LocalStore{}
m, err := store.Load(context.Background(), first.ManifestPath)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim"} {
m.MarkStageSucceeded(name, time.Now().UTC(), nil)
}
m.MarkStageStale("render", time.Now().UTC(), "upstream selection requires reconsideration")
m.MarkStageSkipped("extract", time.Now().UTC(), "notarius_disabled")
if err := store.Save(context.Background(), first.ManifestPath, m); err != nil {
t.Fatal(err)
}
manifestBefore, err := os.ReadFile(first.ManifestPath)
if err != nil {
t.Fatal(err)
}
filesBefore := planFixtureFiles(t, filepath.Dir(first.ManifestPath))
marker := filepath.Join(t.TempDir(), "adapter-invoked")
binaryDir := t.TempDir()
binary := filepath.Join(binaryDir, "scriptorium")
if err := os.WriteFile(binary, []byte("#!/bin/sh\ntouch \""+marker+"\"\nexit 99\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", binaryDir+string(os.PathListSeparator)+os.Getenv("PATH"))
var out bytes.Buffer
err = Plan(context.Background(), []string{
"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath,
"--from", "render", "--through", "analyze",
}, &out)
if err != nil {
t.Fatalf("Plan() error = %v", err)
}
got := out.String()
for _, want := range []string{
"render: run", "extract: run", "analyze: run",
" targets: player_handout, session_recap",
" prerequisites: none", " execute: none",
"player_handout(target:current)", "session_recap(target:current)",
} {
if !strings.Contains(got, want) {
t.Fatalf("plan output = %q, want %q", got, want)
}
}
manifestAfter, err := os.ReadFile(first.ManifestPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(manifestBefore, manifestAfter) {
t.Fatal("plan modified the session manifest")
}
if filesAfter := planFixtureFiles(t, filepath.Dir(first.ManifestPath)); !reflect.DeepEqual(filesAfter, filesBefore) {
t.Fatalf("plan files = %#v, want unchanged %#v", filesAfter, filesBefore)
}
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("adapter marker stat = %v, want absent", err)
}
fake.RunRequests = nil
actual, err := executeStages(context.Background(), cfg, []stage.Stage{
resultStage{name: "render", result: &stage.StageResult{}},
resultStage{name: "extract", result: &stage.StageResult{Disposition: stage.StageDispositionSkipped, SkipReason: "notarius_disabled"}},
analyze,
}, RunOptions{
Env: &Env{Scriptorium: fake},
})
if err != nil {
t.Fatalf("actual analyze: %v", err)
}
if !reflect.DeepEqual(actual.Executed, []string{"render", "extract", "analyze"}) ||
!reflect.DeepEqual(actual.Skipped, []string{"extract"}) || len(fake.RunRequests) != 0 {
t.Fatalf("actual decision: executed=%#v skipped=%#v adapter_requests=%d, want planned stage decisions with artifact reuse", actual.Executed, actual.Skipped, len(fake.RunRequests))
}
}
func planFixtureFiles(t *testing.T, root string) []string {
t.Helper()
var files []string
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
files = append(files, relative+":"+entry.Type().String())
return nil
})
if err != nil {
t.Fatal(err)
}
return files
}

View File

@@ -37,7 +37,7 @@ inputs:
if storeInitCalls != 1 {
t.Fatalf("object store init calls = %d, want 1", storeInitCalls)
}
if !strings.Contains(stdout.String(), "narratio session plan: workdir prepared") {
if !strings.Contains(stdout.String(), "narratio session plan: read-only workdir") {
t.Fatalf("stdout = %q, want plan output", stdout.String())
}
if _, ok := fake.Objects[remoteKey]; !ok {

View File

@@ -257,7 +257,7 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
{
name: "plan",
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath},
want: "narratio session plan: workdir prepared",
want: "narratio session plan: read-only workdir",
},
{
name: "artifacts",

View File

@@ -0,0 +1,94 @@
package stage
import (
"context"
"fmt"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func (analyzeStage) ValidateResume(_ context.Context, env *Env, m *manifest.Manifest) (ResumeValidation, error) {
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: resolved stage environment config is required")
}
cfg := env.Config.Pipeline.Scriptorium
if cfg == nil || len(cfg.Artifacts) == 0 {
return ResumeValidation{Resumable: true, Analyze: &AnalyzeResumeSummary{}}, nil
}
sessionID := ""
if m != nil {
sessionID = strings.TrimSpace(m.SessionID)
}
if sessionID == "" {
sessionID = strings.TrimSpace(env.Config.Session.SessionID)
}
if sessionID == "" {
return ResumeValidation{}, fmt.Errorf("analyze resume: session id is required")
}
effective := env.EffectiveArtifacts
var err error
if !effective.Resolved() {
effective, err = artifacts.ResolveEffectiveArtifactSet(
artifacts.ConfiguredArtifactDefinitions(cfg.Artifacts),
env.SelectedArtifactKeys,
)
if err != nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: resolve effective artifacts: %w", err)
}
}
if len(effective.Keys()) == 0 {
return ResumeValidation{Resumable: true, Analyze: &AnalyzeResumeSummary{}}, nil
}
paths := sessionPathsForEnv(env, sessionID)
catalog, err := buildAnalyzeRuntimeArtifactCatalog(
paths, m, cfg, env.Config.Pipeline.Notarius, effective,
)
if err != nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: build runtime artifact catalog: %w", err)
}
execution := analyzeExecutionContext{
Env: env, Manifest: m, Paths: paths, SessionID: sessionID,
TranscriptRefs: discoverAnalyzeTranscriptRefs(m, paths), Catalog: catalog,
}
reconciliation, err := reconcileAnalyzeArtifacts(cfg, execution)
if err != nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: reconcile configured artifacts: %w", err)
}
plan, err := planAnalyzeWork(cfg, env.SelectedArtifactKeys, env.Force, reconciliation)
if err != nil {
return ResumeValidation{}, fmt.Errorf("analyze resume: plan configured artifacts: %w", err)
}
summary := analyzeResumeSummary(plan)
if len(plan.ExecutionOrder) == 0 {
return ResumeValidation{Resumable: true, Analyze: summary}, nil
}
keys := analyzePlanKeysForMetadata(plan.ExecutionOrder)
return ResumeValidation{
Reason: "analysis artifacts require execution: " + strings.Join(keys, ", "),
Analyze: summary,
}, nil
}
func analyzeResumeSummary(plan analyzeWorkPlan) *AnalyzeResumeSummary {
return &AnalyzeResumeSummary{
ExplicitTargets: append([]string(nil), plan.ExplicitTargets...),
PrerequisiteWork: exportAnalyzeResumeItems(plan.PrerequisiteWork),
ExecutionOrder: exportAnalyzeResumeItems(plan.ExecutionOrder),
ReusedCurrent: exportAnalyzeResumeItems(plan.ReusedCurrent),
}
}
func exportAnalyzeResumeItems(items []analyzePlanItem) []AnalyzeResumeArtifact {
if len(items) == 0 {
return nil
}
result := make([]AnalyzeResumeArtifact, 0, len(items))
for _, item := range items {
result = append(result, AnalyzeResumeArtifact{
Key: item.Key, Role: string(item.Role), Reason: string(item.Reason), Forced: item.Forced,
})
}
return result
}

View File

@@ -0,0 +1,160 @@
package stage
import (
"context"
"path/filepath"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestAnalyzeResumeValidationHonorsFullAndPartialSelections(t *testing.T) {
env, m, _ := currentAnalyzeFixture(t, true)
record := m.Stages["analyze"].AnalyzeArtifacts["player_handout"]
record.Status = manifest.AnalyzeArtifactStale
record.Output = nil
record.OutputSize = 0
m.Stages["analyze"].AnalyzeArtifacts["player_handout"] = record
env.SelectedArtifactKeys = []string{"session_recap"}
partial, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if !partial.Resumable || !reflect.DeepEqual(partial.Analyze.ExplicitTargets, []string{"session_recap"}) {
t.Fatalf("partial validation = %#v, want resumable recap selection", partial)
}
env.SelectedArtifactKeys = nil
full, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if full.Resumable || !resumeArtifactKeysEqual(full.Analyze.ExecutionOrder, []string{"player_handout"}) {
t.Fatalf("full validation = %#v, want stale handout execution", full)
}
}
func TestAnalyzeResumeValidationReportsForceAndCurrentPrerequisites(t *testing.T) {
env, m, _ := currentAnalyzeFixture(t, true)
env.SelectedArtifactKeys = []string{"session_recap"}
env.Force = true
forced, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if forced.Resumable || len(forced.Analyze.ExecutionOrder) != 1 ||
forced.Analyze.ExecutionOrder[0].Key != "session_recap" || !forced.Analyze.ExecutionOrder[0].Forced {
t.Fatalf("forced validation = %#v, want only forced recap", forced)
}
env.Force = false
env.SelectedArtifactKeys = []string{"player_handout"}
dependent := env.Config.Pipeline.Scriptorium.Artifacts["player_handout"]
dependent.PromptID = "dnd.player_handout.revised"
env.Config.Pipeline.Scriptorium.Artifacts["player_handout"] = dependent
changed, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if changed.Resumable || !resumeArtifactKeysEqual(changed.Analyze.ExecutionOrder, []string{"player_handout"}) {
t.Fatalf("changed dependent validation = %#v", changed)
}
if len(changed.Analyze.ReusedCurrent) != 1 || changed.Analyze.ReusedCurrent[0].Key != "session_recap" ||
changed.Analyze.ReusedCurrent[0].Role != "prerequisite" {
t.Fatalf("reused current = %#v, want recap prerequisite", changed.Analyze.ReusedCurrent)
}
}
func TestAnalyzeResumeValidationRejectsChangedInputsTamperedOutputsAndLegacyState(t *testing.T) {
for _, test := range []struct {
name string
mutate func(t *testing.T, env *Env, m *manifest.Manifest)
}{
{
name: "changed input",
mutate: func(t *testing.T, env *Env, m *manifest.Manifest) {
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[{"text":"changed"}]}`)
},
},
{
name: "tampered output",
mutate: func(t *testing.T, env *Env, m *manifest.Manifest) {
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.ArtifactsDir, "session_recap.md"), "tampered\n")
},
},
{
name: "legacy state",
mutate: func(_ *testing.T, _ *Env, m *manifest.Manifest) {
m.Stages["analyze"].AnalyzeStateVersion = 0
m.Stages["analyze"].AnalyzeArtifacts = nil
},
},
} {
t.Run(test.name, func(t *testing.T) {
env, m, _ := currentAnalyzeFixture(t, false)
test.mutate(t, env, m)
validation, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if validation.Resumable || !resumeArtifactKeysEqual(validation.Analyze.ExecutionOrder, []string{"session_recap"}) {
t.Fatalf("validation = %#v, want recap execution", validation)
}
})
}
}
func TestAnalyzeStaleAggregateRestoresSuccessWithoutAdapterWork(t *testing.T) {
env, m, _ := currentAnalyzeFixture(t, false)
m.Stages["analyze"].Status = manifest.StatusStale
validation, err := (analyzeStage{}).ValidateResume(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
if !validation.Resumable || !resumeArtifactKeysEqual(validation.Analyze.ReusedCurrent, []string{"session_recap"}) {
t.Fatalf("validation = %#v, want current recap reuse", validation)
}
env.Scriptorium = nil
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatalf("Run() with no adapter = %v", err)
}
if got, _ := result.Metadata["executed_artifacts"].([]string); len(got) != 0 {
t.Fatalf("executed artifacts = %#v, want none", got)
}
if result.AnalyzeState.Session["session_recap"].Status != manifest.AnalyzeArtifactCurrent {
t.Fatalf("session projection = %#v, want current recap", result.AnalyzeState.Session)
}
}
func currentAnalyzeFixture(t *testing.T, dependent bool) (*Env, *manifest.Manifest, int) {
t.Helper()
env, m, fake := setupAnalyzeEnv(t)
paths := sessionPathsForEnv(env, m.SessionID)
writeAnalyzeFile(t, filepath.Join(paths.TranscriptsDir, "final.trimmed.json"), `{"segments":[]}`)
if dependent {
addAnalyzeDependentArtifact(env)
}
result, err := (analyzeStage{}).Run(context.Background(), env, m)
if err != nil {
t.Fatal(err)
}
installAnalyzeProjection(m, result.AnalyzeState)
m.Stages["analyze"].Status = manifest.StatusSucceeded
return env, m, len(fake.RunRequests)
}
func resumeArtifactKeysEqual(items []AnalyzeResumeArtifact, want []string) bool {
got := make([]string, 0, len(items))
for _, item := range items {
got = append(got, item.Key)
}
return reflect.DeepEqual(got, want)
}

View File

@@ -53,14 +53,35 @@ const maxResumeReasonLength = 512
type ResumeValidation struct {
Resumable bool
Reason string
Analyze *AnalyzeResumeSummary
}
// AnalyzeResumeSummary describes the artifact-level decision behind an
// aggregate analyze resume result.
type AnalyzeResumeSummary struct {
ExplicitTargets []string
PrerequisiteWork []AnalyzeResumeArtifact
ExecutionOrder []AnalyzeResumeArtifact
ReusedCurrent []AnalyzeResumeArtifact
}
// AnalyzeResumeArtifact is one deterministic artifact-level plan entry.
type AnalyzeResumeArtifact struct {
Key string
Role string
Reason string
Forced bool
}
// Normalized returns a result with a bounded reason and no reason on success.
func (r ResumeValidation) Normalized() ResumeValidation {
if r.Resumable {
return Resumable()
r.Reason = ""
return r
}
return NonResumable(r.Reason)
normalized := NonResumable(r.Reason)
normalized.Analyze = r.Analyze
return normalized
}
// ResumeValidator is implemented by stages that validate persisted success before reuse.