Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc8e1040f2 |
11
README.md
11
README.md
@@ -28,6 +28,13 @@ Narratio expects two YAML files:
|
|||||||
- `pipeline.yml`: pipeline/workspace settings
|
- `pipeline.yml`: pipeline/workspace settings
|
||||||
- `session.yml`: per-session settings
|
- `session.yml`: per-session settings
|
||||||
|
|
||||||
|
Pipeline config lookup for CLI commands:
|
||||||
|
|
||||||
|
- if `--config <path>` is provided, Narratio uses that path
|
||||||
|
- if `--config` is omitted, Narratio searches in this order:
|
||||||
|
- `/usr/local/etc/narratio/pipeline.yml`
|
||||||
|
- `/etc/narratio/pipeline.yml`
|
||||||
|
|
||||||
YAML decoding is strict (`KnownFields(true)`), so unknown fields fail fast.
|
YAML decoding is strict (`KnownFields(true)`), so unknown fields fail fast.
|
||||||
|
|
||||||
## Canonical Stage Order
|
## Canonical Stage Order
|
||||||
@@ -242,9 +249,11 @@ go test ./...
|
|||||||
Plan a run:
|
Plan a run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run ./cmd/narratio plan --config examples/pipeline.minimal.yml --session examples/session.minimal.yml
|
go run ./cmd/narratio plan --session examples/session.minimal.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `--config <path>` to override default pipeline lookup when needed.
|
||||||
|
|
||||||
Run full pipeline:
|
Run full pipeline:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ Adapter behavior:
|
|||||||
|
|
||||||
## 5. Configuration Contract
|
## 5. Configuration Contract
|
||||||
|
|
||||||
|
CLI pipeline config path resolution:
|
||||||
|
|
||||||
|
- when `--config <path>` is provided, that path is used
|
||||||
|
- when `--config` is omitted, Narratio searches defaults in order:
|
||||||
|
- `/usr/local/etc/narratio/pipeline.yml`
|
||||||
|
- `/etc/narratio/pipeline.yml`
|
||||||
|
|
||||||
`pipeline.scriptorium` is optional. Existing pipelines without Scriptorium continue to work.
|
`pipeline.scriptorium` is optional. Existing pipelines without Scriptorium continue to work.
|
||||||
|
|
||||||
`pipeline.trim` is optional. Existing pipelines without trim config continue to work.
|
`pipeline.trim` is optional. Existing pipelines without trim config continue to work.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,12 +64,13 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
|||||||
args []string
|
args []string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "run missing flags", args: []string{"run"}, want: "run: --config and --session are required"},
|
{name: "run missing flags", args: []string{"run"}, want: "run: --session is required"},
|
||||||
{name: "plan missing flags", args: []string{"plan"}, want: "plan: --config and --session are required"},
|
{name: "plan missing flags", args: []string{"plan"}, want: "plan: --session is 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: "resume missing flags", args: []string{"resume"}, want: "resume: --config and --session are required"},
|
{name: "resume missing flags", args: []string{"resume"}, want: "resume: --session 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 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"},
|
{name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: --session is required"},
|
||||||
|
{name: "run missing config uses defaults", args: []string{"run", "--session", "session.yml"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -168,6 +170,32 @@ func TestExecuteRunStageTranscribeUsesConfiguredWhisperXServer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteUsesDefaultPipelineConfigPathWhenConfigFlagOmitted(t *testing.T) {
|
||||||
|
workspaceRoot := t.TempDir()
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"source":"default-config-test","segments":[{"speaker":"alice"}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot, srv.URL)
|
||||||
|
originalDefaults := append([]string(nil), config.DefaultPipelineConfigSearchPaths...)
|
||||||
|
config.DefaultPipelineConfigSearchPaths = []string{pipelinePath}
|
||||||
|
defer func() {
|
||||||
|
config.DefaultPipelineConfigSearchPaths = originalDefaults
|
||||||
|
}()
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
code := Execute([]string{"run", "--session", sessionPath}, &stdout, &stderr)
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "narratio run: session 2026-05-03; executed=9 skipped=0; manifest=") {
|
||||||
|
t.Fatalf("stdout = %q, want successful run output", stdout.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
|
||||||
|
|||||||
49
internal/app/pipeline_config_path.go
Normal file
49
internal/app/pipeline_config_path.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func resolvePipelineConfigPath(flagValue string) (string, error) {
|
||||||
|
return resolvePipelineConfigPathWithCandidates(flagValue, config.DefaultPipelineConfigSearchPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePipelineConfigPathWithCandidates(flagValue string, candidates []string) (string, error) {
|
||||||
|
if explicit := strings.TrimSpace(flagValue); explicit != "" {
|
||||||
|
return explicit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ordered := make([]string, 0, len(candidates))
|
||||||
|
for _, raw := range candidates {
|
||||||
|
path := strings.TrimSpace(raw)
|
||||||
|
if path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ordered = append(ordered, path)
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err == nil {
|
||||||
|
if info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return filepath.Clean(path), nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("check default pipeline config %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ordered) == 0 {
|
||||||
|
return "", fmt.Errorf("no pipeline config path provided and no default locations configured")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"no pipeline config path provided and no default pipeline config found; searched: %s",
|
||||||
|
strings.Join(ordered, ", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
65
internal/app/pipeline_config_path_test.go
Normal file
65
internal/app/pipeline_config_path_test.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolvePipelineConfigPathWithCandidatesExplicitWins(t *testing.T) {
|
||||||
|
got, err := resolvePipelineConfigPathWithCandidates(" ./custom/pipeline.yml ", []string{"/a", "/b"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolvePipelineConfigPathWithCandidates() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != "./custom/pipeline.yml" {
|
||||||
|
t.Fatalf("resolved path = %q, want explicit path", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePipelineConfigPathWithCandidatesUsesFirstExisting(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
first := filepath.Join(dir, "first.yml")
|
||||||
|
second := filepath.Join(dir, "second.yml")
|
||||||
|
if err := os.WriteFile(second, []byte("workspace:\n root: ./tmp\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write second default: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := resolvePipelineConfigPathWithCandidates("", []string{first, second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolvePipelineConfigPathWithCandidates() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != filepath.Clean(second) {
|
||||||
|
t.Fatalf("resolved path = %q, want %q", got, filepath.Clean(second))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePipelineConfigPathWithCandidatesPrecedence(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
first := filepath.Join(dir, "first.yml")
|
||||||
|
second := filepath.Join(dir, "second.yml")
|
||||||
|
if err := os.WriteFile(first, []byte("workspace:\n root: ./tmp\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write first default: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(second, []byte("workspace:\n root: ./tmp\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write second default: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := resolvePipelineConfigPathWithCandidates("", []string{first, second})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolvePipelineConfigPathWithCandidates() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != filepath.Clean(first) {
|
||||||
|
t.Fatalf("resolved path = %q, want first candidate %q", got, filepath.Clean(first))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePipelineConfigPathWithCandidatesMissing(t *testing.T) {
|
||||||
|
_, err := resolvePipelineConfigPathWithCandidates("", []string{"/does/not/exist/one.yml", "/does/not/exist/two.yml"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "no default pipeline config found") {
|
||||||
|
t.Fatalf("error = %q, want missing-defaults context", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
var force bool
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
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)")
|
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||||
|
|
||||||
@@ -29,11 +29,16 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if fs.NArg() != 0 {
|
if fs.NArg() != 0 {
|
||||||
return fmt.Errorf("plan: unexpected positional arguments")
|
return fmt.Errorf("plan: unexpected positional arguments")
|
||||||
}
|
}
|
||||||
if pipelinePath == "" || sessionPath == "" {
|
if sessionPath == "" {
|
||||||
return fmt.Errorf("plan: --config and --session are required")
|
return fmt.Errorf("plan: --session is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(pipelinePath, sessionPath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("plan: %w", err)
|
return fmt.Errorf("plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
var force bool
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||||
fs.BoolVar(&force, "force", false, "force stage execution")
|
fs.BoolVar(&force, "force", false, "force stage execution")
|
||||||
|
|
||||||
@@ -28,11 +28,16 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if fs.NArg() != 0 {
|
if fs.NArg() != 0 {
|
||||||
return fmt.Errorf("resume: unexpected positional arguments")
|
return fmt.Errorf("resume: unexpected positional arguments")
|
||||||
}
|
}
|
||||||
if pipelinePath == "" || sessionPath == "" {
|
if sessionPath == "" {
|
||||||
return fmt.Errorf("resume: --config and --session are required")
|
return fmt.Errorf("resume: --session is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(pipelinePath, sessionPath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resume: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resume: %w", err)
|
return fmt.Errorf("resume: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
var force bool
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
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)")
|
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||||
|
|
||||||
@@ -27,11 +27,16 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if fs.NArg() != 0 {
|
if fs.NArg() != 0 {
|
||||||
return fmt.Errorf("run: unexpected positional arguments")
|
return fmt.Errorf("run: unexpected positional arguments")
|
||||||
}
|
}
|
||||||
if pipelinePath == "" || sessionPath == "" {
|
if sessionPath == "" {
|
||||||
return fmt.Errorf("run: --config and --session are required")
|
return fmt.Errorf("run: --session is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(pipelinePath, sessionPath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run: %w", err)
|
return fmt.Errorf("run: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
var pipelinePath string
|
var pipelinePath string
|
||||||
var sessionPath string
|
var sessionPath string
|
||||||
var force bool
|
var force bool
|
||||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml")
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||||
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)")
|
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||||
|
|
||||||
@@ -27,8 +27,8 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
if fs.NArg() != 1 {
|
if fs.NArg() != 1 {
|
||||||
return fmt.Errorf("run-stage: expected exactly one stage name")
|
return fmt.Errorf("run-stage: expected exactly one stage name")
|
||||||
}
|
}
|
||||||
if pipelinePath == "" || sessionPath == "" {
|
if sessionPath == "" {
|
||||||
return fmt.Errorf("run-stage: --config and --session are required")
|
return fmt.Errorf("run-stage: --session is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
stageName := fs.Arg(0)
|
stageName := fs.Arg(0)
|
||||||
@@ -37,7 +37,12 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
|||||||
return fmt.Errorf("run-stage: %w", err)
|
return fmt.Errorf("run-stage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load(pipelinePath, sessionPath)
|
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("run-stage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("run-stage: %w", err)
|
return fmt.Errorf("run-stage: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
18
internal/config/defaults.go
Normal file
18
internal/config/defaults.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// Default filesystem locations for pipeline configuration lookup when --config
|
||||||
|
// is omitted. Order is highest to lowest precedence.
|
||||||
|
const (
|
||||||
|
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
|
||||||
|
DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultPipelineConfigSearchPaths defines the default search order for
|
||||||
|
// pipeline.yml when callers do not provide an explicit path.
|
||||||
|
//
|
||||||
|
// Keep this in a variable so future defaults can be extended without changing
|
||||||
|
// call sites.
|
||||||
|
var DefaultPipelineConfigSearchPaths = []string{
|
||||||
|
DefaultPipelineConfigPathUsrLocal,
|
||||||
|
DefaultPipelineConfigPathEtc,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user