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

@@ -44,8 +44,8 @@ func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&flags.campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
fs.StringVar(&flags.sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.StringVar(&flags.sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
}
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
@@ -290,7 +290,18 @@ func SessionInit(ctx context.Context, args []string, out io.Writer) error {
return fmt.Errorf("session init: %w", err)
}
data, err := buildSessionYAML(base.Campaign.Campaign, sessionID, previousSessionID, date, title, audioS3Prefix, audioDir)
input := sessionInitInput{
Campaign: base.Campaign.Campaign,
CampaignPath: base.CampaignPath,
TemplateFile: base.Campaign.SessionTemplateFile,
SessionID: sessionID,
PreviousSessionID: previousSessionID,
Date: date,
Title: title,
AudioS3Prefix: audioS3Prefix,
AudioDir: audioDir,
}
data, err := buildSessionInitYAML(input)
if err != nil {
return fmt.Errorf("session init: %w", err)
}
@@ -588,6 +599,104 @@ func buildSessionYAML(campaign, sessionID, previousSessionID, date, title, audio
return data, nil
}
type sessionInitInput struct {
Campaign string
CampaignPath string
TemplateFile string
SessionID string
PreviousSessionID string
Date string
Title string
AudioS3Prefix string
AudioDir string
}
func buildSessionInitYAML(in sessionInitInput) ([]byte, error) {
if strings.TrimSpace(in.TemplateFile) == "" {
return buildSessionYAML(in.Campaign, in.SessionID, in.PreviousSessionID, in.Date, in.Title, in.AudioS3Prefix, in.AudioDir)
}
templatePath := resolveSessionInitTemplatePath(in.CampaignPath, in.TemplateFile)
templateBytes, err := os.ReadFile(templatePath)
if err != nil {
return nil, fmt.Errorf("read session template %q: %w", templatePath, err)
}
rendered, err := renderSessionInitTemplate(string(templateBytes), in)
if err != nil {
return nil, fmt.Errorf("render session template %q: %w", templatePath, err)
}
return []byte(rendered), nil
}
func resolveSessionInitTemplatePath(campaignPath, templateFile string) string {
templateFile = strings.TrimSpace(templateFile)
if filepath.IsAbs(templateFile) {
return filepath.Clean(templateFile)
}
return filepath.Clean(filepath.Join(filepath.Dir(campaignPath), templateFile))
}
var sessionInitTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
func renderSessionInitTemplate(content string, in sessionInitInput) (string, error) {
values := map[string]string{
"session_id": strings.TrimSpace(in.SessionID),
"previous_session_id": strings.TrimSpace(in.PreviousSessionID),
"date": strings.TrimSpace(in.Date),
"title": strings.TrimSpace(in.Title),
"audio_s3_prefix": strings.TrimSpace(in.AudioS3Prefix),
"audio_dir": strings.TrimSpace(in.AudioDir),
}
used := map[string]struct{}{}
unknown := map[string]struct{}{}
missing := map[string]struct{}{}
rendered := sessionInitTemplatePattern.ReplaceAllStringFunc(content, func(match string) string {
parts := sessionInitTemplatePattern.FindStringSubmatch(match)
if len(parts) < 2 {
return match
}
name := parts[1]
value, ok := values[name]
if !ok {
unknown[name] = struct{}{}
return match
}
used[name] = struct{}{}
if value == "" {
missing[name] = struct{}{}
return match
}
return value
})
if len(unknown) > 0 {
return "", fmt.Errorf("unsupported template variable(s): %s", sortedStringSet(unknown))
}
if len(missing) > 0 {
return "", fmt.Errorf("missing required template variable value(s): %s", sortedStringSet(missing))
}
unused := map[string]struct{}{}
for _, name := range []string{"previous_session_id", "date", "title", "audio_s3_prefix", "audio_dir"} {
if values[name] == "" {
continue
}
if _, ok := used[name]; !ok {
unused[name] = struct{}{}
}
}
if len(unused) > 0 {
return "", fmt.Errorf("unused template variable value(s): %s", sortedStringSet(unused))
}
return rendered, nil
}
func sortedStringSet(set map[string]struct{}) string {
items := make([]string, 0, len(set))
for item := range set {
items = append(items, item)
}
sort.Strings(items)
return strings.Join(items, ", ")
}
func renderFindings(out io.Writer, campaign, sessionID string, findings []finding) error {
if campaign != "" || sessionID != "" {
fmt.Fprintf(out, "Campaign: %s\n", campaign)

View File

@@ -214,6 +214,209 @@ func TestExecuteSessionInitRemoteLoadsSecretsBeforeObjectStoreInit(t *testing.T)
}
}
func TestExecuteSessionInitLocalRendersCampaignTemplate(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
previous_session_id: "{{ previous_session_id }}"
date: "{{ date }}"
title: "{{ title }}"
inputs:
audio_s3:
prefix: "{{ audio_s3_prefix }}"
`)
outputPath := filepath.Join(t.TempDir(), "session.yml")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--previous-session-id", "2026-05-31",
"--date", "2026-06-07",
"--title", "The Black Cabin",
"--audio-s3-prefix", "audio/",
"--output", outputPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read generated session: %v", err)
}
got := string(data)
for _, want := range []string{
`session_id: "2026-06-07"`,
`previous_session_id: "2026-05-31"`,
`date: "2026-06-07"`,
`title: "The Black Cabin"`,
`prefix: "audio/"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("generated session = %q, want %q", got, want)
}
}
if strings.Contains(got, "{{") {
t.Fatalf("generated session still contains template placeholder: %q", got)
}
}
func TestExecuteSessionInitRemoteRendersCampaignTemplate(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
inputs:
audio_s3:
prefix: audio/
`)
fake := &storage.FakeBackend{}
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
key := artifacts.S3SessionConfigKey(artifacts.S3SessionPrefix("dnd", "sample-campaign", "2026-06-07"))
obj, ok := fake.Objects[key]
if !ok {
t.Fatalf("remote session key %q not uploaded; objects=%v", key, fake.Objects)
}
if strings.Contains(string(obj.Data), "{{") || !strings.Contains(string(obj.Data), `session_id: "2026-06-07"`) {
t.Fatalf("remote session data = %q, want rendered concrete session", string(obj.Data))
}
}
func TestExecuteSessionInitTemplatePathIsCampaignRelative(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
templateDir := filepath.Join(filepath.Dir(campaignPath), "templates")
if err := os.MkdirAll(templateDir, 0o755); err != nil {
t.Fatalf("mkdir template dir: %v", err)
}
templatePath := filepath.Join(templateDir, "session.template.yml")
if err := os.WriteFile(templatePath, []byte(`session_id: "{{ session_id }}"
inputs:
audio_dir: ./audio
`), 0o644); err != nil {
t.Fatalf("write session template: %v", err)
}
addSessionTemplateToCampaign(t, campaignPath, "./templates/session.template.yml")
outputPath := filepath.Join(t.TempDir(), "session.yml")
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--output", outputPath,
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("exit code = %d, want 0; stderr=%q", code, stderr.String())
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("read generated session: %v", err)
}
if !strings.Contains(string(data), `session_id: "2026-06-07"`) {
t.Fatalf("generated session = %q, want campaign-relative template output", string(data))
}
}
func TestExecuteSessionInitTemplateMissingVariableFails(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
date: "{{ date }}"
inputs:
audio_s3:
prefix: audio/
`)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "missing required template variable value(s): date") {
t.Fatalf("stderr = %q, want missing date variable", stderr.String())
}
}
func TestExecuteSessionInitTemplateUnusedFlagFails(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
inputs:
audio_s3:
prefix: audio/
`)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--title", "Unused Title",
"--remote",
}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "unused template variable value(s): title") {
t.Fatalf("stderr = %q, want unused title variable", stderr.String())
}
}
func TestExecuteSessionInitTemplateStrictDecodeFailure(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
writeSessionInitTemplate(t, campaignPath, `session_id: "{{ session_id }}"
unknown: true
inputs:
audio_s3:
prefix: audio/
`)
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{
"session", "init",
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-06-07",
"--remote",
}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "strict decode failed") {
t.Fatalf("stderr = %q, want strict decode error", stderr.String())
}
}
func TestExecuteSessionValidateLoadsSecretsBeforeObjectStoreInit(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -486,6 +689,30 @@ func withDefaultPipelineCampaignConfigs(t *testing.T, pipelinePath, campaignPath
})
}
func writeSessionInitTemplate(t *testing.T, campaignPath, templateYAML string) {
t.Helper()
templatePath := filepath.Join(filepath.Dir(campaignPath), "session.template.yml")
if err := os.WriteFile(templatePath, []byte(templateYAML), 0o644); err != nil {
t.Fatalf("write session template: %v", err)
}
addSessionTemplateToCampaign(t, campaignPath, "./session.template.yml")
}
func addSessionTemplateToCampaign(t *testing.T, campaignPath, templateFile string) {
t.Helper()
data, err := os.ReadFile(campaignPath)
if err != nil {
t.Fatalf("read campaign config: %v", err)
}
if strings.Contains(string(data), "session_template_file:") {
t.Fatalf("campaign config already has session_template_file: %q", string(data))
}
updated := "session_template_file: " + templateFile + "\n" + string(data)
if err := os.WriteFile(campaignPath, []byte(updated), 0o644); err != nil {
t.Fatalf("write campaign config: %v", err)
}
}
func TestExecuteArtifactsListRemoteReportsPromotedAvailability(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)

View File

@@ -28,8 +28,8 @@ func Plan(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
if err := fs.Parse(args); err != nil {

View File

@@ -19,7 +19,7 @@ func TestExecuteRemoteSessionFallbackLoadsFromObjectStore(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
remoteKey := seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}"
remoteKey := seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
inputs:
audio_s3:
prefix: audio/
@@ -56,7 +56,7 @@ func TestExecuteRemoteSessionFallbackLoadsSecretsBeforeObjectStoreInit(t *testin
addSecretsToPipelineConfig(t, pipelinePath, secretsDir, accessKeyEnv, secretKeyEnv)
fake := &storage.FakeBackend{}
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}"
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: 2026-05-03
inputs:
audio_s3:
prefix: audio/
@@ -205,6 +205,48 @@ func TestExecuteRemoteSessionMalformedYAMLFailsStrictDecode(t *testing.T) {
}
}
func TestExecuteRemoteSessionTemplateFailsConcreteSessionCheck(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
seedRemoteSessionConfig(t, fake, "2026-05-03", `session_id: "{{ session_id }}"
inputs:
audio_s3:
prefix: audio/
`)
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "session.yml must be concrete") || !strings.Contains(stderr.String(), "run narratio session init") {
t.Fatalf("stderr = %q, want concrete session guidance", stderr.String())
}
}
func TestExecuteRemoteSessionMismatchFails(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, _ := writeValidConfigFiles(t, workspaceRoot)
fake := &storage.FakeBackend{}
seedRemoteSessionConfig(t, fake, "2026-05-03", "session_id: 2026-05-04\ninputs:\n audio_s3:\n prefix: audio/\n")
var storeInitCalls int
restoreAppConfigTestGlobals(t, fake, &storeInitCalls, []string{filepath.Join(t.TempDir(), "session.yml")})
var stdout bytes.Buffer
var stderr bytes.Buffer
code := Execute([]string{"plan", "--config", pipelinePath, "--campaign", campaignPath, "--session-id", "2026-05-03"}, &stdout, &stderr)
if code == 0 {
t.Fatal("exit code = 0, want non-zero")
}
if !strings.Contains(stderr.String(), "session_id mismatch") {
t.Fatalf("stderr = %q, want session_id mismatch", stderr.String())
}
}
func restoreAppConfigTestGlobals(t *testing.T, fake *storage.FakeBackend, storeInitCalls *int, sessionDefaults []string) {
t.Helper()
origStoreFn := newObjectStoreFromConfigFn

View File

@@ -36,8 +36,8 @@ func Restore(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&dryRun, "dry-run", false, "plan restore actions without writing local files")
fs.BoolVar(&force, "force", false, "overwrite local conflicts with remote state")
fs.BoolVar(&includeAudio, "include-audio", false, "include archived session-level audio objects")

View File

@@ -26,8 +26,8 @@ func Resume(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")

View File

@@ -24,8 +24,8 @@ func Run(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")

View File

@@ -24,8 +24,8 @@ func RunStage(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.BoolVar(&force, "force", false, "force stage execution (reserved for future behavior)")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
@@ -85,8 +85,8 @@ func Analyze(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
fs.Var(&selectedArtifacts, "artifacts", "artifact names to execute during analyze (comma-separated or repeatable)")
if err := fs.Parse(args); err != nil {
@@ -138,8 +138,8 @@ func Publish(ctx context.Context, args []string, out io.Writer) error {
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
fs.StringVar(&campaignPath, "campaign", "", "path to campaign.yml (optional; defaults searched)")
fs.StringVar(&sessionPath, "session", "", "path to session.yml")
fs.StringVar(&sessionID, "session-id", "", "session identifier for session.yml templates")
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier for session.yml templates")
fs.StringVar(&sessionID, "session-id", "", "expected session identifier and remote session lookup value")
fs.StringVar(&previousSessionID, "previous-session-id", "", "expected previous session identifier")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("publish: invalid flags: %w", err)

View File

@@ -10,7 +10,7 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/config"
)
func TestPlanUsesDiscoveredSessionTemplateWithSessionIDs(t *testing.T) {
func TestPlanRejectsDiscoveredSessionTemplate(t *testing.T) {
workspaceRoot := t.TempDir()
pipelinePath, campaignPath, sessionPath := writeValidConfigFiles(t, workspaceRoot)
@@ -32,16 +32,20 @@ inputs:
t.Cleanup(func() { config.DefaultSessionConfigSearchPaths = origSessionDefaults })
var out bytes.Buffer
if err := Plan(context.Background(), []string{
err := Plan(context.Background(), []string{
"--config", pipelinePath,
"--campaign", campaignPath,
"--session-id", "2026-04-04",
"--previous-session-id", "2026-03-28",
}, &out); err != nil {
t.Fatalf("Plan() error = %v", err)
}, &out)
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(out.String(), "narratio plan: workdir prepared") {
t.Fatalf("output = %q, want plan output", out.String())
if !strings.Contains(err.Error(), "session.yml must be concrete") {
t.Fatalf("error = %q, want concrete session guidance", err.Error())
}
if !strings.Contains(err.Error(), "run narratio session init") {
t.Fatalf("error = %q, want session init guidance", err.Error())
}
}