Session configuration templates are now proceeded by narratio session init; all other commands require concrete configuration

This commit is contained in:
2026-05-22 17:38:23 -05:00
parent d0936fb022
commit 7324c5a686
20 changed files with 550 additions and 299 deletions

View File

@@ -36,14 +36,14 @@ func LoadSession(path string) (*SessionConfig, error) {
return LoadSessionWithOptions(path, SessionLoadOptions{})
}
// SessionLoadOptions configures session template rendering behavior.
// SessionLoadOptions configures expected session identity checks.
type SessionLoadOptions struct {
SessionID string
PreviousSessionID string
}
// LoadSessionWithOptions loads session configuration from a YAML file with
// strict field checking after template rendering.
// strict field checking.
func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) {
sessionBytes, err := os.ReadFile(path)
if err != nil {
@@ -53,20 +53,19 @@ func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfi
}
// LoadSessionBytesWithOptions loads session configuration from YAML bytes with
// strict field checking after template rendering.
// strict field checking.
func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOptions) (*SessionConfig, error) {
rendered, err := renderSessionTemplate(string(data), opts)
if err != nil {
if err := rejectSessionTemplatePlaceholders(label, string(data)); err != nil {
return nil, fmt.Errorf("load session config: %w", err)
}
var cfg SessionConfig
if err := decodeStrictYAMLFromReader("session", label, strings.NewReader(rendered), &cfg); err != nil {
if err := decodeStrictYAMLFromReader("session", label, strings.NewReader(string(data)), &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",
"load session config: session file %q: session_id mismatch: --session-id %q does not match session_id %q",
label,
strings.TrimSpace(opts.SessionID),
strings.TrimSpace(cfg.SessionID),
@@ -76,7 +75,7 @@ func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOpti
strings.TrimSpace(cfg.PreviousSessionID) != "" &&
strings.TrimSpace(cfg.PreviousSessionID) != strings.TrimSpace(opts.PreviousSessionID) {
return nil, fmt.Errorf(
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match rendered previous_session_id %q",
"load session config: session file %q: previous_session_id mismatch: --previous-session-id %q does not match previous_session_id %q",
label,
strings.TrimSpace(opts.PreviousSessionID),
strings.TrimSpace(cfg.PreviousSessionID),
@@ -124,7 +123,7 @@ func Load(pipelinePath string, paths ...string) (*Config, error) {
}
// LoadWithSessionOptions loads and resolves combined pipeline, campaign, and
// session configuration with session template options.
// session configuration with expected session identity checks.
func LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath string, sessionOpts SessionLoadOptions) (*Config, error) {
pipelineCfg, err := LoadPipeline(pipelinePath)
if err != nil {
@@ -275,75 +274,33 @@ func decodeStrictYAMLFromReader(kind, path string, r io.Reader, out any) error {
return nil
}
var sessionTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
var sessionTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^}]*\}\}`)
var sessionTemplateVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
func renderSessionTemplate(content string, opts SessionLoadOptions) (string, error) {
sessionID := strings.TrimSpace(opts.SessionID)
previousSessionID := strings.TrimSpace(opts.PreviousSessionID)
rendered := content
if sessionID != "" {
rendered = replaceTemplateVariable(rendered, "session_id", sessionID)
func rejectSessionTemplatePlaceholders(label, content string) error {
placeholders := sessionTemplatePlaceholderPattern.FindAllString(content, -1)
if len(placeholders) == 0 {
return nil
}
if previousSessionID != "" {
rendered = replaceTemplateVariable(rendered, "previous_session_id", previousSessionID)
}
unresolved := sessionTemplatePattern.FindAllStringSubmatch(rendered, -1)
if len(unresolved) > 0 {
seenVars := map[string]struct{}{}
vars := make([]string, 0, len(unresolved))
for _, m := range unresolved {
if len(m) > 1 {
name := m[1]
if _, ok := seenVars[name]; ok {
continue
}
seenVars[name] = struct{}{}
vars = append(vars, name)
}
}
sort.Strings(vars)
if len(vars) > 0 {
hints := unresolvedTemplateHints(vars)
return "", fmt.Errorf(
"session file template rendering failed: unresolved template variable(s): %s%s",
strings.Join(vars, ", "),
hints,
)
}
return "", fmt.Errorf("session file template rendering failed: unresolved template placeholders remain")
}
return rendered, nil
}
func replaceTemplateVariable(content, name, value string) string {
rendered := strings.ReplaceAll(content, "{{"+name+"}}", value)
rendered = strings.ReplaceAll(rendered, "{{ "+name+" }}", value)
return rendered
}
func unresolvedTemplateHints(vars []string) string {
seen := map[string]struct{}{}
flags := make([]string, 0, 2)
for _, name := range vars {
switch name {
case "session_id":
if _, ok := seen["--session-id"]; !ok {
seen["--session-id"] = struct{}{}
flags = append(flags, "--session-id")
}
case "previous_session_id":
if _, ok := seen["--previous-session-id"]; !ok {
seen["--previous-session-id"] = struct{}{}
flags = append(flags, "--previous-session-id")
}
vars := make([]string, 0, len(placeholders))
for _, placeholder := range placeholders {
name := strings.TrimSpace(placeholder)
if match := sessionTemplateVariablePattern.FindStringSubmatch(placeholder); len(match) > 1 {
name = match[1]
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
vars = append(vars, name)
}
if len(flags) == 0 {
return ""
}
return "; pass " + strings.Join(flags, " and ") + " when using those template variable(s)"
sort.Strings(vars)
return fmt.Errorf(
"session file %q contains template placeholder(s): %s; session.yml must be concrete; run narratio session init to generate it",
label,
strings.Join(vars, ", "),
)
}
func shortName(path, fallback string) string {