Add session discovery and template support
This commit is contained in:
32
README.md
32
README.md
@@ -34,6 +34,25 @@ Pipeline config lookup for CLI commands:
|
||||
- `/usr/local/etc/narratio/pipeline.yml`
|
||||
- `/etc/narratio/pipeline.yml`
|
||||
|
||||
Session config lookup for CLI commands:
|
||||
|
||||
- if `--session <path>` is provided, Narratio uses that path
|
||||
- if `--session` is omitted, Narratio searches in this order:
|
||||
- `./session.yml`
|
||||
- `/usr/local/etc/narratio/session.yml`
|
||||
- `/etc/narratio/session.yml`
|
||||
|
||||
Session template support:
|
||||
|
||||
- Narratio renders `session.yml` templates before strict YAML decode.
|
||||
- `--session-id <value>` provides the `session_id` template variable.
|
||||
- Supported placeholder forms:
|
||||
- `{{session_id}}`
|
||||
- `{{ session_id }}`
|
||||
- unresolved template placeholders fail with a clear error.
|
||||
- strict YAML validation still runs after rendering.
|
||||
- concrete `session.yml` files without templates remain fully supported.
|
||||
|
||||
Optional secrets-from-files config:
|
||||
|
||||
- `pipeline.secrets.env_dir` may point to a directory of secret files
|
||||
@@ -345,6 +364,7 @@ Starter files:
|
||||
|
||||
- `examples/pipeline.minimal.yml`
|
||||
- `examples/session.minimal.yml`
|
||||
- `examples/session.template.yml`
|
||||
- `examples/speakers.yml`
|
||||
|
||||
## Commands
|
||||
@@ -363,6 +383,12 @@ go run ./cmd/narratio plan --session examples/session.minimal.yml
|
||||
|
||||
Use `--config <path>` to override default pipeline lookup when needed.
|
||||
|
||||
Run with a discoverable session template:
|
||||
|
||||
```bash
|
||||
go run ./cmd/narratio run --session-id 2026-04-04
|
||||
```
|
||||
|
||||
Run full pipeline:
|
||||
|
||||
```bash
|
||||
@@ -375,6 +401,12 @@ Run analyze only:
|
||||
go run ./cmd/narratio run-stage --config examples/pipeline.minimal.yml --session examples/session.minimal.yml analyze
|
||||
```
|
||||
|
||||
Resume with a template session ID:
|
||||
|
||||
```bash
|
||||
go run ./cmd/narratio resume --config examples/pipeline.minimal.yml --session examples/session.template.yml --session-id 2026-04-04
|
||||
```
|
||||
|
||||
## Operational Note
|
||||
|
||||
Checksum-based stale detection is not implemented yet.
|
||||
|
||||
@@ -115,6 +115,25 @@ CLI pipeline config path resolution:
|
||||
- `/usr/local/etc/narratio/pipeline.yml`
|
||||
- `/etc/narratio/pipeline.yml`
|
||||
|
||||
CLI session config path resolution:
|
||||
|
||||
- when `--session <path>` is provided, that path is used
|
||||
- when `--session` is omitted, Narratio searches defaults in order:
|
||||
- `./session.yml`
|
||||
- `/usr/local/etc/narratio/session.yml`
|
||||
- `/etc/narratio/session.yml`
|
||||
|
||||
Session template rendering:
|
||||
|
||||
- session templates are rendered before strict YAML decode
|
||||
- `--session-id <value>` provides the `session_id` template variable
|
||||
- supported placeholders:
|
||||
- `{{session_id}}`
|
||||
- `{{ session_id }}`
|
||||
- unresolved placeholders fail clearly
|
||||
- strict `KnownFields(true)` YAML validation still applies after rendering
|
||||
- if rendered `session.session_id` conflicts with `--session-id`, load fails clearly
|
||||
|
||||
Optional pipeline secrets directory:
|
||||
|
||||
- `pipeline.secrets.env_dir` enables loading environment variables from local files before command execution
|
||||
|
||||
@@ -95,6 +95,11 @@ Implemented in repository:
|
||||
- campaign/session/run local work and spool path helpers
|
||||
- manifest run/path identity fields
|
||||
- examples and tests for the above foundations
|
||||
- session template operator UX:
|
||||
- default session config discovery (`./session.yml`, `/usr/local/etc/narratio/session.yml`, `/etc/narratio/session.yml`)
|
||||
- `--session-id` template injection for `session_id`
|
||||
- session template rendering before strict YAML decode
|
||||
- unresolved template placeholders and `session_id` mismatches fail clearly
|
||||
- remote storage backend layer:
|
||||
- object-store abstraction with `List`, `Download`, `Upload`, and `Exists`
|
||||
- fake storage backend for deterministic, no-network testing
|
||||
|
||||
12
examples/session.template.yml
Normal file
12
examples/session.template.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
session_id: "{{ session_id }}"
|
||||
campaign: sample-campaign
|
||||
date: ""
|
||||
title: ""
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
# Optional S3 input alternative. Do not configure with audio_dir/audio_files.
|
||||
# audio_s3:
|
||||
# prefix: "audio/{{ session_id }}/"
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
@@ -64,12 +64,12 @@ func TestExecuteMissingRequiredFlags(t *testing.T) {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "run missing flags", args: []string{"run"}, want: "run: --session is required"},
|
||||
{name: "plan missing flags", args: []string{"plan"}, want: "plan: --session is required"},
|
||||
{name: "run missing flags", args: []string{"run"}, want: "run: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||
{name: "plan missing flags", args: []string{"plan"}, want: "plan: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||
{name: "status missing flags", args: []string{"status"}, want: "status: --manifest is required"},
|
||||
{name: "resume missing flags", args: []string{"resume"}, want: "resume: --session is required"},
|
||||
{name: "resume missing flags", args: []string{"resume"}, want: "resume: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||
{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: --session is required"},
|
||||
{name: "run-stage missing config flags", args: []string{"run-stage", "polish"}, want: "run-stage: no pipeline config path provided and no default pipeline config found; searched:"},
|
||||
{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:"},
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,11 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var force bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -32,16 +34,18 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("plan: unexpected positional arguments")
|
||||
}
|
||||
if sessionPath == "" {
|
||||
return fmt.Errorf("plan: --session is required")
|
||||
}
|
||||
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
||||
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var force bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -28,16 +30,18 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("resume: unexpected positional arguments")
|
||||
}
|
||||
if sessionPath == "" {
|
||||
return fmt.Errorf("resume: --session is required")
|
||||
}
|
||||
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resume: %w", err)
|
||||
}
|
||||
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resume: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
||||
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("resume: %w", err)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var force bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -27,16 +29,18 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
|
||||
if fs.NArg() != 0 {
|
||||
return fmt.Errorf("run: unexpected positional arguments")
|
||||
}
|
||||
if sessionPath == "" {
|
||||
return fmt.Errorf("run: --session is required")
|
||||
}
|
||||
|
||||
resolvedPipelinePath, err := resolvePipelineConfigPath(pipelinePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run: %w", err)
|
||||
}
|
||||
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
||||
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("run: %w", err)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
||||
|
||||
var pipelinePath string
|
||||
var sessionPath string
|
||||
var sessionID string
|
||||
var force bool
|
||||
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
||||
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
|
||||
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
|
||||
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -27,10 +29,6 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
||||
if fs.NArg() != 1 {
|
||||
return fmt.Errorf("run-stage: expected exactly one stage name")
|
||||
}
|
||||
if sessionPath == "" {
|
||||
return fmt.Errorf("run-stage: --session is required")
|
||||
}
|
||||
|
||||
stageName := fs.Arg(0)
|
||||
stages, err := BuildSingleStagePlan(stageName)
|
||||
if err != nil {
|
||||
@@ -41,8 +39,14 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("run-stage: %w", err)
|
||||
}
|
||||
resolvedSessionPath, err := resolveSessionConfigPath(sessionPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run-stage: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(resolvedPipelinePath, sessionPath)
|
||||
cfg, err := config.LoadWithSessionOptions(resolvedPipelinePath, resolvedSessionPath, config.SessionLoadOptions{
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("run-stage: %w", err)
|
||||
}
|
||||
|
||||
86
internal/app/session_cli_test.go
Normal file
86
internal/app/session_cli_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlanUsesDiscoveredSessionTemplateWithSessionID(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
sessionTemplate := `session_id: "{{ session_id }}"
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionTemplate), 0o644); err != nil {
|
||||
t.Fatalf("write session template: %v", err)
|
||||
}
|
||||
|
||||
cwd := filepath.Dir(sessionPath)
|
||||
originalWD, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("Getwd(): %v", err)
|
||||
}
|
||||
if err := os.Chdir(cwd); err != nil {
|
||||
t.Fatalf("Chdir(%q): %v", cwd, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(originalWD) })
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := Plan(context.Background(), []string{"--config", pipelinePath, "--session-id", "2026-04-04"}, &out); err != nil {
|
||||
t.Fatalf("Plan() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "narratio plan: workdir prepared") {
|
||||
t.Fatalf("output = %q, want plan output", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFailsWhenSessionIDMismatchesConcreteSession(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
var out bytes.Buffer
|
||||
err := Plan(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--session-id", "2026-04-04"}, &out)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "session_id mismatch") {
|
||||
t.Fatalf("error = %q, want mismatch context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStageAcceptsSessionIDFlagAndParsesStageName(t *testing.T) {
|
||||
workspaceRoot := t.TempDir()
|
||||
pipelinePath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
|
||||
|
||||
var out bytes.Buffer
|
||||
err := RunStage(context.Background(), []string{"--config", pipelinePath, "--session", sessionPath, "--session-id", "2026-05-03", "prepare"}, &out)
|
||||
if err != nil {
|
||||
t.Fatalf("RunStage() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "stage=prepare") {
|
||||
t.Fatalf("output = %q, want stage output", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionConfigPathErrorIncludesSearchedPaths(t *testing.T) {
|
||||
_, err := resolveSessionConfigPathWithCandidates("", []string{"./session.yml", "/usr/local/etc/narratio/session.yml", "/etc/narratio/session.yml"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "searched") {
|
||||
t.Fatalf("error = %q, want searched paths", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pass --session") {
|
||||
t.Fatalf("error = %q, want explicit-session guidance", err.Error())
|
||||
}
|
||||
}
|
||||
49
internal/app/session_config_path.go
Normal file
49
internal/app/session_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 resolveSessionConfigPath(flagValue string) (string, error) {
|
||||
return resolveSessionConfigPathWithCandidates(flagValue, config.DefaultSessionConfigSearchPaths)
|
||||
}
|
||||
|
||||
func resolveSessionConfigPathWithCandidates(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 session config %q: %w", path, err)
|
||||
}
|
||||
|
||||
if len(ordered) == 0 {
|
||||
return "", fmt.Errorf("no session config path provided and no default locations configured")
|
||||
}
|
||||
return "", fmt.Errorf(
|
||||
"no session config path provided and no default session config found; searched: %s; pass --session to use an explicit path",
|
||||
strings.Join(ordered, ", "),
|
||||
)
|
||||
}
|
||||
68
internal/app/session_config_path_test.go
Normal file
68
internal/app/session_config_path_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveSessionConfigPathWithCandidatesExplicitWins(t *testing.T) {
|
||||
got, err := resolveSessionConfigPathWithCandidates(" ./custom/session.yml ", []string{"./session.yml", "/a", "/b"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSessionConfigPathWithCandidates() error = %v", err)
|
||||
}
|
||||
if got != "./custom/session.yml" {
|
||||
t.Fatalf("resolved path = %q, want explicit path", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionConfigPathWithCandidatesUsesFirstExisting(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := filepath.Join(dir, "first.yml")
|
||||
second := filepath.Join(dir, "second.yml")
|
||||
if err := os.WriteFile(second, []byte("session_id: 2026-05-03\n"), 0o644); err != nil {
|
||||
t.Fatalf("write second default: %v", err)
|
||||
}
|
||||
|
||||
got, err := resolveSessionConfigPathWithCandidates("", []string{first, second})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSessionConfigPathWithCandidates() error = %v", err)
|
||||
}
|
||||
if got != filepath.Clean(second) {
|
||||
t.Fatalf("resolved path = %q, want %q", got, filepath.Clean(second))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionConfigPathWithCandidatesPrecedence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := filepath.Join(dir, "first.yml")
|
||||
second := filepath.Join(dir, "second.yml")
|
||||
if err := os.WriteFile(first, []byte("session_id: 2026-05-03\n"), 0o644); err != nil {
|
||||
t.Fatalf("write first default: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(second, []byte("session_id: 2026-05-03\n"), 0o644); err != nil {
|
||||
t.Fatalf("write second default: %v", err)
|
||||
}
|
||||
|
||||
got, err := resolveSessionConfigPathWithCandidates("", []string{first, second})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSessionConfigPathWithCandidates() error = %v", err)
|
||||
}
|
||||
if got != filepath.Clean(first) {
|
||||
t.Fatalf("resolved path = %q, want first candidate %q", got, filepath.Clean(first))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSessionConfigPathWithCandidatesMissing(t *testing.T) {
|
||||
_, err := resolveSessionConfigPathWithCandidates("", []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 session config found") {
|
||||
t.Fatalf("error = %q, want missing-defaults context", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pass --session") {
|
||||
t.Fatalf("error = %q, want explicit-path guidance", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ package config
|
||||
const (
|
||||
DefaultPipelineConfigPathUsrLocal = "/usr/local/etc/narratio/pipeline.yml"
|
||||
DefaultPipelineConfigPathEtc = "/etc/narratio/pipeline.yml"
|
||||
DefaultSessionConfigPathLocal = "./session.yml"
|
||||
DefaultSessionConfigPathUsrLocal = "/usr/local/etc/narratio/session.yml"
|
||||
DefaultSessionConfigPathEtc = "/etc/narratio/session.yml"
|
||||
)
|
||||
|
||||
// DefaultPipelineConfigSearchPaths defines the default search order for
|
||||
@@ -16,3 +19,14 @@ var DefaultPipelineConfigSearchPaths = []string{
|
||||
DefaultPipelineConfigPathUsrLocal,
|
||||
DefaultPipelineConfigPathEtc,
|
||||
}
|
||||
|
||||
// DefaultSessionConfigSearchPaths defines the default search order for
|
||||
// session.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 DefaultSessionConfigSearchPaths = []string{
|
||||
DefaultSessionConfigPathLocal,
|
||||
DefaultSessionConfigPathUsrLocal,
|
||||
DefaultSessionConfigPathEtc,
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@@ -21,21 +23,56 @@ func LoadPipeline(path string) (*PipelineConfig, error) {
|
||||
|
||||
// LoadSession loads session configuration from a YAML file with strict field checking.
|
||||
func LoadSession(path string) (*SessionConfig, error) {
|
||||
var cfg SessionConfig
|
||||
if err := decodeStrictYAML("session", path, &cfg); err != nil {
|
||||
return LoadSessionWithOptions(path, SessionLoadOptions{})
|
||||
}
|
||||
|
||||
// SessionLoadOptions configures session template rendering behavior.
|
||||
type SessionLoadOptions struct {
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// LoadSessionWithOptions loads session configuration from a YAML file with
|
||||
// strict field checking after template rendering.
|
||||
func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) {
|
||||
sessionBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load session config: session file %q: open: %w", path, err)
|
||||
}
|
||||
|
||||
rendered, err := renderSessionTemplate(string(sessionBytes), opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load session config: %w", err)
|
||||
}
|
||||
|
||||
var cfg SessionConfig
|
||||
if err := decodeStrictYAMLFromReader("session", path, strings.NewReader(rendered), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("load session config: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(opts.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != strings.TrimSpace(opts.SessionID) {
|
||||
return nil, fmt.Errorf(
|
||||
"load session config: session file %q: session_id mismatch: --session-id %q does not match rendered session_id %q",
|
||||
path,
|
||||
strings.TrimSpace(opts.SessionID),
|
||||
strings.TrimSpace(cfg.SessionID),
|
||||
)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Load loads and resolves combined pipeline and session configuration.
|
||||
func Load(pipelinePath, sessionPath string) (*Config, error) {
|
||||
return LoadWithSessionOptions(pipelinePath, sessionPath, SessionLoadOptions{})
|
||||
}
|
||||
|
||||
// LoadWithSessionOptions loads and resolves combined pipeline and session
|
||||
// configuration with session template options.
|
||||
func LoadWithSessionOptions(pipelinePath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
|
||||
pipelineCfg, err := LoadPipeline(pipelinePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessionCfg, err := LoadSession(sessionPath)
|
||||
sessionCfg, err := LoadSessionWithOptions(sessionPath, sessionOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -55,7 +92,11 @@ func decodeStrictYAML(kind, path string, out any) error {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
dec := yaml.NewDecoder(f)
|
||||
return decodeStrictYAMLFromReader(kind, path, f, out)
|
||||
}
|
||||
|
||||
func decodeStrictYAMLFromReader(kind, path string, r io.Reader, out any) error {
|
||||
dec := yaml.NewDecoder(r)
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(out); err != nil {
|
||||
return fmt.Errorf("%s file %q: strict decode failed: %w", kind, path, err)
|
||||
@@ -69,6 +110,36 @@ func decodeStrictYAML(kind, path string, out any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sessionTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
|
||||
|
||||
func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) {
|
||||
sessionID := strings.TrimSpace(opts.SessionID)
|
||||
rendered := content
|
||||
if sessionID != "" {
|
||||
rendered = strings.ReplaceAll(rendered, "{{session_id}}", sessionID)
|
||||
rendered = strings.ReplaceAll(rendered, "{{ session_id }}", sessionID)
|
||||
}
|
||||
|
||||
unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1)
|
||||
if len(unresolved) > 0 {
|
||||
vars := make([]string, 0, len(unresolved))
|
||||
for _, m := range unresolved {
|
||||
if len(m) > 1 {
|
||||
vars = append(vars, m[1])
|
||||
}
|
||||
}
|
||||
if len(vars) > 0 {
|
||||
return "", fmt.Errorf(
|
||||
"session file template rendering failed: unresolved template variable(s): %s; pass --session-id when using {{ session_id }}",
|
||||
strings.Join(vars, ", "),
|
||||
)
|
||||
}
|
||||
return "", fmt.Errorf("session file template rendering failed: unresolved template placeholders remain")
|
||||
}
|
||||
|
||||
return rendered, nil
|
||||
}
|
||||
|
||||
func shortName(path, fallback string) string {
|
||||
base := filepath.Base(path)
|
||||
if base == "." || base == string(filepath.Separator) {
|
||||
|
||||
156
internal/config/session_template_test.go
Normal file
156
internal/config/session_template_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadSessionWithOptionsRendersCompactPlaceholder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
sessionYAML := `session_id: "{{session_id}}"
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessionWithOptions() error = %v", err)
|
||||
}
|
||||
if cfg.SessionID != "2026-04-04" {
|
||||
t.Fatalf("SessionID = %q, want 2026-04-04", cfg.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsRendersSpacedPlaceholder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
sessionYAML := `session_id: "{{ session_id }}"
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessionWithOptions() error = %v", err)
|
||||
}
|
||||
if cfg.SessionID != "2026-04-04" {
|
||||
t.Fatalf("SessionID = %q, want 2026-04-04", cfg.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsUnresolvedPlaceholderFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
sessionYAML := `session_id: "{{ session_id }}"
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unresolved template variable") {
|
||||
t.Fatalf("error = %q, want unresolved-variable context", err.Error())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "session_id") {
|
||||
t.Fatalf("error = %q, want session_id variable", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsMismatchFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
sessionYAML := `session_id: 2026-05-03
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "session_id mismatch") {
|
||||
t.Fatalf("error = %q, want mismatch context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsUnknownFieldStillRejectedAfterRendering(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
sessionYAML := `session_id: "{{ session_id }}"
|
||||
campaign: sample-campaign
|
||||
unknown_field: true
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{SessionID: "2026-04-04"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "strict decode failed") {
|
||||
t.Fatalf("error = %q, want strict-decode context", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSessionWithOptionsConcreteSessionStillLoads(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.yml")
|
||||
sessionYAML := `session_id: 2026-05-03
|
||||
campaign: sample-campaign
|
||||
inputs:
|
||||
audio_dir: ./audio
|
||||
speakers_file: ./speakers.yml
|
||||
autocorrect_file: ./autocorrect.yml
|
||||
glossary_file: ./glossary.yml
|
||||
`
|
||||
if err := os.WriteFile(sessionPath, []byte(sessionYAML), 0o644); err != nil {
|
||||
t.Fatalf("write session.yml: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadSessionWithOptions(sessionPath, SessionLoadOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSessionWithOptions() error = %v", err)
|
||||
}
|
||||
if cfg.SessionID != "2026-05-03" {
|
||||
t.Fatalf("SessionID = %q, want 2026-05-03", cfg.SessionID)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user