Add artifact-aware analyze resume planning
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user