Add stage planning and placeholder runner
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
21
internal/app/planner.go
Normal 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
|
||||
}
|
||||
36
internal/app/planner_test.go
Normal file
36
internal/app/planner_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
171
internal/app/runner.go
Normal 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
148
internal/app/runner_test.go
Normal 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
7
internal/app/time.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package app
|
||||
|
||||
import "time"
|
||||
|
||||
var nowUTC = func() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
30
internal/stage/placeholders_test.go
Normal file
30
internal/stage/placeholders_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user