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

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