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

@@ -190,8 +190,13 @@ narratio session plan <session_id> [--from <stage>] [--through <stage>] [--force
``` ```
Uses the same inclusive bounds, endpoint validation, force scope, and artifact Uses the same inclusive bounds, endpoint validation, force scope, and artifact
selection contract as `run`. It validates config, prepares local workdir layout, selection contract as `run`. It validates config and prints run/skip decisions
and prints run/skip decisions for selected stages only. for selected stages only without creating the local workdir or changing the
manifest. Resume-capable selected stages are checked against durable evidence.
For `analyze`, the preview also lists explicit targets, prerequisite-only work,
execution order, and reusable current artifacts with concise reasons. These
artifact decisions come from the same reconciliation and work planner used by
execution; the preview does not predict output identities.
### `session validate` ### `session validate`

View File

@@ -83,6 +83,13 @@ it stale. A partial analyze invocation can therefore succeed while unrelated
configured records remain stale. Existing canonical files never create current configured records remain stale. Existing canonical files never create current
records without validated execution and projection. records without validated execution and projection.
Aggregate analyze status is deliberately coarser than this collection. Resume
validation may skip a succeeded aggregate record when the selected artifact
closure is current even if unrelated records are stale. Conversely, a stale
aggregate record may cross the ordinary runner boundary and perform zero
Scriptorium calls when reconciliation proves every selected artifact current;
the successful projection then restores the aggregate status.
Analyze may return a projection together with an error. That restricted result Analyze may return a projection together with an error. That restricted result
cannot carry ordinary outputs, skip state, aggregate logs, generated configs, cannot carry ordinary outputs, skip state, aggregate logs, generated configs,
or metadata. The runner persists only the validated per-artifact collections, or metadata. The runner persists only the validated per-artifact collections,

View File

@@ -88,6 +88,14 @@ Supported source families:
projected record collection. Valid unrelated configured records survive the projected record collection. Valid unrelated configured records survive the
projection, removed records are omitted, and legacy files never become projection, removed records are omitted, and legacy files never become
current without regeneration. current without regeneration.
- implements aggregate resume validation by running the same read-only catalog,
fingerprint reconciliation, and work planner used by execution. A succeeded
aggregate record is reusable exactly when the selected closure schedules no
artifact work; stale unrelated records do not block a partial selection.
- exposes the typed artifact decision to `session plan`. Planning applies it to
a cloned manifest after modeling earlier selected stage transitions, so
aggregate run/skip and artifact execute/reuse decisions match the ordinary
runner without creating durable state or invoking Scriptorium.
- executes only the work plan's scheduled entries. Manifest-validated current - executes only the work plan's scheduled entries. Manifest-validated current
prerequisites remain available through the runtime catalog without invoking prerequisites remain available through the runtime catalog without invoking
Scriptorium; newly produced prerequisites enter that catalog with the same Scriptorium; newly produced prerequisites enter that catalog with the same
@@ -168,4 +176,5 @@ Supported source families:
`internal/stage/analyze_reconciliation_test.go`, `internal/stage/analyze_reconciliation_test.go`,
`internal/stage/analyze_plan.go`, `internal/stage/analyze_plan_test.go`, and `internal/stage/analyze_plan.go`, `internal/stage/analyze_plan_test.go`, and
`internal/stage/analyze_incremental_execution_test.go`, and `internal/stage/analyze_incremental_execution_test.go`, and
`internal/stage/analyze_failure_test.go` `internal/stage/analyze_failure_test.go`,
`internal/stage/analyze_resume.go`, and `internal/stage/analyze_resume_test.go`

View File

@@ -122,7 +122,13 @@ Omitting `--from` selects from `prepare`; omitting `--through` selects through
`notify`. Force applies only within the selected range. Repeating `--from`, `notify`. Force applies only within the selected range. Repeating `--from`,
`--through`, or `--force` is rejected instead of resolving by argument order. `--through`, or `--force` is rejected instead of resolving by argument order.
The plan command uses the same selection contract and prints only the selected The plan command uses the same selection contract and prints only the selected
range. range. Planning is read-only: it clones the loaded manifest, models selected
stage transitions and invalidation in memory, and invokes resume validation
without writing the manifest, creating run directories, materializing files,
or invoking pipeline adapters. Analyze detail separates explicit targets,
prerequisite rebuilds, scheduled execution, and current reuse. This lets a
coarsely stale aggregate analyze stage show zero artifact executions when its
selected artifact evidence is still semantically current.
Before a bounded run or plan whose range starts after `prepare`, every excluded Before a bounded run or plan whose range starts after `prepare`, every excluded
prefix stage must already have a session-manifest status of `succeeded` or prefix stage must already have a session-manifest status of `succeeded` or

View File

@@ -668,6 +668,8 @@ partial adapter, validation, materialization, and persistence failures.
## Stage 14 — Analyze Resume Validation And Plan/Run Parity ## Stage 14 — Analyze Resume Validation And Plan/Run Parity
**Status: Completed**
### Goal ### Goal
Make aggregate analyze skipping and `session plan` reflect artifact-level Make aggregate analyze skipping and `session plan` reflect artifact-level

View File

@@ -115,8 +115,8 @@ func TestRunStageArtifactsDoesNotImplyForce(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("RunStage() error = %v", err) t.Fatalf("RunStage() error = %v", err)
} }
if !strings.Contains(out.String(), "stage=analyze executed=0 skipped=1 force=false") { if !strings.Contains(out.String(), "stage=analyze executed=1 skipped=0 force=false") {
t.Fatalf("output = %q, want analyze skip without force", out.String()) 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 { if err != nil {
t.Fatalf("Run() error = %v", err) t.Fatalf("Run() error = %v", err)
} }
if !strings.Contains(out.String(), "executed=1 skipped=11") { if !strings.Contains(out.String(), "executed=4 skipped=8") {
t.Fatalf("output = %q, want all stages skipped", out.String()) 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 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: "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: "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="}, {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 ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"log/slog" "strings"
"os" "time"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config" "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 { func Plan(ctx context.Context, args []string, out io.Writer) error {
request, err := parseBoundedRunRequest("plan", args, out) request, err := parseBoundedRunRequest("plan", args, out)
if err != nil { 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 { if err := config.Validate(cfg); err != nil {
return fmt.Errorf("plan: %w", err) 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) return fmt.Errorf("plan: %w", err)
} }
m, err := loadManifestIfPresent(ctx, cfg) 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 { if err := validateBoundedPrerequisites(request.Plan, m); err != nil {
return fmt.Errorf("plan: %w", err) 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) 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 { if err != nil {
return fmt.Errorf("plan: prepare workdir: %w", err) return fmt.Errorf("plan: clone session state: %w", err)
} }
stages := request.Plan.Stages() 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 runCount := 0
skipCount := 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 return err
} }
for _, d := range decisions { for _, selectedStage := range stages {
if d.Action == stageActionRun { 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++ runCount++
} else { } else {
skipCount++ 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 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 { if _, err := fmt.Fprintf(out, "totals: run=%d skip=%d\n", runCount, skipCount); err != nil {
return err return err
@@ -77,3 +111,102 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
return nil 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 ( import (
"bytes" "bytes"
"context" "context"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts" "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/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
) )
func TestPlanCreatesAndReusesWorkdir(t *testing.T) { func TestPlanDoesNotCreateWorkdir(t *testing.T) {
workspaceRoot := t.TempDir() workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot) pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -24,8 +29,8 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
t.Fatalf("first Plan() error = %v", err) t.Fatalf("first Plan() error = %v", err)
} }
got := out.String() got := out.String()
if !strings.Contains(got, "narratio session plan: workdir prepared at") { if !strings.Contains(got, "narratio session plan: read-only workdir at") {
t.Fatalf("first output = %q, want workdir prepared", got) 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"} { for _, name := range []string{"prepare", "transcribe", "merge", "polish", "normalize", "trim", "render", "extract", "analyze", "publish", "notify"} {
if !strings.Contains(got, name+": run") { if !strings.Contains(got, name+": run") {
@@ -37,26 +42,16 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
} }
sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03") sessionWorkdir := artifacts.SessionWorkDirForCampaign(workspaceRoot, "sample-campaign", "2026-05-03")
expectedDirs := []string{ if _, err := os.Stat(sessionWorkdir); !errors.Is(err, os.ErrNotExist) {
sessionWorkdir, t.Fatalf("workdir stat error = %v, want absent", err)
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)
} }
out.Reset() out.Reset()
if err := Plan(context.Background(), args, &out); err != nil { if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("second Plan() error = %v", err) t.Fatalf("second Plan() error = %v", err)
} }
if !strings.Contains(out.String(), "narratio session plan: workdir prepared at") { if !strings.Contains(out.String(), "narratio session plan: read-only workdir at") {
t.Fatalf("second output = %q, want workdir prepared", out.String()) 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() workspaceRoot := t.TempDir()
configDir := t.TempDir() configDir := t.TempDir()
pipelinePath := filepath.Join(configDir, "pipeline.yml") pipelinePath := filepath.Join(configDir, "pipeline.yml")
@@ -130,21 +125,125 @@ inputs:
var out bytes.Buffer var out bytes.Buffer
err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out) err := Plan(context.Background(), []string{"2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, &out)
if err == nil { if err != nil {
t.Fatal("expected error, got nil") t.Fatalf("Plan() error = %v, want missing runtime secrets ignored", err)
}
if !strings.Contains(err.Error(), "validate secrets env_dir") {
t.Fatalf("error = %q, want secrets validation error context", err.Error())
} }
} }
func assertDir(t *testing.T, path string) { func TestPlanAndRunShareAnalyzeArtifactDecisionsWithoutPlanSideEffects(t *testing.T) {
t.Helper() workspaceRoot := t.TempDir()
info, err := os.Stat(path) pipelinePath, campaignPath, sessionPath := writeValidConfigFilesWithScriptoriumArtifacts(t, workspaceRoot)
cfg, err := config.LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, config.SessionLoadOptions{})
if err != nil { if err != nil {
t.Fatalf("Stat(%q) error = %v", path, err) t.Fatalf("load config: %v", err)
} }
if !info.IsDir() { analyze, err := stage.Select("analyze")
t.Fatalf("%q is not a directory", path) 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 { if storeInitCalls != 1 {
t.Fatalf("object store init calls = %d, want 1", storeInitCalls) 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()) t.Fatalf("stdout = %q, want plan output", stdout.String())
} }
if _, ok := fake.Objects[remoteKey]; !ok { if _, ok := fake.Objects[remoteKey]; !ok {

View File

@@ -257,7 +257,7 @@ func TestExecuteSessionSubcommandsAcceptPositionalSessionID(t *testing.T) {
{ {
name: "plan", name: "plan",
args: []string{"session", "plan", "2026-05-03", "--config", pipelinePath, "--campaign-file", campaignPath, "--session", sessionPath}, 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", 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 { type ResumeValidation struct {
Resumable bool Resumable bool
Reason string 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. // Normalized returns a result with a bounded reason and no reason on success.
func (r ResumeValidation) Normalized() ResumeValidation { func (r ResumeValidation) Normalized() ResumeValidation {
if r.Resumable { 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. // ResumeValidator is implemented by stages that validate persisted success before reuse.