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/adapters/whisperx"
|
||||||
"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/stage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Env is the shared dependency container passed to orchestrator components.
|
// Env is the shared dependency container passed to orchestrator components.
|
||||||
@@ -24,3 +25,20 @@ type Env struct {
|
|||||||
Analyzer analyzer.Runner
|
Analyzer analyzer.Runner
|
||||||
Notifier notify.Sender
|
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
|
args []string
|
||||||
wantOut string
|
wantOut string
|
||||||
}{
|
}{
|
||||||
{name: "run", args: []string{"run", "--config", pipelinePath, "--session", sessionPath}, wantOut: "narratio run: configuration loaded and valid"},
|
{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: "narratio plan: configuration loaded and valid; workdir prepared 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: "status", args: []string{"status", "--manifest", manifestPath}, wantOut: "session_id: 2026-05-03"},
|
||||||
{name: "resume", args: []string{"resume"}, wantOut: "narratio resume: not yet implemented"},
|
{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 {
|
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: "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: "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: "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 {
|
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) {
|
func TestExecuteInvalidCommand(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
|
|||||||
@@ -10,15 +10,17 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"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 {
|
func Plan(_ context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
|
fs := flag.NewFlagSet("plan", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.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 {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("plan: invalid flags: %w", err)
|
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)
|
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)
|
_ = force // TODO: use --force in future skip/stale planning behavior.
|
||||||
return err
|
|
||||||
|
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 {
|
if err := Plan(context.Background(), args, &out); err != nil {
|
||||||
t.Fatalf("first Plan() error = %v", err)
|
t.Fatalf("first Plan() error = %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out.String(), "workdir prepared") {
|
got := out.String()
|
||||||
t.Fatalf("first output = %q, want workdir prepared", 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")
|
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 {
|
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(), "workdir prepared") {
|
if !strings.Contains(out.String(), "narratio plan: workdir prepared at") {
|
||||||
t.Fatalf("second output = %q, want workdir prepared", out.String())
|
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"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run validates configuration inputs and reports readiness for future execution.
|
// Run executes the placeholder stage pipeline and persists manifest state.
|
||||||
func Run(_ context.Context, args []string, out io.Writer) error {
|
func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
fs := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||||
fs.SetOutput(io.Discard)
|
fs.SetOutput(io.Discard)
|
||||||
|
|
||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.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 {
|
if err := fs.Parse(args); err != nil {
|
||||||
return fmt.Errorf("run: invalid flags: %w", err)
|
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)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,41 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunStage is a placeholder for future single-stage execution behavior.
|
// RunStage validates stage selection and reserved execution flags.
|
||||||
func RunStage(_ context.Context, _ []string, out io.Writer) error {
|
func RunStage(_ context.Context, args []string, out io.Writer) error {
|
||||||
return placeholder(out, "run-stage")
|
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/app"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func notImplemented(name string) error {
|
const placeholderMessage = "placeholder stage: no real work executed"
|
||||||
return fmt.Errorf("stage %q: not yet implemented", name)
|
|
||||||
|
type placeholderStage struct {
|
||||||
|
name string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Prepare struct{}
|
func (s placeholderStage) Name() string { return s.name }
|
||||||
|
|
||||||
type Transcribe struct{}
|
func (s placeholderStage) Declares() IODecl {
|
||||||
|
return IODecl{
|
||||||
type Normalize struct{}
|
Inputs: []artifacts.Ref{{Kind: "artifact", Category: "input", RelativePath: s.name + ".input.placeholder"}},
|
||||||
|
Outputs: []artifacts.Ref{{Kind: "artifact", Category: "output", RelativePath: s.name + ".output.placeholder"}},
|
||||||
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 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())
|
// Select returns one stage by exact name from the canonical stage set.
|
||||||
}
|
func Select(name string) (Stage, error) {
|
||||||
func (s Polish) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) {
|
for _, s := range All() {
|
||||||
return nil, notImplemented(s.Name())
|
if s.Name() == name {
|
||||||
}
|
return s, nil
|
||||||
func (s Analyze) Run(_ context.Context, _ *app.Env, _ *manifest.Manifest) (*Result, error) {
|
}
|
||||||
return nil, notImplemented(s.Name())
|
}
|
||||||
}
|
return nil, fmt.Errorf("unknown stage %q", 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())
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 (
|
import (
|
||||||
"context"
|
"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/artifacts"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"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.
|
// Stage is the pipeline unit contract.
|
||||||
type Stage interface {
|
type Stage interface {
|
||||||
Name() string
|
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.
|
// StageResult is the declared output of a stage execution.
|
||||||
type Result struct {
|
type StageResult struct {
|
||||||
Outputs []artifacts.Ref
|
Outputs []artifacts.Ref
|
||||||
Metadata map[string]any
|
Logs []string
|
||||||
|
GeneratedConfigs []string
|
||||||
|
Metadata map[string]any
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user