diff --git a/internal/app/app.go b/internal/app/app.go index 239b9d8..281a871 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, + } +} diff --git a/internal/app/commands_test.go b/internal/app/commands_test.go index 4d0438f..b96cded 100644 --- a/internal/app/commands_test.go +++ b/internal/app/commands_test.go @@ -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 diff --git a/internal/app/plan.go b/internal/app/plan.go index de2456a..471f2bc 100644 --- a/internal/app/plan.go +++ b/internal/app/plan.go @@ -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 } diff --git a/internal/app/plan_test.go b/internal/app/plan_test.go index 34d5c39..a960fc3 100644 --- a/internal/app/plan_test.go +++ b/internal/app/plan_test.go @@ -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()) } } diff --git a/internal/app/planner.go b/internal/app/planner.go new file mode 100644 index 0000000..fb4bf89 --- /dev/null +++ b/internal/app/planner.go @@ -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 +} diff --git a/internal/app/planner_test.go b/internal/app/planner_test.go new file mode 100644 index 0000000..8423fdf --- /dev/null +++ b/internal/app/planner_test.go @@ -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") + } +} diff --git a/internal/app/run.go b/internal/app/run.go index c2d0535..50732f1 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -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 } diff --git a/internal/app/run_stage.go b/internal/app/run_stage.go index 9235ecd..6f90b04 100644 --- a/internal/app/run_stage.go +++ b/internal/app/run_stage.go @@ -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 } diff --git a/internal/app/runner.go b/internal/app/runner.go new file mode 100644 index 0000000..3615030 --- /dev/null +++ b/internal/app/runner.go @@ -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") +} diff --git a/internal/app/runner_test.go b/internal/app/runner_test.go new file mode 100644 index 0000000..c923ffa --- /dev/null +++ b/internal/app/runner_test.go @@ -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") + } +} diff --git a/internal/app/time.go b/internal/app/time.go new file mode 100644 index 0000000..ac4aa5c --- /dev/null +++ b/internal/app/time.go @@ -0,0 +1,7 @@ +package app + +import "time" + +var nowUTC = func() time.Time { + return time.Now().UTC() +} diff --git a/internal/stage/placeholders.go b/internal/stage/placeholders.go index 3c64a44..1aa9e74 100644 --- a/internal/stage/placeholders.go +++ b/internal/stage/placeholders.go @@ -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) } diff --git a/internal/stage/placeholders_test.go b/internal/stage/placeholders_test.go new file mode 100644 index 0000000..317cfea --- /dev/null +++ b/internal/stage/placeholders_test.go @@ -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()) + } + } +} diff --git a/internal/stage/stage.go b/internal/stage/stage.go index 28baab4..10bf585 100644 --- a/internal/stage/stage.go +++ b/internal/stage/stage.go @@ -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 }