Add artifact-aware analyze resume planning
This commit is contained in:
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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="},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ", ")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
94
internal/stage/analyze_resume.go
Normal file
94
internal/stage/analyze_resume.go
Normal 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
|
||||||
|
}
|
||||||
160
internal/stage/analyze_resume_test.go
Normal file
160
internal/stage/analyze_resume_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user