Add stage planning and placeholder runner

This commit is contained in:
2026-05-02 11:05:28 -05:00
parent fb659e0d3d
commit 78e7e4f41b
14 changed files with 598 additions and 68 deletions

View File

@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
// Env is the shared dependency container passed to orchestrator components.
@@ -24,3 +25,20 @@ type Env struct {
Analyzer analyzer.Runner
Notifier notify.Sender
}
func toStageEnv(env *Env) *stage.Env {
if env == nil {
return nil
}
return &stage.Env{
Config: env.Config,
ArtifactStore: env.ArtifactStore,
Logger: env.Logger,
WhisperX: env.WhisperX,
Seriatim: env.Seriatim,
Audita: env.Audita,
Analyzer: env.Analyzer,
Notifier: env.Notifier,
}
}

View File

@@ -22,11 +22,11 @@ func TestExecuteValidCommands(t *testing.T) {
args []string
wantOut string
}{
{name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: configuration loaded and valid"},
{name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio plan: configuration loaded and valid; workdir prepared at"},
{name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: completed 8 placeholder stages for session 2026-05-03; manifest updated at"},
{name: "plan", args: []string{"plan", "--config", pipelinePath, "--session", sessionPath}, wantOut: "prepare\ntranscribe\nnormalize\nmerge\npolish\nanalyze\narchive\nnotify"},
{name: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"},
{name: "resume", args: []string{"resume"}, wantOut: "narratio resume: not yet implemented"},
{name: "run-stage", args: []string{"run-stage", "polish"}, wantOut: "narratio run-stage: not yet implemented"},
{name: "run-stage", args: []string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "polish"}, wantOut: "narratio run-stage: selected stage polish (1 stage in plan)"},
}
for _, tc := range cases {
@@ -57,6 +57,8 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
{name: "run missing flags", args: []string{"run"}, want: "run: --config and --session are required"},
{name: "plan missing flags", args: []string{"plan"}, want: "plan: --config and --session are required"},
{name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"},
{name: "run-stage missing name", args: []string{"run-stage", "--config", "a", "--session", "b"}, want: "run-stage: expected exactly one stage name"},
{name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: --config and --session are required"},
}
for _, tc := range cases {
@@ -78,6 +80,22 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
}
}
func TestExecuteRunStageUnknownFails(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"run-stage", "--config", pipelinePath, "--session", sessionPath, "unknown"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "unknown stage") {
t.Fatalf("stderr = %q, want unknown stage error", stderr.String())
}
}
func TestExecuteInvalidCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer

View File

@@ -10,15 +10,17 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
// Plan validates configuration inputs and prepares the local session workdir.
// Plan validates configuration, prepares the local workdir, and prints stage order.
func Plan(_ context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var pipelinePath string
var sessionPath string
var force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("plan: invalid flags: %w", err)
@@ -44,6 +46,17 @@ func Plan(_ context.Context, args []string, out io.Writer) error {
return fmt.Errorf("plan: prepare workdir: %w", err)
}
_, err = fmt.Fprintf(out, "narratio plan: configuration loaded and valid; workdir prepared at %s\n", paths.Root)
return err
_ = force // TODO: use --force in future skip/stale planning behavior.
stages := BuildFullPlan()
if _, err := fmt.Fprintf(out, "narratio plan: workdir prepared at %s\n", paths.Root); err != nil {
return err
}
for _, s := range stages {
if _, err := fmt.Fprintln(out, s.Name()); err != nil {
return err
}
}
return nil
}

View File

@@ -21,8 +21,14 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("first Plan() error = %v", err)
}
if !strings.Contains(out.String(), "workdir prepared") {
t.Fatalf("first output = %q, want workdir prepared", out.String())
got := out.String()
if !strings.Contains(got, "narratio plan: workdir prepared at") {
t.Fatalf("first output = %q, want workdir prepared", got)
}
for _, name := range []string{"prepare", "transcribe", "normalize", "merge", "polish", "analyze", "archive", "notify"} {
if !strings.Contains(got, name) {
t.Fatalf("first output = %q, missing stage %q", got, name)
}
}
sessionWorkdir := artifacts.SessionWorkDir(workspaceRoot, "2026-05-03")
@@ -44,7 +50,7 @@ func TestPlanCreatesAndReusesWorkdir(t *testing.T) {
if err := Plan(context.Background(), args, &out); err != nil {
t.Fatalf("second Plan() error = %v", err)
}
if !strings.Contains(out.String(), "workdir prepared") {
if !strings.Contains(out.String(), "narratio plan: workdir prepared at") {
t.Fatalf("second output = %q, want workdir prepared", out.String())
}
}

21
internal/app/planner.go Normal file
View File

@@ -0,0 +1,21 @@
package app
import (
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
// BuildFullPlan returns the canonical full stage list in deterministic order.
func BuildFullPlan() []stage.Stage {
return stage.All()
}
// BuildSingleStagePlan returns a one-stage plan for an exact stage name.
func BuildSingleStagePlan(name string) ([]stage.Stage, error) {
s, err := stage.Select(name)
if err != nil {
return nil, fmt.Errorf("build stage plan: %w", err)
}
return []stage.Stage{s}, nil
}

View File

@@ -0,0 +1,36 @@
package app
import "testing"
func TestBuildFullPlanOrder(t *testing.T) {
got := BuildFullPlan()
want := []string{"prepare", "transcribe", "normalize", "merge", "polish", "analyze", "archive", "notify"}
if len(got) != len(want) {
t.Fatalf("len(plan) = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i].Name() != want[i] {
t.Fatalf("plan[%d] = %q, want %q", i, got[i].Name(), want[i])
}
}
}
func TestBuildSingleStagePlan(t *testing.T) {
stages, err := BuildSingleStagePlan("polish")
if err != nil {
t.Fatalf("BuildSingleStagePlan() error = %v", err)
}
if len(stages) != 1 {
t.Fatalf("len(plan) = %d, want 1", len(stages))
}
if stages[0].Name() != "polish" {
t.Fatalf("stage name = %q, want %q", stages[0].Name(), "polish")
}
}
func TestBuildSingleStagePlanUnknown(t *testing.T) {
_, err := BuildSingleStagePlan("unknown")
if err == nil {
t.Fatal("expected error for unknown stage, got nil")
}
}

View File

@@ -9,15 +9,17 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
// Run validates configuration inputs and reports readiness for future execution.
func Run(_ context.Context, args []string, out io.Writer) error {
// Run executes the placeholder stage pipeline and persists manifest state.
func Run(ctx context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var pipelinePath string
var sessionPath string
var force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("run: invalid flags: %w", err)
@@ -37,6 +39,12 @@ func Run(_ context.Context, args []string, out io.Writer) error {
return fmt.Errorf("run: %w", err)
}
_, err = fmt.Fprintln(out, "narratio run: configuration loaded and valid")
stages := BuildFullPlan()
summary, err := executeStages(ctx, cfg, stages, RunOptions{Force: force})
if err != nil {
return fmt.Errorf("run: %w", err)
}
_, err = fmt.Fprintf(out, "narratio run: completed %d placeholder stages for session %s; manifest updated at %s\n", len(summary.StageNames), summary.SessionID, summary.ManifestPath)
return err
}

View File

@@ -2,10 +2,41 @@ package app
import (
"context"
"flag"
"fmt"
"io"
)
// RunStage is a placeholder for future single-stage execution behavior.
func RunStage(_ context.Context, _ []string, out io.Writer) error {
return placeholder(out, "run-stage")
// RunStage validates stage selection and reserved execution flags.
func RunStage(_ context.Context, args []string, out io.Writer) error {
fs := flag.NewFlagSet("run-stage", flag.ContinueOnError)
fs.SetOutput(io.Discard)
var pipelinePath string
var sessionPath string
var force bool
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("run-stage: invalid flags: %w", err)
}
if fs.NArg() != 1 {
return fmt.Errorf("run-stage: expected exactly one stage name")
}
if pipelinePath == "" || sessionPath == "" {
return fmt.Errorf("run-stage: --config and --session are required")
}
stageName := fs.Arg(0)
stages, err := BuildSingleStagePlan(stageName)
if err != nil {
return fmt.Errorf("run-stage: %w", err)
}
_ = force // TODO: use in future behavior.
_, err = fmt.Fprintf(out, "narratio run-stage: selected stage %s (%d stage in plan)\n", stages[0].Name(), len(stages))
return err
}

171
internal/app/runner.go Normal file
View File

@@ -0,0 +1,171 @@
package app
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"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"
)
type RunOptions struct {
Force bool
}
type RunSummary struct {
SessionID string
ManifestPath string
StageNames []string
}
func executeStages(ctx context.Context, cfg *config.Config, stages []stage.Stage, opts RunOptions) (*RunSummary, error) {
store := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root)
paths, err := store.EnsureLayout(cfg.Session.SessionID)
if err != nil {
return nil, fmt.Errorf("prepare workdir: %w", err)
}
lock, err := store.AcquireSessionLock(cfg.Session.SessionID)
if err != nil {
return nil, fmt.Errorf("acquire session lock: %w", err)
}
defer func() {
_ = store.ReleaseSessionLock(lock)
}()
manifestStore := &manifest.LocalStore{}
manifestPath := paths.ManifestPath
m, err := loadOrCreateManifest(ctx, manifestStore, manifestPath, cfg.Session.SessionID)
if err != nil {
return nil, err
}
env := &Env{
Config: cfg,
ArtifactStore: store,
Logger: logging.NewLogger(os.Stderr, slog.LevelInfo),
}
stageEnv := toStageEnv(env)
_ = opts // TODO: use --force behavior in future skip/stale logic.
runNames := make([]string, 0, len(stages))
for _, s := range stages {
runNames = append(runNames, s.Name())
now := nowUTC()
m.MarkStageRunning(s.Name(), now)
if err := manifestStore.Save(ctx, manifestPath, m); err != nil {
return nil, fmt.Errorf("save manifest before stage %q: %w", s.Name(), err)
}
result, err := s.Run(ctx, stageEnv, m)
if err != nil {
m.MarkStageFailed(s.Name(), nowUTC(), err.Error())
if saveErr := manifestStore.Save(ctx, manifestPath, m); saveErr != nil {
return nil, fmt.Errorf("stage %q failed (%v) and manifest save failed (%v)", s.Name(), err, saveErr)
}
return nil, fmt.Errorf("stage %q failed: %w", s.Name(), err)
}
outputs := mapResultOutputs(result)
m.MarkStageSucceeded(s.Name(), nowUTC(), outputs)
applyStageResultToManifest(m, s.Name(), result)
if err := manifestStore.Save(ctx, manifestPath, m); err != nil {
return nil, fmt.Errorf("save manifest after stage %q: %w", s.Name(), err)
}
}
return &RunSummary{
SessionID: cfg.Session.SessionID,
ManifestPath: manifestPath,
StageNames: runNames,
}, nil
}
func loadOrCreateManifest(ctx context.Context, store *manifest.LocalStore, path, sessionID string) (*manifest.Manifest, error) {
exists, err := fileExists(path)
if err != nil {
return nil, fmt.Errorf("check manifest existence %q: %w", path, err)
}
if exists {
m, err := store.Load(ctx, path)
if err != nil {
return nil, fmt.Errorf("load manifest %q: %w", path, err)
}
return m, nil
}
m, err := store.Create(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("create manifest: %w", err)
}
if err := store.Save(ctx, path, m); err != nil {
return nil, fmt.Errorf("save new manifest %q: %w", path, err)
}
return m, nil
}
func fileExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func mapResultOutputs(result *stage.StageResult) []manifest.ArtifactRecord {
if result == nil || len(result.Outputs) == 0 {
return nil
}
out := make([]manifest.ArtifactRecord, 0, len(result.Outputs))
for _, ref := range result.Outputs {
localPath := ref.AbsolutePath
if localPath == "" {
localPath = ref.RelativePath
}
out = append(out, manifest.ArtifactRecord{
Kind: ref.Kind,
LocalPath: localPath,
RemoteKey: ref.RemoteKey,
Checksum: ref.Checksum,
})
}
return out
}
func applyStageResultToManifest(m *manifest.Manifest, stageName string, result *stage.StageResult) {
if m == nil || result == nil {
return
}
sr := m.Stages[stageName]
if sr == nil {
return
}
if len(result.Logs) > 0 {
sr.Logs = append([]string(nil), result.Logs...)
}
if len(result.GeneratedConfigs) > 0 {
sr.GeneratedConfigs = append([]string(nil), result.GeneratedConfigs...)
}
if len(result.Metadata) > 0 {
sr.Metadata = result.Metadata
}
}
func manifestPathFor(cfg *config.Config) string {
return filepath.Join(cfg.Pipeline.Workspace.Root, "work", cfg.Session.SessionID, "manifest.json")
}

148
internal/app/runner_test.go Normal file
View File

@@ -0,0 +1,148 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
"gitea.maximumdirect.net/eric/narratio/internal/stage"
)
type failingStage struct {
name string
err error
}
func (s failingStage) Name() string { return s.name }
func (s failingStage) Declares() stage.IODecl { return stage.IODecl{} }
func (s failingStage) Run(_ context.Context, _ *stage.Env, _ *manifest.Manifest) (*stage.StageResult, error) {
return nil, s.err
}
func TestExecuteStagesPlaceholderSuccessUpdatesManifest(t *testing.T) {
cfg := testConfig(t)
summary, err := executeStages(context.Background(), cfg, BuildFullPlan(), RunOptions{})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
if len(summary.StageNames) != 8 {
t.Fatalf("stage count = %d, want 8", len(summary.StageNames))
}
store := &manifest.LocalStore{}
m, err := store.Load(context.Background(), summary.ManifestPath)
if err != nil {
t.Fatalf("Load manifest error = %v", err)
}
for _, name := range []string{"prepare", "transcribe", "normalize", "merge", "polish", "analyze", "archive", "notify"} {
sr := m.Stages[name]
if sr == nil {
t.Fatalf("missing stage record %q", name)
}
if sr.Status != manifest.StatusSucceeded {
t.Fatalf("stage %q status = %q, want %q", name, sr.Status, manifest.StatusSucceeded)
}
if sr.Metadata == nil || sr.Metadata["placeholder"] != true {
t.Fatalf("stage %q missing placeholder metadata", name)
}
}
if _, err := os.Stat(summary.ManifestPath); err != nil {
t.Fatalf("manifest file missing at %q: %v", summary.ManifestPath, err)
}
}
func TestExecuteStagesFailureUpdatesManifest(t *testing.T) {
cfg := testConfig(t)
stages := []stage.Stage{
BuildFullPlan()[0],
failingStage{name: "transcribe", err: errors.New("boom")},
BuildFullPlan()[2],
}
summary, err := executeStages(context.Background(), cfg, stages, RunOptions{})
if err == nil {
t.Fatal("expected error, got nil")
}
if summary != nil {
t.Fatalf("summary = %#v, want nil on failure", summary)
}
if !strings.Contains(err.Error(), "stage \"transcribe\" failed") {
t.Fatalf("error = %q, want stage failure", err.Error())
}
manifestPath := manifestPathFor(cfg)
store := &manifest.LocalStore{}
m, loadErr := store.Load(context.Background(), manifestPath)
if loadErr != nil {
t.Fatalf("Load manifest error = %v", loadErr)
}
if got := m.Stages["prepare"]; got == nil || got.Status != manifest.StatusSucceeded {
t.Fatalf("prepare status = %#v, want succeeded", got)
}
if got := m.Stages["transcribe"]; got == nil || got.Status != manifest.StatusFailed {
t.Fatalf("transcribe status = %#v, want failed", got)
}
if got := m.Stages["normalize"]; got != nil {
t.Fatalf("normalize should not run, got %#v", got)
}
}
func testConfig(t *testing.T) *config.Config {
t.Helper()
workspace := t.TempDir()
return &config.Config{
Pipeline: &config.PipelineConfig{Workspace: config.WorkspaceConfig{Root: workspace}},
Session: &config.SessionConfig{
SessionID: "2026-05-03",
Inputs: config.SessionInputsConfig{
AudioDir: "./audio",
SpeakersFile: "./speakers.yml",
AutocorrectFile: "./autocorrect.yml",
GlossaryFile: "./glossary.yml",
},
},
}
}
func TestExecuteStagesLoadsExistingManifest(t *testing.T) {
cfg := testConfig(t)
manifestPath := manifestPathFor(cfg)
store := &manifest.LocalStore{}
existing := manifest.New(cfg.Session.SessionID, time.Date(2026, 5, 3, 1, 0, 0, 0, time.UTC))
existing.MarkStageSucceeded("prepare", time.Date(2026, 5, 3, 1, 1, 0, 0, time.UTC), nil)
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := store.Save(context.Background(), manifestPath, existing); err != nil {
t.Fatalf("Save manifest error = %v", err)
}
_, err := executeStages(context.Background(), cfg, []stage.Stage{BuildFullPlan()[1]}, RunOptions{})
if err != nil {
t.Fatalf("executeStages() error = %v", err)
}
loaded, err := store.Load(context.Background(), manifestPath)
if err != nil {
t.Fatalf("Load manifest error = %v", err)
}
if loaded.Stages["prepare"] == nil || loaded.Stages["prepare"].Status != manifest.StatusSucceeded {
t.Fatalf("existing stage prepare should remain succeeded")
}
if loaded.Stages["transcribe"] == nil || loaded.Stages["transcribe"].Status != manifest.StatusSucceeded {
t.Fatalf("transcribe should be succeeded after run")
}
}

7
internal/app/time.go Normal file
View File

@@ -0,0 +1,7 @@
package app
import "time"
var nowUTC = func() time.Time {
return time.Now().UTC()
}

View File

@@ -4,60 +4,55 @@ import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/narratio/internal/app"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func notImplemented(name string) error {
return fmt.Errorf("stage %q: not yet implemented", name)
const placeholderMessage = "placeholder stage: no real work executed"
type placeholderStage struct {
name string
}
type Prepare struct{}
func (s placeholderStage) Name() string { return s.name }
type Transcribe struct{}
type Normalize struct{}
type Merge struct{}
type Polish struct{}
type Analyze struct{}
type Archive struct{}
type Notify struct{}
func (Prepare) Name() string { return "prepare" }
func (Transcribe) Name() string { return "transcribe" }
func (Normalize) Name() string { return "normalize" }
func (Merge) Name() string { return "merge" }
func (Polish) Name() string { return "polish" }
func (Analyze) Name() string { return "analyze" }
func (Archive) Name() string { return "archive" }
func (Notify) Name() string { return "notify" }
func (s Prepare) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) {
return nil, notImplemented(s.Name())
func (s placeholderStage) Declares() IODecl {
return IODecl{
Inputs: []artifacts.Ref{{Kind: "artifact", Category: "input", RelativePath: s.name + ".input.placeholder"}},
Outputs: []artifacts.Ref{{Kind: "artifact", Category: "output", RelativePath: s.name + ".output.placeholder"}},
}
}
func (s Transcribe) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) {
return nil, notImplemented(s.Name())
func (s placeholderStage) Run(_ context.Context, _ *Env, _ *manifest.Manifest) (*StageResult, error) {
return &StageResult{
Metadata: map[string]any{
"placeholder": true,
"stage": s.name,
"message": placeholderMessage,
},
}, nil
}
func (s Normalize) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) {
return nil, notImplemented(s.Name())
// All returns the canonical ordered stage list for full pipeline execution.
func All() []Stage {
return []Stage{
placeholderStage{name: "prepare"},
placeholderStage{name: "transcribe"},
placeholderStage{name: "normalize"},
placeholderStage{name: "merge"},
placeholderStage{name: "polish"},
placeholderStage{name: "analyze"},
placeholderStage{name: "archive"},
placeholderStage{name: "notify"},
}
}
func (s Merge) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) {
return nil, notImplemented(s.Name())
}
func (s Polish) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) {
return nil, notImplemented(s.Name())
}
func (s Analyze) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) {
return nil, notImplemented(s.Name())
}
func (s Archive) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) {
return nil, notImplemented(s.Name())
}
func (s Notify) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) {
return nil, notImplemented(s.Name())
// Select returns one stage by exact name from the canonical stage set.
func Select(name string) (Stage, error) {
for _, s := range All() {
if s.Name() == name {
return s, nil
}
}
return nil, fmt.Errorf("unknown stage %q", name)
}

View File

@@ -0,0 +1,30 @@
package stage
import (
"context"
"testing"
"time"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
func TestPlaceholderStagesReturnSuccessMetadata(t *testing.T) {
stages := All()
if len(stages) == 0 {
t.Fatal("expected non-empty stage list")
}
m := manifest.New("2026-05-03", time.Now().UTC())
for _, s := range stages {
result, err := s.Run(context.Background(), nil, m)
if err != nil {
t.Fatalf("stage %q returned unexpected error: %v", s.Name(), err)
}
if result == nil {
t.Fatalf("stage %q returned nil result", s.Name())
}
if result.Metadata["placeholder"] != true {
t.Fatalf("stage %q missing placeholder metadata", s.Name())
}
}
}

View File

@@ -2,20 +2,48 @@ package stage
import (
"context"
"log/slog"
"gitea.maximumdirect.net/eric/narratio/internal/app"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/analyzer"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/audita"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notify"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/seriatim"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/whisperx"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
)
// Env is the shared dependency container visible to stages.
type Env struct {
Config *config.Config
ArtifactStore artifacts.Store
Logger *slog.Logger
WhisperX whisperx.Client
Seriatim seriatim.Runner
Audita audita.Runner
Analyzer analyzer.Runner
Notifier notify.Sender
}
// IODecl declares the intended input/output artifact kinds for a stage.
type IODecl struct {
Inputs []artifacts.Ref
Outputs []artifacts.Ref
}
// Stage is the pipeline unit contract.
type Stage interface {
Name() string
Run(ctx context.Context, env *app.Env, m *manifest.Manifest) (*Result, error)
Declares() IODecl
Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error)
}
// Result is the declared output of a stage execution.
type Result struct {
Outputs []artifacts.Ref
Metadata map[string]any
// StageResult is the declared output of a stage execution.
type StageResult struct {
Outputs []artifacts.Ref
Logs []string
GeneratedConfigs []string
Metadata map[string]any
}