From 3128bef20a1949c6af184cee8c3a0556d3a5bb3b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 29 Aug 2026 20:01:57 +0000 Subject: [PATCH] Add artifact-aware analyze resume planning --- docs/cli.md | 9 +- docs/internal/manifest.md | 7 + docs/internal/stage-analyze.md | 11 +- docs/operations.md | 8 +- docs/roadmap/implementation.md | 2 + .../app/analyze_artifacts_commands_test.go | 8 +- internal/app/commands_test.go | 2 +- internal/app/plan.go | 165 ++++++++++++++++-- internal/app/plan_test.go | 157 ++++++++++++++--- internal/app/remote_session_test.go | 2 +- internal/app/session_oriented_cli_test.go | 2 +- internal/stage/analyze_resume.go | 94 ++++++++++ internal/stage/analyze_resume_test.go | 160 +++++++++++++++++ internal/stage/stage.go | 25 ++- 14 files changed, 594 insertions(+), 58 deletions(-) create mode 100644 internal/stage/analyze_resume.go create mode 100644 internal/stage/analyze_resume_test.go diff --git a/docs/cli.md b/docs/cli.md index 1e4031c..a0e280d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -190,8 +190,13 @@ narratio session plan [--from ] [--through ] [--force ``` Uses the same inclusive bounds, endpoint validation, force scope, and artifact -selection contract as `run`. It validates config, prepares local workdir layout, -and prints run/skip decisions for selected stages only. +selection contract as `run`. It validates config and prints run/skip decisions +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` diff --git a/docs/internal/manifest.md b/docs/internal/manifest.md index 26b066e..0f57e57 100644 --- a/docs/internal/manifest.md +++ b/docs/internal/manifest.md @@ -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 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 cannot carry ordinary outputs, skip state, aggregate logs, generated configs, or metadata. The runner persists only the validated per-artifact collections, diff --git a/docs/internal/stage-analyze.md b/docs/internal/stage-analyze.md index 347b8b4..3d2c5a1 100644 --- a/docs/internal/stage-analyze.md +++ b/docs/internal/stage-analyze.md @@ -88,6 +88,14 @@ Supported source families: projected record collection. Valid unrelated configured records survive the projection, removed records are omitted, and legacy files never become 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 prerequisites remain available through the runtime catalog without invoking 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_plan.go`, `internal/stage/analyze_plan_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` diff --git a/docs/operations.md b/docs/operations.md index a37743c..98ce991 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -122,7 +122,13 @@ Omitting `--from` selects from `prepare`; omitting `--through` selects through `notify`. Force applies only within the selected range. Repeating `--from`, `--through`, or `--force` is rejected instead of resolving by argument order. 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 prefix stage must already have a session-manifest status of `succeeded` or diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index d3c8cf9..46751a1 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -668,6 +668,8 @@ partial adapter, validation, materialization, and persistence failures. ## Stage 14 — Analyze Resume Validation And Plan/Run Parity +**Status: Completed** + ### Goal Make aggregate analyze skipping and `session plan` reflect artifact-level diff --git a/internal/app/analyze_artifacts_commands_test.go b/internal/app/analyze_artifacts_commands_test.go index c65727f..e04d387 100644 --- a/internal/app/analyze_artifacts_commands_test.go +++ b/internal/app/analyze_artifacts_commands_test.go @@ -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()) } } diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index a4c7b33..50f3b83 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -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="}, } diff --git a/internal/app/plan.go b/internal/app/plan.go index 87d9426..1691471 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -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, ", ") +} diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index af57904..c1ce5ce 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -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 +} diff --git a/internal/app/remote_session_test.go b/internal/app/remote_session_test.go index 8431160..b2faf53 100644 --- a/internal/app/remote_session_test.go +++ b/internal/app/remote_session_test.go @@ -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 { diff --git a/internal/app/session_oriented_cli_test.go b/internal/app/session_oriented_cli_test.go index c5c8184..af1aa06 100644 --- a/internal/app/session_oriented_cli_test.go +++ b/internal/app/session_oriented_cli_test.go @@ -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", diff --git a/internal/stage/analyze_resume.go b/internal/stage/analyze_resume.go new file mode 100644 index 0000000..e34feab --- /dev/null +++ b/internal/stage/analyze_resume.go @@ -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 +} diff --git a/internal/stage/analyze_resume_test.go b/internal/stage/analyze_resume_test.go new file mode 100644 index 0000000..eaa5f95 --- /dev/null +++ b/internal/stage/analyze_resume_test.go @@ -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) +} diff --git a/internal/stage/stage.go b/internal/stage/stage.go index 0957580..56aefa0 100644 --- a/internal/stage/stage.go +++ b/internal/stage/stage.go @@ -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.