diff --git a/examples/config.yml b/examples/config.yml index 40f7bc3..8466cd0 100644 --- a/examples/config.yml +++ b/examples/config.yml @@ -35,9 +35,10 @@ missing_source: sources: alerts: none -scriptorium: - binary: scriptorium +promptkit: timeout: 2m + local: + concurrency_limit: 1 workspace: root: workspace diff --git a/internal/adapters/scriptorium/runner.go b/internal/adapters/scriptorium/runner.go deleted file mode 100644 index fef0dcd..0000000 --- a/internal/adapters/scriptorium/runner.go +++ /dev/null @@ -1,291 +0,0 @@ -// Package scriptorium adapts the external scriptorium CLI. -package scriptorium - -import ( - "context" - "fmt" - "io" - "os/exec" - "time" -) - -const maxCapturedOutputBytes = 1024 * 1024 - -type CommandRunner interface { - Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) -} - -type CommandResult struct { - Stdout []byte - Stderr []byte - StdoutTruncated bool - StderrTruncated bool - ExitCode int -} - -type ExecRunner struct{} - -func (ExecRunner) Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) { - runCtx := ctx - cancel := func() {} - if timeout > 0 { - runCtx, cancel = context.WithTimeout(ctx, timeout) - } - defer cancel() - - cmd := exec.CommandContext(runCtx, name, args...) - stdout := &limitedBuffer{limit: maxCapturedOutputBytes} - stderr := &limitedBuffer{limit: maxCapturedOutputBytes} - cmd.Stdout = stdout - cmd.Stderr = stderr - err := cmd.Run() - result := CommandResult{ - Stdout: stdout.Bytes(), - Stderr: stderr.Bytes(), - StdoutTruncated: stdout.Truncated(), - StderrTruncated: stderr.Truncated(), - ExitCode: 0, - } - if err == nil { - return result, nil - } - if runCtx.Err() != nil { - return result, runCtx.Err() - } - if exitErr, ok := err.(*exec.ExitError); ok { - result.ExitCode = exitErr.ExitCode() - return result, nil - } - return result, err -} - -type Runner struct { - Binary string - ConfigPath string - Profile string - Timeout time.Duration - ExtraArgs []string - Commands CommandRunner -} - -type RenderRequest struct { - PromptID string - DataPackagePath string -} - -type StructuredRunRequest struct { - PromptID string - DataPackagePath string - OutputPath string -} - -type RenderResult struct { - Command []string `json:"command"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - StdoutTruncated bool `json:"stdoutTruncated,omitempty"` - StderrTruncated bool `json:"stderrTruncated,omitempty"` - ExitCode int `json:"exitCode"` -} - -type StructuredRunResult struct { - Command []string `json:"command"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - StdoutTruncated bool `json:"stdoutTruncated,omitempty"` - StderrTruncated bool `json:"stderrTruncated,omitempty"` - ExitCode int `json:"exitCode"` - OutputPath string `json:"outputPath"` -} - -func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) { - if req.PromptID == "" { - return nil, fmt.Errorf("prompt id is required") - } - if req.DataPackagePath == "" { - return nil, fmt.Errorf("data package path is required") - } - execution, err := r.execute(ctx, r.renderArgs(req)) - if err != nil { - return nil, fmt.Errorf("run scriptorium render: %w", err) - } - result := &RenderResult{ - Command: execution.argv(), - Stdout: string(execution.result.Stdout), - Stderr: string(execution.result.Stderr), - StdoutTruncated: execution.result.StdoutTruncated, - StderrTruncated: execution.result.StderrTruncated, - ExitCode: execution.result.ExitCode, - } - if execution.result.ExitCode != 0 { - return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.ExitCode, result.Stderr) - } - return result, nil -} - -func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) { - result, err := r.executeRun(ctx, outputRunRequest{ - PromptID: req.PromptID, - DataPackagePath: req.DataPackagePath, - OutputPath: req.OutputPath, - }, "run scriptorium structured output", "scriptorium structured run") - if err != nil { - if result == nil { - return nil, err - } - return result.structuredRunResult(), err - } - return result.structuredRunResult(), nil -} - -func (result outputRunResult) structuredRunResult() *StructuredRunResult { - return &StructuredRunResult{ - Command: result.Command, - Stdout: result.Stdout, - Stderr: result.Stderr, - StdoutTruncated: result.StdoutTruncated, - StderrTruncated: result.StderrTruncated, - ExitCode: result.ExitCode, - OutputPath: result.OutputPath, - } -} - -type execution struct { - binary string - args []string - result CommandResult -} - -type outputRunRequest struct { - PromptID string - DataPackagePath string - OutputPath string -} - -type outputRunResult struct { - Command []string - Stdout string - Stderr string - StdoutTruncated bool - StderrTruncated bool - ExitCode int - OutputPath string -} - -func (r Runner) executeRun(ctx context.Context, req outputRunRequest, executeContext string, exitContext string) (*outputRunResult, error) { - if req.PromptID == "" { - return nil, fmt.Errorf("prompt id is required") - } - if req.DataPackagePath == "" { - return nil, fmt.Errorf("data package path is required") - } - if req.OutputPath == "" { - return nil, fmt.Errorf("output path is required") - } - execution, err := r.execute(ctx, r.runArgs(req)) - if err != nil { - return nil, fmt.Errorf("%s: %w", executeContext, err) - } - result := &outputRunResult{ - Command: execution.argv(), - Stdout: string(execution.result.Stdout), - Stderr: string(execution.result.Stderr), - StdoutTruncated: execution.result.StdoutTruncated, - StderrTruncated: execution.result.StderrTruncated, - ExitCode: execution.result.ExitCode, - OutputPath: req.OutputPath, - } - if execution.result.ExitCode != 0 { - return result, fmt.Errorf("%s exited with code %d: %s", exitContext, execution.result.ExitCode, result.Stderr) - } - return result, nil -} - -func (r Runner) execute(ctx context.Context, args []string) (execution, error) { - binary := r.Binary - if binary == "" { - binary = "scriptorium" - } - commands := r.Commands - if commands == nil { - commands = ExecRunner{} - } - result, err := commands.Run(ctx, binary, args, r.Timeout) - if err != nil { - return execution{}, err - } - return execution{binary: binary, args: args, result: result}, nil -} - -func (e execution) argv() []string { - return append([]string{e.binary}, e.args...) -} - -func (r Runner) renderArgs(req RenderRequest) []string { - args := []string{"render"} - if r.ConfigPath != "" { - args = append(args, "--config", r.ConfigPath) - } - if r.Profile != "" { - args = append(args, "--profile", r.Profile) - } - args = append(args, - "--prompt", req.PromptID, - "--input", "data_package="+req.DataPackagePath, - "--format", "json", - ) - args = append(args, r.ExtraArgs...) - return args -} - -func (r Runner) runArgs(req outputRunRequest) []string { - args := []string{"run"} - if r.ConfigPath != "" { - args = append(args, "--config", r.ConfigPath) - } - if r.Profile != "" { - args = append(args, "--profile", r.Profile) - } - args = append(args, - "--prompt", req.PromptID, - "--input", "data_package="+req.DataPackagePath, - "--out", req.OutputPath, - ) - args = append(args, r.ExtraArgs...) - return args -} - -type limitedBuffer struct { - data []byte - limit int - truncated bool -} - -func (b *limitedBuffer) Write(p []byte) (int, error) { - if b.limit <= 0 { - b.truncated = true - return len(p), nil - } - remaining := b.limit - len(b.data) - if remaining <= 0 { - b.truncated = true - return len(p), nil - } - if len(p) > remaining { - b.data = append(b.data, p[:remaining]...) - b.truncated = true - return len(p), nil - } - b.data = append(b.data, p...) - return len(p), nil -} - -func (b *limitedBuffer) Bytes() []byte { - return append([]byte{}, b.data...) -} - -func (b *limitedBuffer) Truncated() bool { - return b.truncated -} - -var _ io.Writer = (*limitedBuffer)(nil) diff --git a/internal/adapters/scriptorium/runner_test.go b/internal/adapters/scriptorium/runner_test.go deleted file mode 100644 index 267de2a..0000000 --- a/internal/adapters/scriptorium/runner_test.go +++ /dev/null @@ -1,413 +0,0 @@ -package scriptorium - -import ( - "context" - "fmt" - "reflect" - "strings" - "testing" - "time" -) - -func TestRenderConstructsCommand(t *testing.T) { - commands := &fakeCommands{result: CommandResult{Stdout: []byte(`{"ok":true}`)}} - runner := Runner{ - Binary: "/usr/local/bin/scriptorium", - ConfigPath: "/etc/scriptorium.yml", - Profile: "weather", - Timeout: time.Minute, - Commands: commands, - } - - result, err := runner.Render(context.Background(), RenderRequest{ - PromptID: "weather.daily_generated_text", - DataPackagePath: "/tmp/data_package.yaml", - }) - if err != nil { - t.Fatalf("Render() error = %v", err) - } - - wantArgs := []string{ - "render", - "--config", "/etc/scriptorium.yml", - "--profile", "weather", - "--prompt", "weather.daily_generated_text", - "--input", "data_package=/tmp/data_package.yaml", - "--format", "json", - } - if commands.name != "/usr/local/bin/scriptorium" { - t.Fatalf("command name = %q, want custom binary", commands.name) - } - if !reflect.DeepEqual(commands.args, wantArgs) { - t.Fatalf("args = %#v, want %#v", commands.args, wantArgs) - } - if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) { - t.Fatalf("result command = %#v, want full argv", result.Command) - } -} - -func TestRenderReturnsResultForNonzeroExit(t *testing.T) { - runner := Runner{ - Commands: &fakeCommands{ - result: CommandResult{ - Stderr: []byte("missing input"), - ExitCode: 1, - }, - }, - } - - result, err := runner.Render(context.Background(), RenderRequest{ - PromptID: "weather.daily_generated_text", - DataPackagePath: "/tmp/data_package.yaml", - }) - if err == nil { - t.Fatal("Render() error = nil, want nonzero exit error") - } - if result == nil { - t.Fatal("Render() result = nil, want captured result") - } - if result.ExitCode != 1 { - t.Fatalf("ExitCode = %d, want 1", result.ExitCode) - } - if !strings.Contains(err.Error(), "missing input") { - t.Fatalf("error = %q, want stderr context", err.Error()) - } -} - -func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) { - commands := &fakeCommands{result: CommandResult{ - Stdout: []byte(`{"summary":"ok"}`), - Stderr: []byte("wrote generated text"), - StdoutTruncated: true, - }} - runner := Runner{ - Binary: "/usr/local/bin/scriptorium", - ConfigPath: "/etc/scriptorium.yml", - Profile: "weather", - Timeout: 30 * time.Second, - Commands: commands, - } - - result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{ - PromptID: "weather.hourly_generated_text", - DataPackagePath: "/tmp/data_package.hourly.yaml", - OutputPath: "/tmp/generated_text_raw.hourly.json", - }) - if err != nil { - t.Fatalf("StructuredRun() error = %v", err) - } - - wantArgs := []string{ - "run", - "--config", "/etc/scriptorium.yml", - "--profile", "weather", - "--prompt", "weather.hourly_generated_text", - "--input", "data_package=/tmp/data_package.hourly.yaml", - "--out", "/tmp/generated_text_raw.hourly.json", - } - if commands.name != "/usr/local/bin/scriptorium" { - t.Fatalf("command name = %q, want custom binary", commands.name) - } - if !reflect.DeepEqual(commands.args, wantArgs) { - t.Fatalf("args = %#v, want %#v", commands.args, wantArgs) - } - for _, disallowed := range []string{"--format", "--schema", "--schema-path", "--json-schema"} { - if containsArg(commands.args, disallowed) { - t.Fatalf("args = %#v, should not include %q", commands.args, disallowed) - } - } - if commands.timeout != 30*time.Second { - t.Fatalf("timeout = %s, want 30s", commands.timeout) - } - if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) { - t.Fatalf("result command = %#v, want full argv", result.Command) - } - if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated { - t.Fatalf("result = %#v, want captured output and truncation flags", result) - } - if result.OutputPath != "/tmp/generated_text_raw.hourly.json" { - t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath) - } -} - -func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) { - runner := Runner{ - Commands: &fakeCommands{ - result: CommandResult{ - Stdout: []byte(`{"summary":"partial"}`), - Stderr: []byte("structured output failed"), - ExitCode: 3, - }, - }, - } - - result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{ - PromptID: "weather.hourly_generated_text", - DataPackagePath: "/tmp/data_package.hourly.yaml", - OutputPath: "/tmp/generated_text_raw.hourly.json", - }) - if err == nil { - t.Fatal("StructuredRun() error = nil, want nonzero exit error") - } - if result == nil { - t.Fatal("StructuredRun() result = nil, want captured result") - } - if result.ExitCode != 3 { - t.Fatalf("ExitCode = %d, want 3", result.ExitCode) - } - if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/generated_text_raw.hourly.json" { - t.Fatalf("result = %#v, want captured result fields", result) - } - if !strings.Contains(err.Error(), "structured output failed") { - t.Fatalf("error = %q, want stderr context", err.Error()) - } -} - -func TestOutputRunsPreserveCapturedResultFields(t *testing.T) { - type commonResult struct { - Command []string - Stdout string - Stderr string - StdoutTruncated bool - StderrTruncated bool - ExitCode int - OutputPath string - } - tests := []struct { - name string - run func(Runner) (*commonResult, error) - }{ - { - name: "StructuredRun", - run: func(runner Runner) (*commonResult, error) { - result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{ - PromptID: "weather.daily_generated_text", - DataPackagePath: "/tmp/data_package.yaml", - OutputPath: "/tmp/report.md", - }) - if result == nil { - return nil, err - } - return &commonResult{ - Command: result.Command, - Stdout: result.Stdout, - Stderr: result.Stderr, - StdoutTruncated: result.StdoutTruncated, - StderrTruncated: result.StderrTruncated, - ExitCode: result.ExitCode, - OutputPath: result.OutputPath, - }, err - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - commands := &fakeCommands{result: CommandResult{ - Stdout: []byte("captured stdout"), - Stderr: []byte("captured stderr"), - StdoutTruncated: true, - StderrTruncated: true, - }} - runner := Runner{ - Binary: "/usr/local/bin/scriptorium", - ConfigPath: "/etc/scriptorium.yml", - Profile: "weather", - Timeout: 15 * time.Second, - Commands: commands, - } - - result, err := test.run(runner) - if err != nil { - t.Fatalf("%s error = %v", test.name, err) - } - wantArgs := []string{ - "run", - "--config", "/etc/scriptorium.yml", - "--profile", "weather", - "--prompt", "weather.daily_generated_text", - "--input", "data_package=/tmp/data_package.yaml", - "--out", "/tmp/report.md", - } - if !reflect.DeepEqual(commands.args, wantArgs) { - t.Fatalf("args = %#v, want %#v", commands.args, wantArgs) - } - if commands.timeout != 15*time.Second { - t.Fatalf("timeout = %s, want 15s", commands.timeout) - } - if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) { - t.Fatalf("Command = %#v, want full argv", result.Command) - } - if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" { - t.Fatalf("captured output = %q/%q, want stdout/stderr", result.Stdout, result.Stderr) - } - if !result.StdoutTruncated || !result.StderrTruncated { - t.Fatalf("truncation flags = %t/%t, want both true", result.StdoutTruncated, result.StderrTruncated) - } - if result.ExitCode != 0 || result.OutputPath != "/tmp/report.md" { - t.Fatalf("result = %#v, want exit 0 and output path", result) - } - }) - } -} - -func TestOutputRunsReturnCapturedResultForNonzeroExit(t *testing.T) { - type commonResult struct { - Stdout string - Stderr string - StderrTruncated bool - ExitCode int - OutputPath string - } - tests := []struct { - name string - run func(Runner) (*commonResult, error) - wantErr string - }{ - { - name: "StructuredRun", - run: func(runner Runner) (*commonResult, error) { - result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{ - PromptID: "weather.daily_generated_text", - DataPackagePath: "/tmp/data_package.yaml", - OutputPath: "/tmp/report.md", - }) - if result == nil { - return nil, err - } - return &commonResult{ - Stdout: result.Stdout, - Stderr: result.Stderr, - StderrTruncated: result.StderrTruncated, - ExitCode: result.ExitCode, - OutputPath: result.OutputPath, - }, err - }, - wantErr: "scriptorium structured run exited with code 7: captured stderr", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - runner := Runner{ - Commands: &fakeCommands{result: CommandResult{ - Stdout: []byte("captured stdout"), - Stderr: []byte("captured stderr"), - StderrTruncated: true, - ExitCode: 7, - }}, - } - - result, err := test.run(runner) - if err == nil { - t.Fatalf("%s error = nil, want nonzero exit error", test.name) - } - if result == nil { - t.Fatalf("%s result = nil, want captured result", test.name) - } - if err.Error() != test.wantErr { - t.Fatalf("%s error = %q, want %q", test.name, err.Error(), test.wantErr) - } - if result.Stdout != "captured stdout" || result.Stderr != "captured stderr" || !result.StderrTruncated { - t.Fatalf("captured result = %#v, want stdout/stderr/truncation", result) - } - if result.ExitCode != 7 || result.OutputPath != "/tmp/report.md" { - t.Fatalf("result = %#v, want exit 7 and output path", result) - } - }) - } -} - -func TestOutputRunsValidateRequiredFieldsBeforeExecution(t *testing.T) { - tests := []struct { - name string - run func(Runner, string, string, string) error - }{ - { - name: "StructuredRun", - run: func(runner Runner, promptID string, dataPackagePath string, outputPath string) error { - result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{ - PromptID: promptID, - DataPackagePath: dataPackagePath, - OutputPath: outputPath, - }) - if result != nil { - return fmt.Errorf("result = %#v, want nil", result) - } - return err - }, - }, - } - cases := []struct { - name string - promptID string - dataPackagePath string - outputPath string - want string - }{ - { - name: "prompt id", - dataPackagePath: "/tmp/data_package.yaml", - outputPath: "/tmp/report.md", - want: "prompt id is required", - }, - { - name: "data package path", - promptID: "weather.daily_generated_text", - outputPath: "/tmp/report.md", - want: "data package path is required", - }, - { - name: "output path", - promptID: "weather.daily_generated_text", - dataPackagePath: "/tmp/data_package.yaml", - want: "output path is required", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - commands := &fakeCommands{} - err := test.run(Runner{Commands: commands}, tc.promptID, tc.dataPackagePath, tc.outputPath) - if err == nil { - t.Fatalf("%s error = nil, want validation error", test.name) - } - if !strings.Contains(err.Error(), tc.want) { - t.Fatalf("%s error = %v, want %q", test.name, err, tc.want) - } - if commands.calls != 0 { - t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls) - } - }) - } - }) - } -} - -type fakeCommands struct { - name string - args []string - timeout time.Duration - result CommandResult - err error - calls int -} - -func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) { - f.calls++ - f.name = name - f.args = append([]string{}, args...) - f.timeout = timeout - return f.result, f.err -} - -func containsArg(args []string, want string) bool { - for _, arg := range args { - if arg == want { - return true - } - } - return false -} diff --git a/internal/app/app.go b/internal/app/app.go index 5663e73..60919ef 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,13 +3,11 @@ package app import ( "context" - "errors" "fmt" "path/filepath" "time" distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor" - "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" "gitea.maximumdirect.net/eric/weatherreporter/internal/changes" "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" @@ -17,7 +15,6 @@ import ( "gitea.maximumdirect.net/eric/weatherreporter/internal/facts" "gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil" "gitea.maximumdirect.net/eric/weatherreporter/internal/forecast" - "gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext" "gitea.maximumdirect.net/eric/weatherreporter/internal/module" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" @@ -57,14 +54,15 @@ type GenerateRequest struct { } type BatchRequest struct { - Config config.Config - Batch BatchKind - Now time.Time - OutputDir string - Collector Collector - Renderer Renderer - Store state.Store - Notifier Notifier + Config config.Config + Batch BatchKind + Now time.Time + OutputDir string + LLMDebugDir string + Collector Collector + Executor promptexec.Executor + Store state.Store + Notifier Notifier } type FetchBundleRequest struct { @@ -82,17 +80,6 @@ type ReportFacts struct { Derived facts.DerivedFacts } -type ReportRequest struct { - Config config.Config - Resolved report.Resolved - OutputPath string - Collection collect.Result - Renderer Renderer - Store state.Store - Notifier Notifier - noNotify bool -} - type ReportResult struct { ModuleSnapshot module.Snapshot ModuleSnapshotPath string @@ -101,7 +88,6 @@ type ReportResult struct { PreparationPath string ExecutionPath string LLMDebugPath string - PreflightPath string ReportPath string OutputPath string NotificationPath string @@ -202,11 +188,6 @@ func batchReportFailures(result *BatchResult) int { return failures } -type Renderer interface { - Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error) - StructuredRun(context.Context, scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) -} - type Collector interface { Run(context.Context, collect.Request) (*collect.Result, error) } @@ -324,6 +305,22 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro if _, err := report.BatchForCommandName(string(req.Batch)); err != nil { return nil, err } + debugWriter, err := state.NewPromptDebugWriter(req.LLMDebugDir) + if err != nil { + return nil, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err) + } + candidates, err := batchInspectionCandidates(req, now) + if err != nil { + return nil, err + } + inspections, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{ + Resolved: candidates, + Executor: req.Executor, + Promptkit: req.Config.Promptkit, + }) + if err != nil { + return nil, err + } collection, err := collectWeather(ctx, req.Config, req.Collector) if err != nil { return nil, err @@ -348,49 +345,35 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro item := batchReportResult(planned) if paths, err := store.Paths(resolved); err == nil { item.DataPackagePath = paths.DataPackage - item.PreparationPath = paths.Preflight + item.PreparationPath = paths.Preparation + item.ExecutionPath = paths.Execution item.ReportPath = paths.RenderedReport item.MetadataPath = paths.Metadata } outputPath := plannedBatchOutputPath(req.OutputDir, planned) - reportResult, err := generateLegacyBatchReport(ctx, ReportRequest{ - Config: req.Config, - Resolved: resolved, - OutputPath: outputPath, - Collection: *collection, - Renderer: req.Renderer, - Store: store, - Notifier: req.Notifier, - noNotify: true, + reportResult, err := generatePromptReport(ctx, promptReportRequest{ + GenerateRequest: GenerateRequest{ + Config: req.Config, + OutputPath: outputPath, + Notifier: req.Notifier, + Executor: req.Executor, + Store: store, + }, + Resolved: resolved, + Collection: *collection, + Inspection: inspections[resolved.Definition.ID], + DebugWriter: debugWriter, + noNotify: true, }) + if reportResult != nil { + copyBatchReportPaths(&item, reportResult) + } if err != nil { item.Status = "failed" item.Error = err.Error() - var notificationErr *NotificationError - if errors.As(err, ¬ificationErr) { - item.NotificationStatus = "failed" - item.NotificationError = notificationErr.Error() - item.NotificationPipelineID = notificationErr.Request.PipelineID - if paths, pathErr := store.Paths(resolved); pathErr == nil { - item.NotificationPath = paths.Notification - } - } result.Failed++ } else { item.Status = "succeeded" - item.DataPackagePath = reportResult.DataPackagePath - item.PreparationPath = reportResult.PreparationPath - item.ExecutionPath = reportResult.ExecutionPath - item.LLMDebugPath = reportResult.LLMDebugPath - item.ReportPath = reportResult.ReportPath - item.OutputPath = reportResult.OutputPath - item.MetadataPath = reportResult.MetadataPath - item.NotificationPath = reportResult.NotificationPath - if reportResult.Notification != nil { - item.NotificationStatus = reportResult.Notification.Status - item.NotificationRunID = reportResult.Notification.RunID - item.NotificationPipelineID = reportResult.Notification.PipelineID - } result.Succeeded++ } result.Reports = append(result.Reports, item) @@ -409,6 +392,51 @@ func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, erro return nil, fmt.Errorf("run is not implemented") } +func copyBatchReportPaths(item *BatchReportResult, result *ReportResult) { + item.DataPackagePath = result.DataPackagePath + item.PreparationPath = result.PreparationPath + item.ExecutionPath = result.ExecutionPath + item.LLMDebugPath = result.LLMDebugPath + item.ReportPath = result.ReportPath + item.OutputPath = result.OutputPath + item.MetadataPath = result.MetadataPath + item.NotificationPath = result.NotificationPath + if result.Notification != nil { + item.NotificationStatus = result.Notification.Status + item.NotificationRunID = result.Notification.RunID + item.NotificationPipelineID = result.Notification.PipelineID + } +} + +func batchInspectionCandidates(req BatchRequest, now time.Time) ([]report.Resolved, error) { + location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone) + if err != nil { + return nil, err + } + registry, err := reportRegistry(req.Config) + if err != nil { + return nil, err + } + ids := []report.ID{report.Tomorrow, report.Daily} + if req.Batch == BatchMorning { + ids = []report.ID{report.Today, report.Tomorrow, report.Daily} + } + date := timeutil.LocalDate(now, location).AddDate(0, 0, 2) + candidates := make([]report.Resolved, 0, len(ids)) + for _, id := range ids { + resolveReq := report.ResolveRequest{Now: now, Location: location} + if id == report.Daily { + resolveReq.Date = date + } + resolved, err := registry.Resolve(id, resolveReq) + if err != nil { + return nil, err + } + candidates = append(candidates, resolved) + } + return candidates, nil +} + func batchReportResult(planned plannedBatchReport) BatchReportResult { resolved := planned.Resolved metadata := resolved.Metadata() @@ -507,315 +535,6 @@ func FetchAndSaveBundle(ctx context.Context, req FetchBundleRequest) (*weatherda return bundle, nil } -// generateLegacyBatchReport is the temporary Scriptorium implementation used -// only by batch commands while their Promptkit migration is deferred. -func generateLegacyBatchReport(ctx context.Context, req ReportRequest) (*ReportResult, error) { - bundle := req.Collection.Bundle - if bundle == nil { - return nil, fmt.Errorf("collected weather bundle is required") - } - - store := req.Store - if store == nil { - defaultStore, err := defaultStore(req.Config) - if err != nil { - return nil, err - } - store = defaultStore - } - paths, err := store.Paths(req.Resolved) - if err != nil { - return nil, err - } - priorSnapshot, err := store.FindPriorSnapshot(ctx, req.Resolved) - if err != nil { - return nil, err - } - - reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{ - Config: req.Config, - Resolved: req.Resolved, - }, bundle) - if err != nil { - return nil, err - } - moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{ - Config: req.Config, - Resolved: req.Resolved, - }, reportFacts) - if err != nil { - return nil, err - } - - moduleSnapshotPath, err := store.SaveModuleSnapshot(ctx, req.Resolved, moduleSnapshot) - if err != nil { - return nil, err - } - - recentChanges, err := recentChanges(ctx, store, priorSnapshot, req.Resolved.Definition.ID, moduleSnapshot, req.Config.RecentChange) - if err != nil { - return nil, err - } - briefingMetadata := briefing.BuildMetadata(briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)) - metadata := state.BuildMetadataFromBriefingMetadata(req.Resolved, briefingMetadata, state.ArtifactPaths{ - ModuleSnapshot: moduleSnapshotPath, - Metadata: paths.Metadata, - DataPackage: paths.DataPackage, - Preflight: paths.Preflight, - RenderedReport: paths.RenderedReport, - GeneratedTextRaw: paths.GeneratedTextRaw, - GeneratedTextResult: paths.GeneratedTextResult, - GeneratedText: paths.GeneratedText, - RenderContext: paths.RenderContext, - }) - dataPackage, err := promptinput.Build(promptinput.BuildRequest{ - Metadata: promptMetadata(metadata), - Modules: moduleSnapshot, - RecentChanges: recentChanges, - }) - if err != nil { - return nil, err - } - dataPackagePath, err := store.SaveDataPackage(ctx, req.Resolved, dataPackage) - if err != nil { - return nil, err - } - metadata.DataPackagePath = dataPackagePath - - renderer := req.Renderer - if renderer == nil { - renderer = scriptorium.Runner{ - Binary: req.Config.Scriptorium.Binary, - ConfigPath: req.Config.Scriptorium.ConfigPath, - Profile: req.Config.Scriptorium.Profile, - Timeout: req.Config.Scriptorium.Timeout, - ExtraArgs: req.Config.Scriptorium.ExtraArgs, - } - } - renderResult, renderErr := renderer.Render(ctx, scriptorium.RenderRequest{ - PromptID: req.Resolved.Definition.PromptID, - DataPackagePath: dataPackagePath, - }) - - preflightPath := paths.Preflight - if renderResult != nil { - var err error - preflightPath, err = store.SavePreflight(ctx, req.Resolved, preflightArtifact(renderResult)) - if err != nil { - return nil, err - } - } - metadata.PreflightPath = preflightPath - metadataPath, metadataErr := store.SaveMetadata(ctx, metadata) - if metadataErr != nil { - return nil, metadataErr - } - if renderErr != nil { - return nil, generatedReportError(req.Resolved, metadata.RunID, "render preflight", renderErr) - } - - return generateTextTemplateReport(ctx, generatedReportRequest{ - ReportRequest: req, - store: store, - paths: paths, - moduleSnapshot: moduleSnapshot, - moduleSnapshotPath: moduleSnapshotPath, - reportFacts: reportFacts, - dataPackage: dataPackage, - dataPackagePath: dataPackagePath, - briefingMetadata: briefingMetadata, - metadata: metadata, - metadataPath: metadataPath, - preflightPath: preflightPath, - priorSnapshot: priorSnapshot, - recentChanges: recentChanges, - renderResult: renderResult, - renderer: renderer, - }) -} - -type generatedReportRequest struct { - ReportRequest - store state.Store - paths state.ArtifactPaths - moduleSnapshot module.Snapshot - moduleSnapshotPath string - reportFacts ReportFacts - dataPackage promptinput.Package - dataPackagePath string - briefingMetadata briefing.Metadata - metadata state.Metadata - metadataPath string - preflightPath string - priorSnapshot *state.PriorSnapshot - recentChanges []changes.Change - renderResult *scriptorium.RenderResult - renderer Renderer -} - -func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) (*ReportResult, error) { - handler, err := generatedtext.LookupDefinition(req.Resolved.Definition) - if err != nil { - return nil, generatedReportError(req.Resolved, req.metadata.RunID, "lookup generated text catalog", err) - } - - structuredResult, runErr := req.renderer.StructuredRun(ctx, scriptorium.StructuredRunRequest{ - PromptID: req.Resolved.Definition.PromptID, - DataPackagePath: req.dataPackagePath, - OutputPath: req.paths.GeneratedTextRaw, - }) - generatedTextResultPath := req.paths.GeneratedTextResult - if structuredResult != nil { - var err error - generatedTextResultPath, err = req.store.SaveGeneratedTextResult(ctx, req.Resolved, structuredResult) - if err != nil { - return nil, err - } - req.metadata.GeneratedTextResultPath = generatedTextResultPath - req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata) - if err != nil { - return nil, err - } - } - if runErr != nil { - return nil, generatedReportError(req.Resolved, req.metadata.RunID, "structured generated text", runErr) - } - - rawGeneratedText, err := req.store.LoadGeneratedText(ctx, req.paths.GeneratedTextRaw) - if err != nil { - return nil, generatedReportError(req.Resolved, req.metadata.RunID, "load raw generated text", err) - } - generatedText, normalizedGeneratedText, err := handler.Validate(rawGeneratedText) - if err != nil { - return nil, generatedReportError(req.Resolved, req.metadata.RunID, "validate generated text", err) - } - generatedTextPath, err := req.store.SaveGeneratedText(ctx, req.Resolved, normalizedGeneratedText) - if err != nil { - return nil, err - } - req.metadata.GeneratedTextPath = generatedTextPath - req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata) - if err != nil { - return nil, err - } - - renderContext, err := handler.BuildRenderContext(req.briefingMetadata, req.moduleSnapshot, req.reportFacts.Collected, req.reportFacts.Derived, generatedText) - if err != nil { - return nil, generatedReportError(req.Resolved, req.metadata.RunID, "build render context", err) - } - renderContextPath, err := req.store.SaveRenderContext(ctx, req.Resolved, renderContext) - if err != nil { - return nil, err - } - req.metadata.RenderContextPath = renderContextPath - req.metadataPath, err = req.store.SaveMetadata(ctx, req.metadata) - if err != nil { - return nil, err - } - - rendered, err := handler.Render(renderContext) - if err != nil { - return nil, generatedReportError(req.Resolved, req.metadata.RunID, "render template", err) - } - reportPath, err := req.store.PrepareRenderedReport(ctx, req.Resolved) - if err != nil { - return nil, err - } - if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil { - return nil, err - } - finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{ - Config: req.Config, - Store: req.store, - Resolved: req.Resolved, - Metadata: req.metadata, - ManagedReportPath: reportPath, - OutputPath: req.OutputPath, - Notifier: req.Notifier, - noNotify: req.noNotify, - }) - if err != nil { - if finalizeResultEmpty(finalized) { - return nil, err - } - return renderedReportResult(reportResultRequest{ - moduleSnapshot: req.moduleSnapshot, - moduleSnapshotPath: req.moduleSnapshotPath, - dataPackage: req.dataPackage, - dataPackagePath: req.dataPackagePath, - preflightPath: req.preflightPath, - reportPath: reportPath, - finalized: finalized, - priorSnapshot: req.priorSnapshot, - recentChanges: req.recentChanges, - generatedTextRawPath: req.paths.GeneratedTextRaw, - generatedTextPath: generatedTextPath, - renderContextPath: renderContextPath, - }), err - } - - return renderedReportResult(reportResultRequest{ - moduleSnapshot: req.moduleSnapshot, - moduleSnapshotPath: req.moduleSnapshotPath, - dataPackage: req.dataPackage, - dataPackagePath: req.dataPackagePath, - preflightPath: req.preflightPath, - reportPath: reportPath, - finalized: finalized, - priorSnapshot: req.priorSnapshot, - recentChanges: req.recentChanges, - generatedTextRawPath: req.paths.GeneratedTextRaw, - generatedTextPath: generatedTextPath, - renderContextPath: renderContextPath, - }), nil -} - -func finalizeResultEmpty(result finalizeRenderedReportResult) bool { - return result.OutputPath == "" && - result.NotificationPath == "" && - result.MetadataPath == "" && - result.Metadata.RunID == "" && - result.Notification == nil -} - -type reportResultRequest struct { - moduleSnapshot module.Snapshot - moduleSnapshotPath string - dataPackage promptinput.Package - dataPackagePath string - preflightPath string - reportPath string - finalized finalizeRenderedReportResult - priorSnapshot *state.PriorSnapshot - recentChanges []changes.Change - generatedTextRawPath string - generatedTextPath string - renderContextPath string -} - -func renderedReportResult(req reportResultRequest) *ReportResult { - return &ReportResult{ - ModuleSnapshot: req.moduleSnapshot, - ModuleSnapshotPath: req.moduleSnapshotPath, - DataPackage: req.dataPackage, - DataPackagePath: req.dataPackagePath, - PreparationPath: req.preflightPath, - ExecutionPath: req.finalized.Metadata.GeneratedTextResultPath, - PreflightPath: req.preflightPath, - ReportPath: req.reportPath, - OutputPath: req.finalized.OutputPath, - NotificationPath: req.finalized.NotificationPath, - Metadata: req.finalized.Metadata, - MetadataPath: req.finalized.MetadataPath, - PriorSnapshot: req.priorSnapshot, - RecentChanges: req.recentChanges, - GeneratedTextRawPath: req.generatedTextRawPath, - GeneratedTextPath: req.generatedTextPath, - RenderContextPath: req.renderContextPath, - Notification: req.finalized.Notification, - } -} - type finalizeRenderedReportRequest struct { Config config.Config Store state.Store @@ -1292,20 +1011,6 @@ func recentChanges(ctx context.Context, store state.Store, priorSnapshot *state. } } -func preflightArtifact(result *scriptorium.RenderResult) state.PreflightArtifact { - if result == nil { - return state.PreflightArtifact{} - } - return state.PreflightArtifact{ - Command: append([]string(nil), result.Command...), - Stdout: result.Stdout, - Stderr: result.Stderr, - StdoutTruncated: result.StdoutTruncated, - StderrTruncated: result.StderrTruncated, - ExitCode: result.ExitCode, - } -} - func generatedReportError(resolved report.Resolved, runID string, operation string, err error) error { if err == nil { return nil diff --git a/internal/app/app_test.go b/internal/app/app_test.go deleted file mode 100644 index 2aed3d8..0000000 --- a/internal/app/app_test.go +++ /dev/null @@ -1,4149 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" - "gitea.maximumdirect.net/eric/weatherreporter/internal/config" - "gitea.maximumdirect.net/eric/weatherreporter/internal/module" - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" - "gitea.maximumdirect.net/eric/weatherreporter/internal/state" - "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" -) - -func TestFetchAndSaveBundle(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/observations": - _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) - case "/conditions/current": - _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) - case "/forecast/hourly": - _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00"}]}}`)) - case "/forecast/narrative": - _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[]}}`)) - case "/alerts/active": - _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) - case "/discussion": - _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":[],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for saved bundle."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for saved bundle."}}}`)) - case "/weatherstories/latest": - _, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`)) - case "/outlooks/convective": - _, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - cfg := config.Defaults() - cfg.WeatherAPI.BaseURL = server.URL + "/" - path := filepath.Join(t.TempDir(), "bundle.json") - - bundle, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: cfg, OutputPath: path}) - if err != nil { - t.Fatalf("FetchAndSaveBundle() error = %v", err) - } - if bundle.Hourly == nil { - t.Fatal("Hourly = nil, want fetched bundle") - } - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read saved bundle: %v", err) - } - if !strings.Contains(string(data), `"product": "hourly"`) { - t.Fatalf("saved bundle missing hourly product:\n%s", string(data)) - } - if !strings.Contains(string(data), `"title": "Several Chances for Rain Through Monday"`) { - t.Fatalf("saved bundle missing weather story title:\n%s", string(data)) - } -} - -type promptExecutorTest struct { - err error - afterPreparationErr error - validation promptexec.ValidationStatus - preparationDebug *promptexec.PreparationDebug - executionDebug *promptexec.ExecutionDebug - captureDebug *bool - providerCalled *bool -} - -func (e promptExecutorTest) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) { - name := strings.TrimSuffix(strings.TrimPrefix(id, "weather."), "_generated_text") - return promptexec.PromptInspection{ - PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", DefaultProfileID: "test-profile", - Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}}, - Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: name + ".generated_text.schema.json"}, - }, nil -} - -func (e promptExecutorTest) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) { - return promptexec.ProfileInspection{ProfileID: id, BackendID: "test", ModelName: "test-model"}, nil -} - -func (e promptExecutorTest) Execute(_ context.Context, request promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) { - if e.captureDebug != nil { - *e.captureDebug = request.CaptureDebug - } - if e.err != nil { - return nil, e.err - } - now := time.Now().UTC() - if err := callback(promptexec.Preparation{ - PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", - ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", DataPackagePath: request.DataPackagePath, - StartedAt: now, EndedAt: now, - }, e.preparationDebug); err != nil { - return nil, err - } - if e.providerCalled != nil { - *e.providerCalled = true - } - if e.afterPreparationErr != nil { - return nil, e.afterPreparationErr - } - raw := []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}`) - if request.PromptID == "weather.hourly_generated_text" { - raw = []byte(`{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}`) - } - validation := e.validation - if validation == "" { - validation = promptexec.ValidationPassed - } - return &promptexec.Execution{ - RunID: "provider-run", PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", - ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash", - StartedAt: now, EndedAt: now, DataPackagePath: request.DataPackagePath, RawOutput: raw, - Validation: promptexec.NewValidation(validation, "json_schema", "generated_text.schema.json", nil), Debug: e.executionDebug, - }, nil -} - -func TestFetchAndSaveBundleRequiresOutputPath(t *testing.T) { - _, err := FetchAndSaveBundle(context.Background(), FetchBundleRequest{Config: config.Defaults()}) - if err == nil { - t.Fatal("FetchAndSaveBundle() error = nil, want output path error") - } - if !strings.Contains(err.Error(), "output path") { - t.Fatalf("error = %q, want output path context", err.Error()) - } -} - -func TestGenerateUsesProvidedCollector(t *testing.T) { - cfg := config.Defaults() - cfg.WeatherAPI.BaseURL = "" - cfg.Workspace.Root = t.TempDir() - collector := &recordingCollector{err: errors.New("provided collector failed")} - - err := Generate(context.Background(), GenerateRequest{ - Config: cfg, - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - Now: mustParse("2026-05-29T05:00:00-05:00"), - Collector: collector, - Executor: promptExecutorTest{}, - }) - if err == nil { - t.Fatal("Generate() error = nil, want collector error") - } - if !strings.Contains(err.Error(), "provided collector failed") { - t.Fatalf("Generate() error = %q, want provided collector error", err.Error()) - } - if len(collector.requests) != 1 { - t.Fatalf("collector requests = %d, want 1", len(collector.requests)) - } - if collector.requests[0].Config.WeatherAPI.BaseURL != "" { - t.Fatalf("collector base URL = %q, want request config", collector.requests[0].Config.WeatherAPI.BaseURL) - } -} - -func TestGenerateCollectsOnceForSingleReport(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - collection := collectionForTest(t, cfg) - collector := &recordingCollector{result: &collection} - err := Generate(context.Background(), GenerateRequest{ - Config: cfg, - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - Now: mustParse("2026-05-29T05:00:00-05:00"), - Collector: collector, - Executor: promptExecutorTest{err: errors.New("prompt execution failed")}, - }) - if err == nil { - t.Fatal("Generate() error = nil, want render error") - } - if len(collector.requests) != 1 { - t.Fatalf("collector requests = %d, want 1", len(collector.requests)) - } -} - -func TestGenerateCollectionFailureStopsBeforeReportExecution(t *testing.T) { - cfg := config.Defaults() - cfg.WeatherAPI.BaseURL = "" - cfg.Workspace.Root = t.TempDir() - collector := &recordingCollector{err: errors.New("collection unavailable")} - - err := Generate(context.Background(), GenerateRequest{ - Config: cfg, - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - Now: mustParse("2026-05-29T05:00:00-05:00"), - Collector: collector, - Executor: promptExecutorTest{}, - }) - if err == nil { - t.Fatal("Generate() error = nil, want collector error") - } -} - -func TestGenerateDetailedReturnsReportResult(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - cfg.Scriptorium.Binary = fakeScriptoriumBinary(t) - collection := collectionForTest(t, cfg) - collector := &recordingCollector{result: &collection} - outputPath := filepath.Join(t.TempDir(), "daily.md") - - result, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, - Report: ReportDaily, - OutputPath: outputPath, - Date: mustParse("2026-05-29T12:00:00-05:00"), - Now: mustParse("2026-05-29T05:00:00-05:00"), - Collector: collector, - Executor: promptExecutorTest{}, - }) - if err != nil { - t.Fatalf("GenerateDetailed() error = %v", err) - } - if result == nil { - t.Fatal("GenerateDetailed() result = nil, want report result") - } - if result.Metadata.ReportID != report.Daily || result.Metadata.RunID == "" { - t.Fatalf("metadata = %#v, want daily report metadata with run id", result.Metadata) - } - if result.OutputPath != outputPath { - t.Fatalf("OutputPath = %q, want requested output copy %q", result.OutputPath, outputPath) - } - assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreparationPath, result.ExecutionPath, result.ReportPath, result.MetadataPath, outputPath) - if len(collector.requests) != 1 { - t.Fatalf("collector requests = %d, want one collection", len(collector.requests)) - } -} - -func TestGenerateReturnsUnderlyingErrorOnly(t *testing.T) { - cfg := config.Defaults() - cfg.WeatherAPI.BaseURL = "" - cfg.Workspace.Root = t.TempDir() - wantErr := errors.New("collector unavailable") - - err := Generate(context.Background(), GenerateRequest{ - Config: cfg, - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - Now: mustParse("2026-05-29T05:00:00-05:00"), - Collector: &recordingCollector{err: wantErr}, - Executor: promptExecutorTest{}, - }) - if !errors.Is(err, wantErr) { - t.Fatalf("Generate() error = %v, want underlying collector error", err) - } -} - -func TestGenerateDetailedNotificationFailureReturnsInspectableResult(t *testing.T) { - server := hourlyBundleServer(t) - cfg := hourlyGeneratedTextConfig(t, server) - cfg.Scriptorium.Binary = fakeScriptoriumBinary(t) - collection := collectionForTest(t, cfg) - notifier := &recordingNotifier{err: errors.New("upload rejected")} - outputPath := filepath.Join(t.TempDir(), "hourly.md") - - result, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, - Report: ReportHourly, - OutputPath: outputPath, - Now: mustParse("2026-05-29T08:30:00-05:00"), - Collector: &recordingCollector{result: &collection}, - Notifier: notifier, - Executor: promptExecutorTest{}, - }) - if err == nil { - t.Fatal("GenerateDetailed() error = nil, want notification error") - } - var notificationErr *NotificationError - if !errors.As(err, ¬ificationErr) { - t.Fatalf("GenerateDetailed() error = %T %v, want NotificationError", err, err) - } - if result == nil { - t.Fatal("GenerateDetailed() result = nil, want inspectable result on notification failure") - } - if result.Metadata.ReportID != report.Hourly || result.Metadata.NotificationPath != result.NotificationPath { - t.Fatalf("metadata = %#v notificationPath=%q, want hourly notification artifact link", result.Metadata, result.NotificationPath) - } - if result.NotificationPath == "" || result.ReportPath == "" || result.MetadataPath == "" { - t.Fatalf("result paths = report %q metadata %q notification %q, want inspectable artifact paths", result.ReportPath, result.MetadataPath, result.NotificationPath) - } - assertPathsExist(t, result.ReportPath, outputPath, result.MetadataPath, result.NotificationPath, result.GeneratedTextRawPath, result.GeneratedTextPath, result.RenderContextPath) - if result.Notification != nil { - t.Fatalf("Notification = %#v, want nil notification result when notifier returned only an error", result.Notification) - } - if len(notifier.requests) != 1 { - t.Fatalf("notification requests = %d, want one attempted notification", len(notifier.requests)) - } -} - -func TestGenerateDetailedInspectsBeforeCollectionOrArtifactWrites(t *testing.T) { - cfg := config.Defaults() - cfg.Workspace.Root = t.TempDir() - collector := &recordingCollector{err: errors.New("collector must not run")} - _, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), - Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: collector, Executor: failingPromptInspectionExecutor{}, - }) - if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration { - t.Fatalf("GenerateDetailed() error/category = %v/%q, want invalid configuration", err, promptexec.CategoryOf(err)) - } - if len(collector.requests) != 0 { - t.Fatalf("collector requests = %d, want inspection failure before collection", len(collector.requests)) - } - entries, readErr := os.ReadDir(cfg.Workspace.Root) - if readErr != nil || len(entries) != 0 { - t.Fatalf("workspace entries = %#v, err %v, want none", entries, readErr) - } -} - -func TestGenerateDetailedRejectsInvalidDebugRootBeforeCollection(t *testing.T) { - cfg := config.Defaults() - cfg.Workspace.Root = t.TempDir() - collector := &recordingCollector{err: errors.New("collector must not run")} - _, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), - Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: collector, LLMDebugDir: "relative-debug", - }) - if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration || strings.Contains(err.Error(), "relative-debug") { - t.Fatalf("GenerateDetailed() error/category = %v/%q, want safe debug-root validation", err, promptexec.CategoryOf(err)) - } - if len(collector.requests) != 0 { - t.Fatalf("collector requests = %d, want debug initialization before collection", len(collector.requests)) - } -} - -func TestGenerateDetailedPersistsPromptFailureReceiptsWithoutRawOutput(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - collection := collectionForTest(t, cfg) - result, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), - Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: &recordingCollector{result: &collection}, - Executor: promptExecutorTest{afterPreparationErr: promptexec.NewError(promptexec.Generation, "provider unavailable", nil)}, - }) - if err == nil || result == nil { - t.Fatalf("GenerateDetailed() result/error = %#v/%v, want partial result and error", result, err) - } - if result.PreparationPath == "" || result.ExecutionPath == "" || result.MetadataPath == "" || result.GeneratedTextRawPath != "" { - t.Fatalf("result paths = %#v, want preparation/execution/metadata without raw output", result) - } - if result.Metadata.SchemaVersion != state.MetadataSchemaVersion || result.Metadata.PreparationPath != result.PreparationPath || result.Metadata.ExecutionPath != result.ExecutionPath { - t.Fatalf("metadata = %#v, want linked V2 prompt artifacts", result.Metadata) - } - store := recordingFilesystemStore(t, cfg) - execution, loadErr := store.LoadPromptExecution(context.Background(), result.ExecutionPath) - if loadErr != nil || execution.Status != state.PromptExecutionFailed || execution.Paths.RawOutputPath != "" { - t.Fatalf("execution/load error = %#v/%v, want failed receipt without raw path", execution, loadErr) - } -} - -func TestGenerateDetailedPersistsRawOutputForValidationRejection(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - collection := collectionForTest(t, cfg) - result, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), - Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: &recordingCollector{result: &collection}, - Executor: promptExecutorTest{validation: promptexec.ValidationFailed}, - }) - if err == nil || result == nil || promptexec.CategoryOf(err) != promptexec.ValidationRejected { - t.Fatalf("GenerateDetailed() result/error/category = %#v/%v/%q, want validation rejection", result, err, promptexec.CategoryOf(err)) - } - if result.GeneratedTextRawPath == "" || result.ExecutionPath == "" || result.GeneratedTextPath != "" { - t.Fatalf("result paths = %#v, want raw and execution paths only", result) - } - store := recordingFilesystemStore(t, cfg) - execution, loadErr := store.LoadPromptExecution(context.Background(), result.ExecutionPath) - if loadErr != nil || execution.Status != state.PromptExecutionValidationRejected || execution.Paths.RawOutputPath != result.GeneratedTextRawPath { - t.Fatalf("execution/load error = %#v/%v, want rejected execution with raw path", execution, loadErr) - } -} - -func TestGenerateDetailedWritesRequestedPromptDebugOutsideWorkspace(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - collection := collectionForTest(t, cfg) - debugRoot := filepath.Join(t.TempDir(), "prompt-debug") - captureDebug := false - result, err := GenerateDetailed(context.Background(), GenerateRequest{ - Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), - Now: mustParse("2026-05-29T05:00:00-05:00"), Collector: &recordingCollector{result: &collection}, LLMDebugDir: debugRoot, - Executor: promptExecutorTest{ - captureDebug: &captureDebug, - preparationDebug: &promptexec.PreparationDebug{ - RenderedMessages: []promptexec.RenderedMessage{{Role: "system", Content: "sensitive rendered prompt"}}, - ParametersJSON: []byte(`{"api_key":"secret-value"}`), - }, - executionDebug: &promptexec.ExecutionDebug{ValidationDiagnostics: []string{"provider validation detail"}}, - }, - }) - if err != nil { - t.Fatalf("GenerateDetailed() error = %v", err) - } - if !captureDebug || result.LLMDebugPath == "" || !strings.HasPrefix(result.LLMDebugPath, debugRoot+string(filepath.Separator)) { - t.Fatalf("capture/debug path = %t/%q, want requested isolated debug capture", captureDebug, result.LLMDebugPath) - } - preparationData, readErr := os.ReadFile(filepath.Join(result.LLMDebugPath, "preparation.json")) - if readErr != nil { - t.Fatalf("read preparation debug: %v", readErr) - } - executionData, readErr := os.ReadFile(filepath.Join(result.LLMDebugPath, "execution.json")) - if readErr != nil { - t.Fatalf("read execution debug: %v", readErr) - } - if !strings.Contains(string(preparationData), "sensitive rendered prompt") || strings.Contains(string(preparationData), "secret-value") || !strings.Contains(string(executionData), "provider validation detail") { - t.Fatalf("debug artifacts did not retain/redact expected content:\n%s\n%s", preparationData, executionData) - } - metadataData, readErr := os.ReadFile(result.MetadataPath) - if readErr != nil { - t.Fatalf("read metadata: %v", readErr) - } - if strings.Contains(string(metadataData), "sensitive rendered prompt") || strings.Contains(string(metadataData), "provider validation detail") { - t.Fatalf("normal metadata contains debug content:\n%s", metadataData) - } -} - -func TestGenerateDetailedDebugWriteFailureStopsProviderExecution(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - collection := collectionForTest(t, cfg) - now := mustParse("2026-05-29T05:00:00-05:00") - request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), Now: now} - resolved := resolveGenerateForTest(t, cfg, request, now.Format(time.RFC3339)) - debugRoot := filepath.Join(t.TempDir(), "prompt-debug") - debugPath := filepath.Join(debugRoot, string(resolved.Definition.ID), resolved.ValidPeriod.Start.Format("2006-01-02"), resolved.Metadata().RunID) - if err := os.MkdirAll(filepath.Dir(debugPath), 0o700); err != nil { - t.Fatalf("create debug parent: %v", err) - } - if err := os.WriteFile(debugPath, []byte("not a directory"), 0o600); err != nil { - t.Fatalf("create debug collision: %v", err) - } - providerCalled := false - request.Collector = &recordingCollector{result: &collection} - request.LLMDebugDir = debugRoot - request.Executor = promptExecutorTest{providerCalled: &providerCalled} - result, err := GenerateDetailed(context.Background(), request) - if err == nil || result == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration { - t.Fatalf("GenerateDetailed() result/error = %#v/%v, want partial result and debug error", result, err) - } - if providerCalled || result.Metadata.RunID == "" || result.PreparationPath == "" || result.ExecutionPath != "" || result.LLMDebugPath != "" { - t.Fatalf("provider/run/preparation/execution/debug = %t/%q/%q/%q/%q, want preparation only before provider", providerCalled, result.Metadata.RunID, result.PreparationPath, result.ExecutionPath, result.LLMDebugPath) - } -} - -func TestGenerateDetailedExecutionDebugFailureRetainsPreparationCapture(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - collection := collectionForTest(t, cfg) - now := mustParse("2026-05-29T05:00:00-05:00") - request := GenerateRequest{Config: cfg, Report: ReportDaily, Date: mustParse("2026-05-29T12:00:00-05:00"), Now: now} - resolved := resolveGenerateForTest(t, cfg, request, now.Format(time.RFC3339)) - debugRoot := filepath.Join(t.TempDir(), "prompt-debug") - executionDebugPath := filepath.Join(debugRoot, string(resolved.Definition.ID), resolved.ValidPeriod.Start.Format("2006-01-02"), resolved.Metadata().RunID, "execution.json") - if err := os.MkdirAll(executionDebugPath, 0o700); err != nil { - t.Fatalf("create execution debug collision: %v", err) - } - providerCalled := false - request.Collector = &recordingCollector{result: &collection} - request.LLMDebugDir = debugRoot - request.Executor = promptExecutorTest{providerCalled: &providerCalled} - result, err := GenerateDetailed(context.Background(), request) - if err == nil || result == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration { - t.Fatalf("GenerateDetailed() result/error = %#v/%v, want partial result and debug error", result, err) - } - if !providerCalled || result.LLMDebugPath == "" || result.ExecutionPath != "" || result.GeneratedTextRawPath != "" { - t.Fatalf("provider/debug/execution/raw = %t/%q/%q/%q, want preparation debug only after completed execution", providerCalled, result.LLMDebugPath, result.ExecutionPath, result.GeneratedTextRawPath) - } - if _, statErr := os.Stat(filepath.Join(result.LLMDebugPath, "preparation.json")); statErr != nil { - t.Fatalf("preparation debug artifact: %v", statErr) - } -} - -type failingPromptInspectionExecutor struct{} - -func (failingPromptInspectionExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) { - return promptexec.PromptInspection{}, errors.New("unavailable") -} - -func (failingPromptInspectionExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) { - return promptexec.ProfileInspection{}, errors.New("unexpected") -} - -func (failingPromptInspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) { - return nil, errors.New("unexpected") -} - -func TestGenerateReportWritesReportAndPreflight(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ - Command: []string{"scriptorium", "render"}, - Stdout: `{"prepared":true}`, - ExitCode: 0, - }, - structuredRunResult: &scriptorium.StructuredRunResult{ - Command: []string{"scriptorium", "run"}, - Stderr: "wrote generated text", - ExitCode: 0, - }, - } - outputPath := filepath.Join(t.TempDir(), "daily.md") - store := recordingFilesystemStore(t, cfg) - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - OutputPath: outputPath, - Renderer: renderer, - Store: store, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - - if renderer.renderCalls != 1 { - t.Fatalf("render calls = %d, want 1", renderer.renderCalls) - } - if renderer.structuredRunCalls != 1 { - t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls) - } - if renderer.renderRequest.PromptID != "weather.daily_generated_text" { - t.Fatalf("render PromptID = %q, want weather.daily_generated_text", renderer.renderRequest.PromptID) - } - if renderer.renderRequest.DataPackagePath != result.DataPackagePath { - t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath) - } - if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath { - t.Fatalf("structured run DataPackagePath = %q, want managed path %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath) - } - if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath { - t.Fatalf("structured run OutputPath = %q, want raw generated text path %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath) - } - if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; !strings.HasPrefix(got, want) { - t.Fatalf("store calls = %v, want prefix %s", store.calls, want) - } - assertPathsExist(t, result.ModuleSnapshotPath, result.DataPackagePath, result.PreflightPath, result.GeneratedTextRawPath, result.Metadata.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath, result.MetadataPath, outputPath) - snapshotData, err := os.ReadFile(result.ModuleSnapshotPath) - if err != nil { - t.Fatalf("read module snapshot: %v", err) - } - if !strings.Contains(string(snapshotData), module.SnapshotSchemaVersion) || !strings.Contains(string(snapshotData), `"metadata"`) || !strings.Contains(string(snapshotData), `"derived_daily_summary"`) { - t.Fatalf("module snapshot missing expected stanzas:\n%s", string(snapshotData)) - } - for _, want := range []string{`"condition_text_lower"`, `"hour_label"`, `"text_description_lower"`, `"mention_precipitation"`} { - if !strings.Contains(string(snapshotData), want) { - t.Fatalf("module snapshot missing rich helper field %q:\n%s", want, string(snapshotData)) - } - } - for _, want := range []string{`"temperature_phrase_f"`, `"dominant_condition_lower"`, `"dominant_condition_display"`, `"max_pop_time_label"`} { - if !strings.Contains(string(snapshotData), want) { - t.Fatalf("module snapshot missing rich daypart helper field %q:\n%s", want, string(snapshotData)) - } - } - data, err := os.ReadFile(result.DataPackagePath) - if err != nil { - t.Fatalf("read data package: %v", err) - } - if !strings.HasPrefix(filepath.Base(result.DataPackagePath), "data_package.") || !strings.HasSuffix(result.DataPackagePath, ".yaml") { - t.Fatalf("DataPackagePath = %q, want YAML data package path", result.DataPackagePath) - } - if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") || - !strings.Contains(string(data), "recent_changes:") || - !strings.Contains(string(data), "applicable_risk_products:") || - !strings.Contains(string(data), "derived_summaries:") || - !strings.Contains(string(data), "narrative_products:") || - !strings.Contains(string(data), "raw_data:") || - !strings.Contains(string(data), "current_conditions:") || - !strings.Contains(string(data), "narrative_forecast:") || - !strings.Contains(string(data), "hourly_forecast:") || - !strings.Contains(string(data), "area_forecast_discussion:") || - !strings.Contains(string(data), "spc_convective_outlooks:") { - t.Fatalf("data package missing expected content:\n%s", string(data)) - } - if strings.Contains(string(data), "spc_convective_discussion:") { - t.Fatalf("data package has SPC convective discussion, want omitted for empty checked source:\n%s", string(data)) - } - if strings.Contains(string(data), "source_warnings:") { - t.Fatalf("data package has source warnings, want none for complete fetched sources:\n%s", string(data)) - } - riskIndex := strings.Index(string(data), " applicable_risk_products:") - derivedIndex := strings.Index(string(data), " derived_summaries:") - narrativeIndex := strings.Index(string(data), " narrative_products:") - rawIndex := strings.Index(string(data), " raw_data:") - alertIndex := strings.Index(string(data), " alert_digest:") - summaryIndex := strings.Index(string(data), " derived_daily_summary:") - storyIndex := strings.Index(string(data), " weather_story:") - currentIndex := strings.Index(string(data), " current_conditions:") - hourlyIndex := strings.Index(string(data), " hourly_forecast:") - outlookIndex := strings.Index(string(data), " spc_convective_outlooks:") - if riskIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || rawIndex < 0 || alertIndex < 0 || outlookIndex < 0 || summaryIndex < 0 || storyIndex < 0 || currentIndex < 0 || hourlyIndex < 0 || - !(riskIndex < derivedIndex && derivedIndex < narrativeIndex && narrativeIndex < rawIndex) || - !(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex && derivedIndex < summaryIndex && narrativeIndex < storyIndex && rawIndex < currentIndex && currentIndex < hourlyIndex) { - t.Fatalf("data package grouping is wrong, want categorized prompt stanzas:\n%s", string(data)) - } - savedDataPackage, err := promptinput.LoadYAML(data) - if err != nil { - t.Fatalf("decode data package: %v", err) - } - if savedDataPackage.Report.CurrentLocalDate != "2026-05-29" { - t.Fatalf("data package currentLocalDate = %q, want 2026-05-29", savedDataPackage.Report.CurrentLocalDate) - } - metadataStanza, ok := savedDataPackage.Briefing.Values["metadata"].(map[string]any) - if !ok { - t.Fatalf("data package metadata stanza = %#v, want metadata map", savedDataPackage.Briefing.Values["metadata"]) - } - if _, ok := metadataStanza["alerts"]; ok { - t.Fatalf("data package metadata contains alerts, want alert details only in alert_digest: %#v", metadataStanza) - } - assertNoStaleModuleIntervalKeys(t, savedDataPackage.Briefing.Values) - spcOutlooks, ok := savedDataPackage.Briefing.Values["spc_convective_outlooks"].(map[string]any) - if !ok || spcOutlooks["checked"] != true || spcOutlooks["outlook_count"] != 0 { - t.Fatalf("data package SPC convective outlooks = %#v, want checked empty source", savedDataPackage.Briefing.Values["spc_convective_outlooks"]) - } - current, ok := savedDataPackage.Briefing.Values["current_conditions"].(map[string]any) - if !ok || current["condition_text"] != "Clear" { - t.Fatalf("data package current conditions = %#v, want current conditions", savedDataPackage.Briefing.Values["current_conditions"]) - } - for _, omitted := range []string{"condition_text_lower", "wind_direction_text"} { - if _, ok := current[omitted]; ok { - t.Fatalf("data package current conditions contains helper field %q: %#v", omitted, current) - } - } - narrative, ok := savedDataPackage.Briefing.Values["narrative_forecast"].(map[string]any) - if !ok || narrative["product"] != "narrative" || !strings.Contains(string(data), "Morning storms, then partly sunny.") { - t.Fatalf("data package narrative forecast = %#v, want narrative forecast", savedDataPackage.Briefing.Values["narrative_forecast"]) - } - hourly, ok := savedDataPackage.Briefing.Values["hourly_forecast"].(map[string]any) - if !ok || hourly["product"] != "hourly" || !strings.Contains(string(data), "Showers and thunderstorms") { - t.Fatalf("data package hourly forecast = %#v, want hourly forecast", savedDataPackage.Briefing.Values["hourly_forecast"]) - } - periods, ok := hourly["periods"].([]any) - if !ok || len(periods) == 0 { - t.Fatalf("data package hourly periods = %#v, want prompt period rows", hourly["periods"]) - } - firstPeriod, ok := periods[0].(map[string]any) - if !ok { - t.Fatalf("data package hourly first period = %#v, want mapping", periods[0]) - } - for _, omitted := range []string{"hour_label", "text_description_lower", "mention_precipitation"} { - if _, ok := firstPeriod[omitted]; ok { - t.Fatalf("data package hourly period contains helper field %q: %#v", omitted, firstPeriod) - } - } - dayparts, ok := savedDataPackage.Briefing.Values["derived_daypart_summaries"].(map[string]any) - if !ok { - t.Fatalf("data package daypart summaries = %#v, want daypart map", savedDataPackage.Briefing.Values["derived_daypart_summaries"]) - } - morning, ok := dayparts["morning"].(map[string]any) - if !ok { - t.Fatalf("data package morning daypart = %#v, want daypart map", dayparts["morning"]) - } - if morning["max_pop_time"] != "6:00 AM" { - t.Fatalf("data package morning max_pop_time = %#v, want friendly label", morning["max_pop_time"]) - } - for _, omitted := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} { - if _, ok := morning[omitted]; ok { - t.Fatalf("data package morning daypart contains helper field %q: %#v", omitted, morning) - } - } - story, ok := savedDataPackage.Briefing.Values["weather_story"].(map[string]any) - if !ok || story["title"] != "Several Chances for Rain Through Monday" { - t.Fatalf("data package weather story = %#v, want weather story title", savedDataPackage.Briefing.Values["weather_story"]) - } - if !strings.Contains(string(data), "Long-term AFD narrative for generated report.") { - t.Fatalf("data package missing daily long-term AFD discussion:\n%s", string(data)) - } - for _, omitted := range []string{"Short-term AFD narrative for generated report.", "Storms are most likely during the morning."} { - if strings.Contains(string(data), omitted) { - t.Fatalf("data package contains daily omitted AFD field %q:\n%s", omitted, string(data)) - } - } - preflight, err := os.ReadFile(result.PreflightPath) - if err != nil { - t.Fatalf("read preflight: %v", err) - } - if !strings.Contains(string(preflight), `prepared`) { - t.Fatalf("preflight output missing render stdout:\n%s", string(preflight)) - } - if result.Metadata.RunID != resolved.Metadata().RunID { - t.Fatalf("metadata RunID = %q, want %q", result.Metadata.RunID, resolved.Metadata().RunID) - } - if result.Metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || result.Metadata.DataPackagePath != result.DataPackagePath { - t.Fatalf("metadata does not link artifact paths: %#v", result.Metadata) - } - if result.Metadata.RenderedReportPath != result.ReportPath { - t.Fatalf("metadata rendered report path = %q, want %q", result.Metadata.RenderedReportPath, result.ReportPath) - } - if len(result.RecentChanges) != 0 { - t.Fatalf("RecentChanges = %#v, want none without prior snapshot", result.RecentChanges) - } - report, err := os.ReadFile(outputPath) - if err != nil { - t.Fatalf("read report output: %v", err) - } - if !strings.Contains(string(report), "# Friday's Weather") { - t.Fatalf("report output missing markdown:\n%s", string(report)) - } -} - -func TestGeneratedTemplateReportsUseRichArtifactsAndCuratedDataPackages(t *testing.T) { - server := dailyBundleServer(t) - tests := []struct { - name string - kind ReportKind - date time.Time - now time.Time - prompt string - }{ - { - name: "today", - kind: ReportToday, - date: mustParse("2026-05-29T12:00:00-05:00"), - now: mustParse("2026-05-29T05:00:00-05:00"), - prompt: "weather.today_generated_text", - }, - { - name: "tomorrow", - kind: ReportTomorrow, - now: mustParse("2026-05-29T18:00:00-05:00"), - prompt: "weather.tomorrow_generated_text", - }, - { - name: "daily", - kind: ReportDaily, - date: mustParse("2026-05-29T12:00:00-05:00"), - now: mustParse("2026-05-29T05:00:00-05:00"), - prompt: "weather.daily_generated_text", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := dailyWorkspaceConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: tt.kind, - Date: tt.date, - }, tt.now.Format(time.RFC3339)) - renderer := successfulGeneratedTextRenderer("") - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: renderer, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 { - t.Fatalf("renderer calls render=%d structured=%d, want generated-template workflow", renderer.renderCalls, renderer.structuredRunCalls) - } - if renderer.renderRequest.PromptID != tt.prompt || renderer.structuredRunRequest.PromptID != tt.prompt { - t.Fatalf("prompt IDs render=%q structured=%q, want %q", renderer.renderRequest.PromptID, renderer.structuredRunRequest.PromptID, tt.prompt) - } - if renderer.renderRequest.DataPackagePath != result.DataPackagePath { - t.Fatalf("render DataPackagePath = %q, want managed path %q", renderer.renderRequest.DataPackagePath, result.DataPackagePath) - } - if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath { - t.Fatalf("structured run DataPackagePath = %q, want managed path %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath) - } - assertRichPromptHelperArtifacts(t, result) - assertCuratedPromptDataPackage(t, result) - }) - } -} - -func TestGenerateReportIncludesSPCConvectivePromptStanzas(t *testing.T) { - server := dailyBundleServerWithConvectiveResponse(t, qualifyingConvectiveOutlooksResponse) - cfg := dailyTestConfig(t, server) - - result := generateDailyReportForTest(t, cfg) - if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok { - t.Fatal("module snapshot missing spc_convective_outlooks stanza") - } - if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); !ok { - t.Fatal("module snapshot missing spc_convective_discussion stanza") - } - data := readDataPackageForTest(t, result) - text := string(data) - if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") { - t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text) - } - for _, want := range []string{ - " spc_convective_outlooks:", - " spc_convective_discussion:", - " included_because: categorical severity_rank >= 3", - " label_text: Slight Risk", - "background_definition:", - "plain_language: Scattered severe storms possible.", - "official_description: Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.", - "relative_level: 2 of 5", - " period_begins:", - " period_ends:", - " discussion: Severe thunderstorms may produce damaging winds during the afternoon.", - } { - if !strings.Contains(text, want) { - t.Fatalf("data package missing %q:\n%s", want, text) - } - } - for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} { - if strings.Contains(text, omitted) { - t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text) - } - } - - riskIndex := strings.Index(text, " applicable_risk_products:") - alertIndex := strings.Index(text, " alert_digest:") - outlookIndex := strings.Index(text, " spc_convective_outlooks:") - derivedIndex := strings.Index(text, " derived_summaries:") - narrativeIndex := strings.Index(text, " narrative_products:") - forecastIndex := strings.Index(text, " narrative_forecast:") - afdIndex := strings.Index(text, " area_forecast_discussion:") - discussionIndex := strings.Index(text, " spc_convective_discussion:") - storyIndex := strings.Index(text, " weather_story:") - rawIndex := strings.Index(text, " raw_data:") - if riskIndex < 0 || alertIndex < 0 || outlookIndex < 0 || derivedIndex < 0 || narrativeIndex < 0 || forecastIndex < 0 || afdIndex < 0 || discussionIndex < 0 || storyIndex < 0 || rawIndex < 0 || - !(riskIndex < alertIndex && alertIndex < outlookIndex && outlookIndex < derivedIndex) || - !(narrativeIndex < forecastIndex && forecastIndex < afdIndex && afdIndex < discussionIndex && discussionIndex < storyIndex && storyIndex < rawIndex) { - t.Fatalf("data package category order is wrong:\n%s", text) - } - - loaded, err := promptinput.LoadYAML(data) - if err != nil { - t.Fatalf("LoadYAML() error = %v", err) - } - assertNoStaleModuleIntervalKeys(t, loaded.Briefing.Values) - if _, ok := loaded.Briefing.Values["spc_convective_outlooks"]; !ok { - t.Fatal("loaded package missing spc_convective_outlooks stanza") - } - if _, ok := loaded.Briefing.Values["spc_convective_discussion"]; !ok { - t.Fatal("loaded package missing spc_convective_discussion stanza") - } -} - -func TestGenerateReportOmitsSPCConvectiveDiscussionBelowThreshold(t *testing.T) { - server := dailyBundleServerWithConvectiveResponse(t, lowerRiskConvectiveOutlooksResponse) - cfg := dailyTestConfig(t, server) - - result := generateDailyReportForTest(t, cfg) - if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_outlooks"); !ok { - t.Fatal("module snapshot missing spc_convective_outlooks stanza") - } - if _, ok := result.ModuleSnapshot.LookupStanza("spc_convective_discussion"); ok { - t.Fatal("module snapshot has spc_convective_discussion stanza, want omitted below threshold") - } - text := string(readDataPackageForTest(t, result)) - if !strings.Contains(text, " spc_convective_outlooks:") || !strings.Contains(text, " label_text: Marginal Risk") { - t.Fatalf("data package missing lower-risk SPC outlook:\n%s", text) - } - for _, omitted := range []string{" severity_rank:", " expires_at:", " source_url:"} { - if strings.Contains(text, omitted) { - t.Fatalf("data package contains %q, want SPC prompt schema without it:\n%s", omitted, text) - } - } - if strings.Contains(text, "spc_convective_discussion:") || strings.Contains(text, "Low-end severe threat discussion.") { - t.Fatalf("data package has SPC convective discussion, want omitted below threshold:\n%s", text) - } - if strings.Contains(text, "geometry:") || strings.Contains(text, "coordinates:") || strings.Contains(text, "Polygon") { - t.Fatalf("data package contains geometry, want prompt-facing fields only:\n%s", text) - } -} - -func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) { - server := hourlyBundleServer(t) - cfg := hourlyTestConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{Report: ReportHourly}, "2026-05-29T08:30:00-05:00") - store := recordingFilesystemStore(t, cfg) - renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ - Command: []string{"scriptorium", "render"}, - Stdout: `{"prepared":true}`, - ExitCode: 0, - }, - structuredRunResult: &scriptorium.StructuredRunResult{ - Command: []string{"scriptorium", "run"}, - Stderr: "wrote generated text", - ExitCode: 0, - }, - structuredRunBody: `{ - "summary": " Storm chances increase through late morning. ", - "forecast_discussion": "A front will keep the region unsettled.", - "precipitation_timing": "A cold front is moving into the region.", - "confidence": "Medium" - }`, - } - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: renderer, - Store: store, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - - if renderer.renderCalls != 1 { - t.Fatalf("render calls = %d, want 1", renderer.renderCalls) - } - if renderer.structuredRunCalls != 1 { - t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls) - } - if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath { - t.Fatalf("structured run OutputPath = %q, want %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath) - } - if renderer.structuredRunRequest.DataPackagePath != result.DataPackagePath { - t.Fatalf("structured run DataPackagePath = %q, want %q", renderer.structuredRunRequest.DataPackagePath, result.DataPackagePath) - } - if got, want := strings.Join(store.calls, ","), "module_snapshot,data_package,preflight,metadata,generated_text_result,metadata,generated_text,metadata,render_context,metadata,prepare_report,metadata"; got != want { - t.Fatalf("store calls = %v, want %s", store.calls, want) - } - - assertPathsExist(t, - result.ModuleSnapshotPath, - result.DataPackagePath, - result.PreflightPath, - result.GeneratedTextRawPath, - result.Metadata.GeneratedTextResultPath, - result.GeneratedTextPath, - result.RenderContextPath, - result.ReportPath, - result.MetadataPath, - ) - raw, err := os.ReadFile(result.GeneratedTextRawPath) - if err != nil { - t.Fatalf("read raw generated text: %v", err) - } - if !strings.Contains(string(raw), `"summary": " Storm chances increase through late morning. "`) { - t.Fatalf("raw generated text was not preserved:\n%s", string(raw)) - } - normalized, err := os.ReadFile(result.GeneratedTextPath) - if err != nil { - t.Fatalf("read validated generated text: %v", err) - } - if string(normalized) != `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}` { - t.Fatalf("validated generated text = %s, want normalized JSON", normalized) - } - renderContext, err := os.ReadFile(result.RenderContextPath) - if err != nil { - t.Fatalf("read render context: %v", err) - } - for _, want := range []string{ - `"Report": {`, - `"Title": "Hourly Report"`, - `"LocationName": "Brentwood, MO"`, - `"GeneratedText": {`, - `"Modules": {`, - `"CurrentConditions": {`, - `"HourlyForecast": {`, - `"Collected": {`, - `"Derived": {`, - } { - if !strings.Contains(string(renderContext), want) { - t.Fatalf("render context missing %q:\n%s", want, string(renderContext)) - } - } - reportData, err := os.ReadFile(result.ReportPath) - if err != nil { - t.Fatalf("read report: %v", err) - } - reportText := string(reportData) - for _, want := range []string{ - "# Hourly Report", - "Storm chances increase through late morning.", - "## Alert Digest", - "- **Flood Watch**: Flood Watch in effect from May 29 at 11:00 AM to May 29 at 3:00 PM.", - "## Precipitation Timing", - "A cold front is moving into the region.", - "A front will keep the region unsettled.", - } { - if !strings.Contains(reportText, want) { - t.Fatalf("rendered hourly report missing %q:\n%s", want, reportText) - } - } - if strings.Contains(reportText, "Avoid low-water crossings.") { - t.Fatalf("rendered hourly report includes alert instruction:\n%s", reportText) - } - if result.OutputPath != result.ReportPath { - t.Fatalf("OutputPath = %q, want managed report path %q", result.OutputPath, result.ReportPath) - } - if len(result.RecentChanges) != 0 { - t.Fatalf("RecentChanges = %#v, want none for hourly report", result.RecentChanges) - } - if result.Metadata.GeneratedTextSchemaID != "hourly" || - result.Metadata.GeneratedTextRawPath != result.GeneratedTextRawPath || - result.Metadata.GeneratedTextResultPath == "" || - result.Metadata.GeneratedTextPath != result.GeneratedTextPath || - result.Metadata.RenderContextPath != result.RenderContextPath || - result.Metadata.RenderedReportPath != result.ReportPath { - t.Fatalf("metadata generated-text links = %#v, want saved artifact links", result.Metadata) - } - metadataData, err := os.ReadFile(result.MetadataPath) - if err != nil { - t.Fatalf("read metadata: %v", err) - } - if !strings.Contains(string(metadataData), `"generatedTextSchemaId": "hourly"`) || - !strings.Contains(string(metadataData), result.GeneratedTextRawPath) || - !strings.Contains(string(metadataData), result.RenderContextPath) { - t.Fatalf("metadata JSON missing generated-text links:\n%s", string(metadataData)) - } -} - -func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T) { - cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) - notifier.result = successfulNotificationResult() - renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON()) - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - OutputPath: outputPath, - Renderer: renderer, - Store: store, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if result.OutputPath != outputPath { - t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath) - } - assertPathsExist(t, result.ReportPath, outputPath, result.NotificationPath) - reportData, err := os.ReadFile(result.ReportPath) - if err != nil { - t.Fatalf("read managed report: %v", err) - } - copyData, err := os.ReadFile(outputPath) - if err != nil { - t.Fatalf("read output copy: %v", err) - } - if string(copyData) != string(reportData) { - t.Fatalf("output copy differs from managed report") - } - if len(notifier.requests) != 1 { - t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) - } - req := notifier.requests[0] - if req.ReportPath != result.ReportPath { - t.Fatalf("notification ReportPath = %q, want managed report path %q", req.ReportPath, result.ReportPath) - } - if req.ReportPath == outputPath { - t.Fatalf("notification used output copy %q, want managed report path", outputPath) - } - wantBundlePaths := []string{"hourly/index.md"} - if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { - t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths) - } - if req.PipelineID != "weatherreporter.hourly" { - t.Fatalf("notification PipelineID = %q, want weatherreporter.hourly", req.PipelineID) - } - if req.BundleID != "weatherreporter.home.hourly" { - t.Fatalf("notification BundleID = %q, want weatherreporter.home.hourly", req.BundleID) - } - if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID { - t.Fatalf("notification IdempotencyKey = %q, want per-run key", req.IdempotencyKey) - } - if result.Notification == nil || result.Notification.RunID != "distributor-run" { - t.Fatalf("Notification = %#v, want distributor result", result.Notification) - } - if result.Metadata.NotificationPath != result.NotificationPath { - t.Fatalf("metadata NotificationPath = %q, want %q", result.Metadata.NotificationPath, result.NotificationPath) - } - notificationData, err := os.ReadFile(result.NotificationPath) - if err != nil { - t.Fatalf("read notification artifact: %v", err) - } - var notificationArtifact state.DistributorNotificationArtifact - if err := json.Unmarshal(notificationData, ¬ificationArtifact); err != nil { - t.Fatalf("decode notification artifact: %v", err) - } - if notificationArtifact.SourcePath != result.ReportPath || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.RunStatus == nil { - t.Fatalf("notification artifact = %#v, want managed source and run status", notificationArtifact) - } -} - -func TestGenerateTodayReportCopiesOutputAndNotifiesTodayTemplateValues(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - cfg.Notify.Distributor.Enabled = true - cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}" - cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}" - cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}" - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportToday, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - outputPath := filepath.Join(t.TempDir(), "today-copy.md") - notifier := &recordingNotifier{ - result: successfulNotificationResult(), - } - renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON()) - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - OutputPath: outputPath, - Renderer: renderer, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if result.OutputPath != outputPath { - t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath) - } - assertPathsExist(t, result.ReportPath, outputPath, result.NotificationPath) - reportData, err := os.ReadFile(result.ReportPath) - if err != nil { - t.Fatalf("read managed report: %v", err) - } - copyData, err := os.ReadFile(outputPath) - if err != nil { - t.Fatalf("read output copy: %v", err) - } - if string(copyData) != string(reportData) { - t.Fatalf("output copy differs from managed report") - } - if len(notifier.requests) != 1 { - t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) - } - req := notifier.requests[0] - if req.ReportID != report.Today { - t.Fatalf("notification ReportID = %q, want today", req.ReportID) - } - if req.ReportPath != result.ReportPath { - t.Fatalf("notification ReportPath = %q, want managed report path %q", req.ReportPath, result.ReportPath) - } - if req.ReportPath == outputPath { - t.Fatalf("notification used output copy %q, want managed report path", outputPath) - } - if req.PipelineID != "weatherreporter.today.today" { - t.Fatalf("PipelineID = %q, want Today report and artifact values", req.PipelineID) - } - if req.BundleID != "today.today.md.today" { - t.Fatalf("BundleID = %q, want Today artifact group, output name, and report id", req.BundleID) - } - wantBundlePaths := []string{ - "daily/2026-05-29/" + result.Metadata.RunID + ".md", - "daily/2026-05-29/index.md", - "today/index.md", - } - if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { - t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths) - } - notificationData, err := os.ReadFile(result.NotificationPath) - if err != nil { - t.Fatalf("read notification artifact: %v", err) - } - var notificationArtifact state.DistributorNotificationArtifact - if err := json.Unmarshal(notificationData, ¬ificationArtifact); err != nil { - t.Fatalf("decode notification artifact: %v", err) - } - if notificationArtifact.ReportID != report.Today || - notificationArtifact.PipelineID != "weatherreporter.today.today" || - notificationArtifact.BundleID != "today.today.md.today" || - notificationArtifact.SourcePath != result.ReportPath || - strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || - notificationArtifact.RunStatus == nil || - !strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") { - t.Fatalf("notification artifact = %#v, want Today managed-source notification", notificationArtifact) - } -} - -func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) { - cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) - notifier.err = errors.New("upload rejected") - renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, - structuredRunBody: validHourlyGeneratedTextJSON(), - } - - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - OutputPath: outputPath, - Renderer: renderer, - Store: store, - Notifier: notifier, - }) - if err == nil { - t.Fatal("generateLegacyBatchReport() error = nil, want notification error") - } - var notificationErr *NotificationError - if !errors.As(err, ¬ificationErr) { - t.Fatalf("generateLegacyBatchReport() error = %T %v, want NotificationError", err, err) - } - if !strings.Contains(err.Error(), `notify report "hourly"`) || !strings.Contains(err.Error(), "upload rejected") { - t.Fatalf("error = %q, want hourly notification context", err.Error()) - } - if len(notifier.requests) != 1 { - t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) - } - paths := hourlyArtifactPaths(t, store, resolved) - assertPathsExist(t, paths.RenderedReport, outputPath, paths.Metadata, paths.Notification) - if notifier.requests[0].ReportPath != paths.RenderedReport { - t.Fatalf("notification ReportPath = %q, want managed report path %q", notifier.requests[0].ReportPath, paths.RenderedReport) - } - notificationData, readErr := os.ReadFile(paths.Notification) - if readErr != nil { - t.Fatalf("read notification artifact after failure: %v", readErr) - } - var notification state.DistributorNotificationArtifact - if err := json.Unmarshal(notificationData, ¬ification); err != nil { - t.Fatalf("decode notification artifact: %v", err) - } - if notification.Status != "failed" || !strings.Contains(notification.Error, "upload rejected") || notification.SourcePath != paths.RenderedReport { - t.Fatalf("notification failure artifact = %+v, want failed managed-source context", notification) - } -} - -func TestGenerateReportSavesFinalMetadata(t *testing.T) { - server := hourlyBundleServer(t) - cfg := hourlyGeneratedTextConfig(t, server) - cfg.Notify.Distributor.Enabled = false - resolved, store, _, _ := resolveHourlyGeneratedTextFixture(t, cfg) - renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, - structuredRunBody: validHourlyGeneratedTextJSON(), - } - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: renderer, - Store: store, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - - saved := readMetadataForTest(t, result.MetadataPath) - if saved.RenderedReportPath != result.ReportPath || saved.NotificationPath != "" { - t.Fatalf("saved metadata = %#v, want final rendered path without notification", saved) - } - if saved.GeneratedTextSchemaID != "hourly" || - saved.GeneratedTextRawPath != result.GeneratedTextRawPath || - saved.GeneratedTextResultPath != result.Metadata.GeneratedTextResultPath || - saved.GeneratedTextPath != result.GeneratedTextPath || - saved.RenderContextPath != result.RenderContextPath { - t.Fatalf("saved generated-text metadata = %#v, want generated-text artifact links", saved) - } -} - -func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - cfg.Notify.Distributor.Enabled = true - cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}.{artifact_group}" - cfg.Notify.Distributor.BundleIDTemplate = "{artifact_group}.{batch_output_name}.{report_id}" - cfg.Notify.Distributor.IdempotencyKeyTemplate = "{bundle_id}.{run_id}" - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportTomorrow, - }, "2026-05-29T18:00:00-05:00") - notifier := &recordingNotifier{} - renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON()) - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: renderer, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if len(notifier.requests) != 1 { - t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) - } - req := notifier.requests[0] - if req.ReportID != report.Tomorrow { - t.Fatalf("notification ReportID = %q, want tomorrow", req.ReportID) - } - if req.PipelineID != "weatherreporter.tomorrow.tomorrow" { - t.Fatalf("PipelineID = %q, want report/artifact group values", req.PipelineID) - } - if req.BundleID != "tomorrow.tomorrow.md.tomorrow" { - t.Fatalf("BundleID = %q, want artifact group, batch output name, and report id", req.BundleID) - } - wantBundlePaths := []string{ - "daily/2026-05-30/" + result.Metadata.RunID + ".md", - "daily/2026-05-30/index.md", - "tomorrow/index.md", - } - if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { - t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths) - } - if req.ReportPath != result.ReportPath { - t.Fatalf("ReportPath = %q, want managed path %q", req.ReportPath, result.ReportPath) - } -} - -func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) { - cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) - renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ - Command: []string{"scriptorium", "render"}, - Stderr: "render failed", - ExitCode: 1, - }, - err: errors.New("scriptorium render exited with code 1: render failed"), - } - - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - OutputPath: outputPath, - Renderer: renderer, - Store: store, - Notifier: notifier, - }) - assertGeneratedReportError(t, err, resolved, "render preflight") - if renderer.structuredRunCalls != 0 || renderer.runCalls != 0 { - t.Fatalf("post-preflight calls structured=%d run=%d, want none", renderer.structuredRunCalls, renderer.runCalls) - } - assertNoGeneratedFailureSideEffects(t, notifier, outputPath) - - paths := hourlyArtifactPaths(t, store, resolved) - assertPathsExist(t, paths.Preflight, paths.Metadata) - assertPathsMissing(t, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText, paths.RenderContext, paths.RenderedReport) - preflight, readErr := os.ReadFile(paths.Preflight) - if readErr != nil { - t.Fatalf("read failed preflight: %v", readErr) - } - if !strings.Contains(string(preflight), `"exitCode": 1`) || !strings.Contains(string(preflight), "render failed") { - t.Fatalf("failed preflight was not persisted:\n%s", string(preflight)) - } - metadataData, readErr := os.ReadFile(paths.Metadata) - if readErr != nil { - t.Fatalf("read metadata: %v", readErr) - } - if !strings.Contains(string(metadataData), paths.Preflight) || !strings.Contains(string(metadataData), paths.GeneratedTextRaw) { - t.Fatalf("metadata missing failed-run artifact links:\n%s", string(metadataData)) - } -} - -func TestGenerateHourlyReportPersistsStructuredRunFailure(t *testing.T) { - cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) - renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - structuredRunResult: &scriptorium.StructuredRunResult{ - Command: []string{"scriptorium", "run", "--json"}, - Stderr: "generation failed", - ExitCode: 2, - }, - structuredRunErr: errors.New("scriptorium structured run exited with code 2: generation failed"), - structuredRunBody: validHourlyGeneratedTextJSON(), - } - - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - OutputPath: outputPath, - Renderer: renderer, - Store: store, - Notifier: notifier, - }) - assertGeneratedReportError(t, err, resolved, "structured generated text") - if renderer.structuredRunCalls != 1 || renderer.runCalls != 0 { - t.Fatalf("calls structured=%d run=%d, want one structured run and no markdown run", renderer.structuredRunCalls, renderer.runCalls) - } - assertNoGeneratedFailureSideEffects(t, notifier, outputPath) - - paths := hourlyArtifactPaths(t, store, resolved) - assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult) - assertPathsMissing(t, paths.GeneratedText, paths.RenderContext, paths.RenderedReport) - metadataData, readErr := os.ReadFile(paths.Metadata) - if readErr != nil { - t.Fatalf("read metadata: %v", readErr) - } - if !strings.Contains(string(metadataData), paths.GeneratedTextRaw) || !strings.Contains(string(metadataData), paths.GeneratedTextResult) { - t.Fatalf("metadata missing structured failure links:\n%s", string(metadataData)) - } -} - -func TestGenerateHourlyReportPreservesRawTextOnValidationFailure(t *testing.T) { - cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) - renderer := successfulGeneratedTextRenderer(`{ - "summary": "Storm chances increase through late morning.", - "forecast_discussion": "A front will keep the region unsettled.", - "details": "not allowed" - }`) - - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - OutputPath: outputPath, - Renderer: renderer, - Store: store, - Notifier: notifier, - }) - assertGeneratedReportError(t, err, resolved, "validate generated text") - assertNoGeneratedFailureSideEffects(t, notifier, outputPath) - - paths := hourlyArtifactPaths(t, store, resolved) - assertPathsExist(t, paths.Preflight, paths.Metadata, paths.GeneratedTextRaw, paths.GeneratedTextResult) - assertPathsMissing(t, paths.GeneratedText, paths.RenderContext, paths.RenderedReport) - raw, readErr := os.ReadFile(paths.GeneratedTextRaw) - if readErr != nil { - t.Fatalf("read raw generated text: %v", readErr) - } - if !strings.Contains(string(raw), `"details": "not allowed"`) { - t.Fatalf("raw generated text was not preserved:\n%s", string(raw)) - } -} - -func TestGenerateHourlyReportRejectsUnsupportedTemplateBeforeStructuredRun(t *testing.T) { - cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) - resolved.Definition.TemplateID = "missing-template" - renderer := successfulGeneratedTextRenderer(validHourlyGeneratedTextJSON()) - - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - OutputPath: outputPath, - Renderer: renderer, - Store: store, - Notifier: notifier, - }) - assertGeneratedReportError(t, err, resolved, "lookup generated text catalog") - if renderer.structuredRunCalls != 0 || renderer.runCalls != 0 { - t.Fatalf("calls structured=%d run=%d, want no generated text run", renderer.structuredRunCalls, renderer.runCalls) - } - assertNoGeneratedFailureSideEffects(t, notifier, outputPath) - - paths := hourlyArtifactPaths(t, store, resolved) - assertPathsExist(t, paths.Preflight, paths.Metadata) - assertPathsMissing(t, paths.GeneratedTextRaw, paths.GeneratedTextResult, paths.GeneratedText, paths.RenderContext, paths.RenderedReport) - metadataData, readErr := os.ReadFile(paths.Metadata) - if readErr != nil { - t.Fatalf("read metadata: %v", readErr) - } - if !strings.Contains(string(metadataData), paths.GeneratedTextRaw) { - t.Fatalf("metadata missing generated text artifact path:\n%s", string(metadataData)) - } -} - -func TestGenerateReportDisabledNotificationDoesNotCallNotifier(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - notifier := &recordingNotifier{} - - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: successfulRenderer("# Daily Report\n"), - Notifier: notifier, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if len(notifier.requests) != 0 { - t.Fatalf("notification requests = %#v, want none when disabled", notifier.requests) - } -} - -func TestGenerateReportNotifiesManagedReportPath(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - notifier := &recordingNotifier{ - result: successfulNotificationResult(), - } - outputPath := filepath.Join(t.TempDir(), "daily-copy.md") - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - OutputPath: outputPath, - Renderer: successfulRenderer("# Daily Report\n"), - Notifier: notifier, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if result.Notification == nil { - t.Fatal("Notification = nil, want notification result") - } - if result.Notification.RunID != "distributor-run" || result.Notification.Status != "succeeded" { - t.Fatalf("Notification = %#v, want succeeded distributor run", result.Notification) - } - if result.NotificationPath == "" || result.Metadata.NotificationPath != result.NotificationPath { - t.Fatalf("NotificationPath result=%q metadata=%q, want linked artifact", result.NotificationPath, result.Metadata.NotificationPath) - } - notificationData, err := os.ReadFile(result.NotificationPath) - if err != nil { - t.Fatalf("read notification artifact: %v", err) - } - var notificationArtifact state.DistributorNotificationArtifact - if err := json.Unmarshal(notificationData, ¬ificationArtifact); err != nil { - t.Fatalf("decode notification artifact: %v", err) - } - wantBundlePaths := []string{ - "daily/2026-05-29/" + result.Metadata.RunID + ".md", - "daily/2026-05-29/index.md", - } - if notificationArtifact.PipelineID != "weatherreporter.daily" || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.BundleCreated.IsZero() || notificationArtifact.RunStatus == nil || !strings.Contains(string(notificationArtifact.RunStatus.Report), "replace_older") { - t.Fatalf("notification artifact = %#v, want requested pipeline, status report, and created timestamp", notificationArtifact) - } - if len(notifier.requests) != 1 { - t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) - } - req := notifier.requests[0] - if req.ReportPath != result.ReportPath { - t.Fatalf("notification ReportPath = %q, want managed path %q", req.ReportPath, result.ReportPath) - } - if req.ReportPath == outputPath { - t.Fatalf("notification used output copy %q, want managed report path", outputPath) - } - if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { - t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths) - } - if req.PipelineID != "weatherreporter.daily" { - t.Fatalf("notification PipelineID = %q, want rendered pipeline", req.PipelineID) - } - if req.BundleID != "weatherreporter.home.daily" { - t.Fatalf("notification BundleID = %q, want default template", req.BundleID) - } - if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID { - t.Fatalf("IdempotencyKey = %q, want per-run key", req.IdempotencyKey) - } - if req.RunID != result.Metadata.RunID { - t.Fatalf("notification RunID = %q, want report run id %q", req.RunID, result.Metadata.RunID) - } - if !req.CreatedAt.Equal(result.Metadata.GeneratedAt) { - t.Fatalf("notification CreatedAt = %s, want generated at %s", req.CreatedAt, result.Metadata.GeneratedAt) - } -} - -func TestGenerateReportNotificationFailureFailsReport(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - notifier := &recordingNotifier{err: errors.New("upload rejected")} - - store := recordingFilesystemStore(t, cfg) - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: successfulRenderer("# Daily Report\n"), - Store: store, - Notifier: notifier, - }) - if err == nil { - t.Fatal("generateLegacyBatchReport() error = nil, want notification error") - } - if !strings.Contains(err.Error(), "notify report") || !strings.Contains(err.Error(), "upload rejected") { - t.Fatalf("error = %q, want notification context", err.Error()) - } - if len(notifier.requests) != 1 { - t.Fatalf("notification requests = %d, want one attempted notification", len(notifier.requests)) - } - paths, pathErr := store.Paths(resolved) - if pathErr != nil { - t.Fatalf("Paths() error = %v", pathErr) - } - notificationData, readErr := os.ReadFile(paths.Notification) - if readErr != nil { - t.Fatalf("read notification artifact after failure: %v", readErr) - } - var notification state.DistributorNotificationArtifact - if err := json.Unmarshal(notificationData, ¬ification); err != nil { - t.Fatalf("decode notification artifact: %v", err) - } - if notification.Status != "failed" || !strings.Contains(notification.Error, "upload rejected") { - t.Fatalf("notification failure artifact = %+v, want failed upload context", notification) - } -} - -func TestGenerateReportDoesNotNotifyAfterRenderOrRunFailure(t *testing.T) { - server := dailyBundleServer(t) - tests := []struct { - name string - renderer Renderer - }{ - { - name: "Render", - renderer: &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 1, Stderr: "render failed"}, - err: errors.New("render failed"), - }, - }, - { - name: "Run", - renderer: &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 2, Stderr: "run failed"}, - structuredRunErr: errors.New("run failed"), - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := dailyNotificationConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - notifier := &recordingNotifier{} - - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: tt.renderer, - Notifier: notifier, - }) - if err == nil { - t.Fatal("generateLegacyBatchReport() error = nil, want generation error") - } - if len(notifier.requests) != 0 { - t.Fatalf("notification requests = %#v, want none after generation failure", notifier.requests) - } - }) - } -} - -func TestGenerateReportRequiresCollectedBundleBeforeStateWrites(t *testing.T) { - cfg := config.Defaults() - cfg.WeatherAPI.BaseURL = "" - cfg.WeatherAPI.Timezone = "America/Chicago" - cfg.Workspace.Root = t.TempDir() - cfg.Notify.Distributor.Enabled = true - cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}" - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - notifier := &recordingNotifier{} - store := recordingFilesystemStore(t, cfg) - - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Resolved: resolved, - Renderer: successfulRenderer("# Daily Report\n"), - Store: store, - Notifier: notifier, - }) - if err == nil { - t.Fatal("generateLegacyBatchReport() error = nil, want collected bundle error") - } - if !strings.Contains(err.Error(), "collected weather bundle is required") { - t.Fatalf("generateLegacyBatchReport() error = %q, want collected bundle context", err.Error()) - } - if len(notifier.requests) != 0 { - t.Fatalf("notification requests = %#v, want none without collected data", notifier.requests) - } - if len(store.calls) != 0 { - t.Fatalf("state calls = %#v, want no state writes without collected data", store.calls) - } -} - -func TestGenerateReportPersistsFailedPreflight(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ - Command: []string{"scriptorium", "render"}, - Stderr: "render failed", - ExitCode: 1, - }, - err: errors.New("scriptorium render exited with code 1: render failed"), - } - - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: renderer, - }) - if err == nil { - t.Fatal("generateLegacyBatchReport() error = nil, want render error") - } - store := recordingFilesystemStore(t, cfg) - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - preflightPath := paths.Preflight - preflight, readErr := os.ReadFile(preflightPath) - if readErr != nil { - t.Fatalf("read failed preflight: %v", readErr) - } - if !strings.Contains(string(preflight), `"exitCode": 1`) { - t.Fatalf("failed preflight was not persisted:\n%s", string(preflight)) - } - if _, err := os.Stat(paths.Metadata); err != nil { - t.Fatalf("expected metadata for failed preflight %q: %v", paths.Metadata, err) - } - if renderer.runCalls != 0 { - t.Fatalf("run calls = %d, want none after failed preflight", renderer.runCalls) - } -} - -func TestGenerateReportReturnsRunErrorAfterPreflight(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - renderer := &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - structuredRunResult: &scriptorium.StructuredRunResult{ - Stderr: "validation failed", - ExitCode: 2, - }, - structuredRunErr: errors.New("scriptorium run exited with code 2: validation failed"), - } - - _, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: renderer, - }) - if err == nil { - t.Fatal("generateLegacyBatchReport() error = nil, want run error") - } - if renderer.renderCalls != 1 || renderer.structuredRunCalls != 1 || renderer.runCalls != 0 { - t.Fatalf("calls render=%d structured=%d run=%d, want render and structured run only", renderer.renderCalls, renderer.structuredRunCalls, renderer.runCalls) - } - store := recordingFilesystemStore(t, cfg) - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - if _, err := os.Stat(paths.Metadata); err != nil { - t.Fatalf("expected metadata for failed run %q: %v", paths.Metadata, err) - } - if _, err := os.Stat(paths.GeneratedTextRaw); err != nil { - t.Fatalf("expected raw generated text from failed run %q: %v", paths.GeneratedTextRaw, err) - } - if _, err := os.Stat(paths.RenderedReport); !os.IsNotExist(err) { - t.Fatalf("rendered report exists after failed generated-text run: %v", err) - } -} - -func TestGenerateReportIncludesRecentChangesFromPriorSnapshot(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - store := recordingFilesystemStore(t, cfg) - priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T04:00:00-05:00") - savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved)) - - currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - renderer := successfulRenderer("") - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: currentResolved, - Renderer: renderer, - Store: store, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if len(result.RecentChanges) == 0 { - t.Fatal("RecentChanges length = 0, want changes from prior snapshot") - } - data, err := os.ReadFile(result.DataPackagePath) - if err != nil { - t.Fatalf("read data package: %v", err) - } - if !strings.Contains(string(data), "alert_added") || !strings.Contains(string(data), "temperature_shift") { - t.Fatalf("data package missing recent changes:\n%s", string(data)) - } -} - -func TestGenerateTodayReportUsesTodayIdentityAndRecentChanges(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - store := recordingFilesystemStore(t, cfg) - priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportToday, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T04:00:00-05:00") - savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved)) - - currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportToday, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - renderer := successfulGeneratedTextRenderer(validTodayGeneratedTextJSON()) - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: currentResolved, - Renderer: renderer, - Store: store, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if renderer.runCalls != 0 || renderer.structuredRunCalls != 1 { - t.Fatalf("renderer calls run=%d structured=%d, want generated-text flow", renderer.runCalls, renderer.structuredRunCalls) - } - if result.Metadata.ReportID != report.Today || result.Metadata.Variant != "today" || result.Metadata.GeneratedTextSchemaID != "today" { - t.Fatalf("metadata = %#v, want Today generated-text metadata", result.Metadata) - } - if !strings.Contains(result.Metadata.RunID, "_today") { - t.Fatalf("RunID = %q, want Today report ID suffix", result.Metadata.RunID) - } - if !strings.Contains(result.DataPackagePath, filepath.Join("data-packages", "today", "2026-05-29")) { - t.Fatalf("DataPackagePath = %q, want Today artifact group", result.DataPackagePath) - } - if !strings.Contains(result.ReportPath, filepath.Join("reports", "today")) { - t.Fatalf("ReportPath = %q, want Today report group", result.ReportPath) - } - if _, ok := result.ModuleSnapshot.LookupStanza("today_planning"); !ok { - t.Fatal("today_planning stanza missing") - } - if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); ok { - t.Fatal("tomorrow_planning stanza present, want Today-specific planning") - } - if result.PriorSnapshot == nil || len(result.RecentChanges) == 0 { - t.Fatalf("prior=%#v recentChanges=%#v, want Today daily comparison changes", result.PriorSnapshot, result.RecentChanges) - } - data, err := os.ReadFile(result.DataPackagePath) - if err != nil { - t.Fatalf("read data package: %v", err) - } - for _, want := range []string{"id: today", "prompt_id: weather.today_generated_text", "today_planning:", "recent_changes:"} { - if !strings.Contains(string(data), want) { - t.Fatalf("data package missing %q:\n%s", want, string(data)) - } - } - reportData, err := os.ReadFile(result.ReportPath) - if err != nil { - t.Fatalf("read report: %v", err) - } - for _, want := range []string{"# Today's Weather", "Today starts with showers before improving."} { - if !strings.Contains(string(reportData), want) { - t.Fatalf("today report missing %q:\n%s", want, string(reportData)) - } - } -} - -func TestGenerateTomorrowReportUsesTomorrowBriefingDate(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportTomorrow, - }, "2026-05-29T18:00:00-05:00") - renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON()) - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: renderer, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if renderer.runCalls != 0 { - t.Fatalf("markdown run calls = %d, want none", renderer.runCalls) - } - if renderer.structuredRunCalls != 1 { - t.Fatalf("structured run calls = %d, want 1", renderer.structuredRunCalls) - } - if renderer.structuredRunRequest.OutputPath != result.GeneratedTextRawPath { - t.Fatalf("structured run OutputPath = %q, want %q", renderer.structuredRunRequest.OutputPath, result.GeneratedTextRawPath) - } - - if result.Metadata.ReportID != report.Tomorrow || result.Metadata.Variant != "tomorrow" { - t.Fatalf("metadata report/variant = %q/%q, want tomorrow", result.Metadata.ReportID, result.Metadata.Variant) - } - if result.Metadata.GeneratedTextSchemaID != "tomorrow" || result.Metadata.GeneratedTextPath != result.GeneratedTextPath || result.Metadata.RenderContextPath != result.RenderContextPath || result.Metadata.RenderedReportPath != result.ReportPath { - t.Fatalf("metadata generated-text links = %#v, want tomorrow generated-text artifacts", result.Metadata) - } - dailySummary, ok, err := module.StanzaValue[map[string]any](result.ModuleSnapshot, "derived_daily_summary") - if err != nil { - t.Fatalf("decode daily summary: %v", err) - } - if !ok || dailySummary["date"] != "Saturday, May 30, 2026" { - t.Fatalf("daily summary = %#v, want tomorrow date", dailySummary) - } - if _, ok := result.ModuleSnapshot.LookupStanza("tomorrow_planning"); !ok { - t.Fatal("tomorrow_planning stanza missing") - } - if !strings.Contains(filepath.Base(result.ReportPath), "tomorrow") { - t.Fatalf("ReportPath = %q, want managed tomorrow report path", result.ReportPath) - } - assertPathsExist(t, result.GeneratedTextRawPath, result.Metadata.GeneratedTextResultPath, result.GeneratedTextPath, result.RenderContextPath, result.ReportPath) - renderContext, err := os.ReadFile(result.RenderContextPath) - if err != nil { - t.Fatalf("read render context: %v", err) - } - for _, want := range []string{`"Title": "Saturday's Weather"`, `"GeneratedText": {`, `"forecast_discussion": [`, `"Dayparts": [`} { - if !strings.Contains(string(renderContext), want) { - t.Fatalf("render context missing %q:\n%s", want, string(renderContext)) - } - } - reportData, err := os.ReadFile(result.ReportPath) - if err != nil { - t.Fatalf("read report: %v", err) - } - for _, want := range []string{"# Saturday's Weather", "## Daypart Forecast", "## Forecast Discussion", "Tomorrow starts with showers before improving."} { - if !strings.Contains(string(reportData), want) { - t.Fatalf("tomorrow report missing %q:\n%s", want, string(reportData)) - } - } -} - -func TestTomorrowReportCanCompareAgainstPriorTomorrowSnapshot(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - store := recordingFilesystemStore(t, cfg) - priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportTomorrow, - }, "2026-05-29T17:00:00-05:00") - savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved)) - - currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportTomorrow, - }, "2026-05-29T18:00:00-05:00") - renderer := successfulGeneratedTextRenderer(validTomorrowGeneratedTextJSON()) - - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: currentResolved, - Renderer: renderer, - Store: store, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if result.PriorSnapshot == nil { - t.Fatal("PriorSnapshot = nil, want compatible prior tomorrow snapshot") - } - if len(result.RecentChanges) == 0 { - t.Fatal("RecentChanges length = 0, want changes from compatible prior tomorrow snapshot") - } -} - -func TestDailyReportIgnoresPriorTomorrowSnapshot(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - store := recordingFilesystemStore(t, cfg) - priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportTomorrow, - }, "2026-05-28T18:00:00-05:00") - savePriorRun(t, store, priorResolved, priorDailyModuleSnapshot(t, priorResolved)) - - currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: currentResolved, - Renderer: successfulRenderer("# Daily Report\n"), - Store: store, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - if result.PriorSnapshot != nil { - t.Fatalf("PriorSnapshot = %#v, want nil for prior tomorrow snapshot", result.PriorSnapshot) - } - if len(result.RecentChanges) != 0 { - t.Fatalf("RecentChanges = %#v, want none from incompatible prior tomorrow snapshot", result.RecentChanges) - } -} - -func TestInspectGeneratedReportArtifacts(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - renderer := successfulRenderer("") - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: renderer, - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - - records, err := InspectReports(context.Background(), InspectReportsRequest{Config: cfg, Limit: 1}) - if err != nil { - t.Fatalf("InspectReports() error = %v", err) - } - if len(records) != 1 || records[0].RunID != result.Metadata.RunID { - t.Fatalf("records = %#v, want generated run", records) - } - metadata, err := InspectMetadata(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) - if err != nil { - t.Fatalf("InspectMetadata() error = %v", err) - } - if metadata.ModuleSnapshotPath != result.ModuleSnapshotPath || metadata.DataPackagePath != result.DataPackagePath { - t.Fatalf("metadata paths = %#v, want generated artifact paths", metadata) - } - moduleSnapshot, err := InspectModules(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) - if err != nil { - t.Fatalf("InspectModules() error = %v", err) - } - if moduleSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(moduleSnapshot.Outputs) == 0 { - t.Fatalf("module snapshot = %#v, want persisted outputs", moduleSnapshot) - } - dataPackage, err := InspectDataPackage(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) - if err != nil { - t.Fatalf("InspectDataPackage() error = %v", err) - } - if dataPackage.RunID != result.Metadata.RunID { - t.Fatalf("data package RunID = %q, want %q", dataPackage.RunID, result.Metadata.RunID) - } - sources, err := InspectSources(context.Background(), InspectRunRequest{Config: cfg, RunID: result.Metadata.RunID}) - if err != nil { - t.Fatalf("InspectSources() error = %v", err) - } - if len(sources.Sources) == 0 { - t.Fatalf("sources = %#v, want provenance", sources) - } - if len(sources.Warnings) != 0 { - t.Fatalf("sources warnings = %#v, want none for complete fetched sources", sources.Warnings) - } -} - -func TestInspectPriorSnapshot(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - store := recordingFilesystemStore(t, cfg) - priorResolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T04:00:00-05:00") - currentResolved := resolveGenerateForTest(t, cfg, GenerateRequest{ - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, "2026-05-29T05:00:00-05:00") - renderer := successfulRenderer("") - if _, err := generateLegacyBatchReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: priorResolved, Renderer: renderer, Store: store}); err != nil { - t.Fatalf("generateLegacyBatchReport(prior) error = %v", err) - } - current, err := generateLegacyBatchReport(context.Background(), ReportRequest{Config: cfg, Collection: collectionForTest(t, cfg), Resolved: currentResolved, Renderer: renderer, Store: store}) - if err != nil { - t.Fatalf("generateLegacyBatchReport(current) error = %v", err) - } - - prior, err := InspectPriorSnapshot(context.Background(), InspectRunRequest{Config: cfg, RunID: current.Metadata.RunID}) - if err != nil { - t.Fatalf("InspectPriorSnapshot() error = %v", err) - } - if prior == nil || prior.Metadata.RunID != priorResolved.Metadata().RunID { - t.Fatalf("prior = %#v, want previous generated run", prior) - } -} - -func TestInspectMissingMetadata(t *testing.T) { - cfg := config.Defaults() - cfg.Workspace.Root = t.TempDir() - - _, err := InspectMetadata(context.Background(), InspectRunRequest{Config: cfg, RunID: "missing"}) - if err == nil { - t.Fatal("InspectMetadata() error = nil, want missing metadata error") - } - if !strings.Contains(err.Error(), "metadata for run id") { - t.Fatalf("error = %q, want missing run id context", err.Error()) - } -} - -func TestResolveGenerateMapsCommandToReportDefinition(t *testing.T) { - cfg := config.Defaults() - cfg.WeatherAPI.Timezone = "America/Chicago" - now := mustParse("2026-05-29T08:00:00-05:00") - - tests := []struct { - name string - kind ReportKind - wantID report.ID - wantPrompt string - wantStart string - wantEnd string - requestDate time.Time - }{ - { - name: "tomorrow", - kind: ReportTomorrow, - wantID: report.Tomorrow, - wantPrompt: "weather.tomorrow_generated_text", - wantStart: "2026-05-30T00:00:00-05:00", - wantEnd: "2026-05-31T00:00:00-05:00", - requestDate: time.Time{}, - }, - { - name: "hourly", - kind: ReportHourly, - wantID: report.Hourly, - wantPrompt: "weather.hourly_generated_text", - wantStart: "2026-05-29T08:00:00-05:00", - wantEnd: "2026-05-29T14:00:00-05:00", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resolved, err := ResolveGenerate(GenerateRequest{ - Config: cfg, - Report: tt.kind, - Date: tt.requestDate, - }, now) - if err != nil { - t.Fatalf("ResolveGenerate() error = %v", err) - } - if resolved.Definition.ID != tt.wantID { - t.Fatalf("ID = %q, want %q", resolved.Definition.ID, tt.wantID) - } - if resolved.Definition.PromptID != tt.wantPrompt { - t.Fatalf("PromptID = %q, want %q", resolved.Definition.PromptID, tt.wantPrompt) - } - if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != tt.wantStart { - t.Fatalf("valid start = %s, want %s", got, tt.wantStart) - } - if got := resolved.ValidPeriod.End.Format(time.RFC3339); got != tt.wantEnd { - t.Fatalf("valid end = %s, want %s", got, tt.wantEnd) - } - }) - } -} - -func TestResolveGenerateDailyRequiresDate(t *testing.T) { - cfg := config.Defaults() - cfg.WeatherAPI.Timezone = "America/Chicago" - - _, err := ResolveGenerate(GenerateRequest{ - Config: cfg, - Report: ReportDaily, - }, mustParse("2026-05-29T08:00:00-05:00")) - if err == nil { - t.Fatal("ResolveGenerate() error = nil, want required date error") - } - if !strings.Contains(err.Error(), "requires an explicit date") { - t.Fatalf("ResolveGenerate() error = %q, want required date context", err.Error()) - } -} - -func TestResolveGenerateUsesConfiguredReportModules(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.yml") - if err := os.WriteFile(path, []byte(` -reports: - tomorrow: - deterministic_modules: - - metadata - - alert_digest - - tomorrow_planning -`), 0o600); err != nil { - t.Fatalf("write config fixture: %v", err) - } - cfg, err := config.LoadFile(path) - if err != nil { - t.Fatalf("LoadFile() error = %v", err) - } - now := mustParse("2026-05-29T18:00:00-05:00") - - resolved, err := ResolveGenerate(GenerateRequest{ - Config: cfg, - Report: ReportTomorrow, - }, now) - if err != nil { - t.Fatalf("ResolveGenerate() error = %v", err) - } - want := []module.ID{module.Metadata, module.AlertDigest, module.TomorrowPlanning} - if got := resolved.Definition.ModuleIDs(); strings.Join(moduleIDsForTest(got), ",") != strings.Join(moduleIDsForTest(want), ",") { - t.Fatalf("ModuleIDs() = %#v, want %#v", got, want) - } -} - -func TestResolveGenerateRejectsInvalidProgrammaticReportOverrides(t *testing.T) { - cfg := config.Defaults() - cfg.Reports = map[string]config.ReportConfig{ - "moon": {}, - } - - _, err := ResolveGenerate(GenerateRequest{ - Config: cfg, - Report: ReportDaily, - }, mustParse("2026-05-29T05:00:00-05:00")) - if err == nil { - t.Fatal("ResolveGenerate() error = nil, want report override error") - } - if !strings.Contains(err.Error(), "reports.moon") { - t.Fatalf("ResolveGenerate() error = %q, want report override context", err.Error()) - } -} - -func moduleIDsForTest(ids []module.ID) []string { - out := make([]string, 0, len(ids)) - for _, id := range ids { - out = append(out, string(id)) - } - return out -} - -func snapshotModuleIDs(snapshot module.Snapshot) []module.ID { - ids := make([]module.ID, 0, len(snapshot.Outputs)) - for _, output := range snapshot.Outputs { - ids = append(ids, output.ID) - } - return ids -} - -func mustMarshalString(t *testing.T, value any) string { - t.Helper() - data, err := json.Marshal(value) - if err != nil { - t.Fatalf("marshal value: %v", err) - } - return string(data) -} - -func dailyBundleServer(t *testing.T) *httptest.Server { - t.Helper() - return dailyBundleServerWithConvectiveResponse(t, emptyConvectiveOutlooksResponse) -} - -func dailyBundleServerWithConvectiveResponse(t *testing.T, convectiveResponse string) *httptest.Server { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/observations": - _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) - case "/conditions/current": - _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear","temperatureF":75,"relativeHumidityPercent":56,"windSpeedMph":8}}`)) - case "/forecast/hourly": - _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) - case "/forecast/narrative": - _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`)) - case "/alerts/active": - _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Flood Watch","effective":"2026-05-29T05:00:00-05:00","expires":"2026-05-29T09:00:00-05:00"}]}}`)) - case "/discussion": - _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for generated report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for generated report."}}}`)) - case "/weatherstories/latest": - _, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`)) - case "/outlooks/convective": - _, _ = w.Write([]byte(convectiveResponse)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - return server -} - -func hourlyBundleServer(t *testing.T) *httptest.Server { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/observations": - _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T13:20:00Z","conditionCode":3}}`)) - case "/conditions/current": - _, _ = w.Write([]byte(`{"data":{"conditionText":"Cloudy","temperatureF":72,"relativeHumidityPercent":70,"windSpeedMph":9}}`)) - case "/forecast/hourly": - _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T08:00:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T07:30:00-05:00","endTime":"2026-05-29T08:30:00-05:00","textDescription":"Before-window storms","temperatureF":68,"probabilityOfPrecipitationPercent":90},{"startTime":"2026-05-29T08:00:00-05:00","endTime":"2026-05-29T09:00:00-05:00","textDescription":"Showers entering the area","temperatureF":70,"probabilityOfPrecipitationPercent":50},{"startTime":"2026-05-29T09:00:00-05:00","endTime":"2026-05-29T10:00:00-05:00","textDescription":"Brief dry break","temperatureF":72,"probabilityOfPrecipitationPercent":20},{"startTime":"2026-05-29T10:00:00-05:00","endTime":"2026-05-29T11:00:00-05:00","textDescription":"Thunderstorms increase","temperatureF":73,"probabilityOfPrecipitationPercent":80},{"startTime":"2026-05-29T11:00:00-05:00","endTime":"2026-05-29T12:00:00-05:00","textDescription":"Heavy rain","temperatureF":74,"probabilityOfPrecipitationPercent":70},{"startTime":"2026-05-29T13:00:00-05:00","endTime":"2026-05-29T14:00:00-05:00","textDescription":"Drying out","temperatureF":76,"probabilityOfPrecipitationPercent":10},{"startTime":"2026-05-29T14:30:00-05:00","endTime":"2026-05-29T15:30:00-05:00","textDescription":"After-window rain","temperatureF":77,"probabilityOfPrecipitationPercent":60}]}}`)) - case "/forecast/narrative": - _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T08:00:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Storms are possible today."}]}}`)) - case "/alerts/active": - _, _ = w.Write([]byte(`{"data":{"alerts":[{"event":"Expired Advisory","headline":"Ends at valid start","severity":"Minor","effective":"2026-05-29T06:00:00-05:00","expires":"2026-05-29T08:30:00-05:00"},{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate","instruction":"Avoid low-water crossings.","effective":"2026-05-29T11:00:00-05:00","expires":"2026-05-29T15:00:00-05:00"},{"event":"Evening Advisory","headline":"Starts at valid end","severity":"Minor","effective":"2026-05-29T14:30:00-05:00","expires":"2026-05-29T18:00:00-05:00"}]}}`)) - case "/discussion": - _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T08:05:00-05:00","keyMessages":["Storms are most likely late this morning."],"shortTerm":{"qualifier":"(Short Term)","text":"Short-term AFD narrative for hourly report."},"longTerm":{"qualifier":"(Long Term)","text":"Long-term AFD narrative for hourly report."}}}`)) - case "/weatherstories/latest": - _, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T20:00:00Z","updatedAt":"2026-05-29T13:05:00Z","title":"Hourly Storm Chances","description":"Scattered showers and thunderstorms are possible.","altText":"Weather story graphic with rain chances.","priority":true,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/hourly"}}`)) - case "/outlooks/convective": - _, _ = w.Write([]byte(`{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T13:30:00Z","issuedAt":"2026-05-29T13:00:00Z","outlooks":[{"id":"day1-hourly","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T10:00:00-05:00","validTo":"2026-05-29T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true},{"id":"day2-outside","day":2,"outlookType":"categorical","label":"ENH","labelText":"Day 2 outlook","severityRank":4,"validFrom":"2026-05-30T10:00:00-05:00","validTo":"2026-05-30T16:00:00-05:00","issuedAt":"2026-05-29T08:00:00-05:00","containsLocation":true}],"discussions":[{"day":1,"headline":"hourly severe storms","summary":"Scattered severe storms are possible.","discussion":"Damaging winds may occur during the hourly window.","updatedAt":"2026-05-29T08:15:00-05:00"},{"day":2,"headline":"Day 2 discussion","summary":"Later period risk.","discussion":"This day 2 discussion should not be retained.","updatedAt":"2026-05-29T08:20:00-05:00"}]}}`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - return server -} - -const emptyConvectiveOutlooksResponse = `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}` - -const qualifyingConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","updatedAt":"2026-05-29T16:05:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"SLGT","labelText":"Slight Risk","severityRank":3,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","issuedAt":"2026-05-29T10:45:00-05:00","expiresAt":"2026-05-30T07:00:00-05:00","containsLocation":true,"sourceUrl":"https://www.spc.noaa.gov/products/outlook/day1otlk.html","imageUrl":"https://www.spc.noaa.gov/products/outlook/day1probotlk.gif","geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Severe storms possible","summary":"Scattered severe storms are possible.","discussion":"Severe thunderstorms may produce damaging winds during the afternoon.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}` - -const lowerRiskConvectiveOutlooksResponse = `{"data":{"locationId":"home","locationName":"Brentwood","asOf":"2026-05-29T16:00:00Z","issuedAt":"2026-05-29T15:45:00Z","outlooks":[{"id":"day1-categorical","day":1,"outlookType":"categorical","label":"MRGL","labelText":"Marginal Risk","severityRank":2,"validFrom":"2026-05-29T11:00:00-05:00","validTo":"2026-05-30T07:00:00-05:00","containsLocation":true,"geometry":{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}}],"discussions":[{"day":1,"headline":"Low-end severe threat","summary":"An isolated severe storm cannot be ruled out.","discussion":"Low-end severe threat discussion.","updatedAt":"2026-05-29T11:15:00-05:00"}]}}` - -func TestBuildNotificationRequestUsesReportDefaultBundlePaths(t *testing.T) { - cfg := config.Defaults() - cfg.Location.ID = "home" - cfg.WeatherAPI.Timezone = "America/Chicago" - cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}" - location := mustLoadTestLocation(t, cfg.WeatherAPI.Timezone) - now := mustParse("2026-05-29T12:00:00-05:00") - registry := report.DefaultRegistry() - - tests := []struct { - id report.ID - req report.ResolveRequest - want func(state.Metadata) []string - source string - }{ - { - id: report.Hourly, - req: report.ResolveRequest{ - Now: now, - Location: location, - }, - want: func(metadata state.Metadata) []string { - return []string{"hourly/index.md"} - }, - source: "/managed/hourly.md", - }, - { - id: report.Daily, - req: report.ResolveRequest{ - Now: now, - Location: location, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, - want: func(metadata state.Metadata) []string { - return []string{ - "daily/2026-05-29/" + metadata.RunID + ".md", - "daily/2026-05-29/index.md", - } - }, - source: "/managed/daily.md", - }, - { - id: report.Today, - req: report.ResolveRequest{ - Now: now, - Location: location, - }, - want: func(metadata state.Metadata) []string { - return []string{ - "daily/2026-05-29/" + metadata.RunID + ".md", - "daily/2026-05-29/index.md", - "today/index.md", - } - }, - source: "/managed/today.md", - }, - { - id: report.Tomorrow, - req: report.ResolveRequest{ - Now: now, - Location: location, - }, - want: func(metadata state.Metadata) []string { - return []string{ - "daily/2026-05-30/" + metadata.RunID + ".md", - "daily/2026-05-30/index.md", - "tomorrow/index.md", - } - }, - source: "/managed/tomorrow.md", - }, - } - - for _, tt := range tests { - t.Run(string(tt.id), func(t *testing.T) { - resolved, err := registry.Resolve(tt.id, tt.req) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - metadata := notificationMetadataForTest(resolved) - req, err := buildNotificationRequest(cfg, resolved, tt.source, metadata) - if err != nil { - t.Fatalf("buildNotificationRequest() error = %v", err) - } - want := tt.want(metadata) - if strings.Join(req.BundlePaths, "\n") != strings.Join(want, "\n") { - t.Fatalf("BundlePaths = %#v, want %#v", req.BundlePaths, want) - } - if req.ReportPath != tt.source { - t.Fatalf("ReportPath = %q, want %q", req.ReportPath, tt.source) - } - }) - } -} - -func TestBuildNotificationRequestUsesReportDistributorPathOverride(t *testing.T) { - cfg := config.Defaults() - cfg.Location.ID = "home" - cfg.WeatherAPI.Timezone = "America/Chicago" - cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}" - applyReportDistributorPathOverrides(t, &cfg, ` -reports: - daily: - distributor: - path_templates: - - "custom/{report_id}/{run_id}.md" -`) - location := mustLoadTestLocation(t, cfg.WeatherAPI.Timezone) - resolved, err := report.Resolve(report.Daily, report.ResolveRequest{ - Now: mustParse("2026-05-29T12:00:00-05:00"), - Location: location, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - metadata := notificationMetadataForTest(resolved) - - req, err := buildNotificationRequest(cfg, resolved, "/managed/daily.md", metadata) - if err != nil { - t.Fatalf("buildNotificationRequest() error = %v", err) - } - want := []string{"custom/daily/" + metadata.RunID + ".md"} - if strings.Join(req.BundlePaths, "\n") != strings.Join(want, "\n") { - t.Fatalf("BundlePaths = %#v, want override %#v", req.BundlePaths, want) - } -} - -func TestBuildNotificationRequestRequiresReportBundlePaths(t *testing.T) { - cfg := config.Defaults() - cfg.Location.ID = "home" - cfg.WeatherAPI.Timezone = "America/Chicago" - cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{report_id}" - location := mustLoadTestLocation(t, cfg.WeatherAPI.Timezone) - resolved, err := report.Resolve(report.Hourly, report.ResolveRequest{ - Now: mustParse("2026-05-29T12:00:00-05:00"), - Location: location, - }) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - resolved.Definition.DistributorPathTemplates = nil - metadata := notificationMetadataForTest(resolved) - - _, err = buildNotificationRequest(cfg, resolved, "/managed/hourly.md", metadata) - if err == nil { - t.Fatal("buildNotificationRequest() error = nil, want missing path templates error") - } - for _, want := range []string{`report "hourly"`, metadata.RunID, `/managed/hourly.md`, "no distributor path templates"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("error = %q, want %q", err.Error(), want) - } - } -} - -func TestBatchRunIDUsesUTCStartAndBatchName(t *testing.T) { - tests := []struct { - name string - startedAt time.Time - batch BatchKind - want string - }{ - { - name: "morning", - startedAt: mustParse("2026-05-29T05:00:00-05:00"), - batch: BatchMorning, - want: "20260529T100000.000000000Z_morning", - }, - { - name: "evening", - startedAt: mustParse("2026-05-29T18:30:45-05:00"), - batch: BatchEvening, - want: "20260529T233045.000000000Z_evening", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := batchRunID(tt.startedAt, tt.batch); got != tt.want { - t.Fatalf("batchRunID() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestRenderBatchNotificationIdentity(t *testing.T) { - cfg := config.Defaults() - cfg.Location.ID = "home" - cfg.WeatherAPI.Timezone = "America/Chicago" - cfg.Notify.Distributor.Batch.PipelineIDTemplate = "weatherreporter.{batch_started_date}" - cfg.Notify.Distributor.Batch.BundleIDTemplate = "weatherreporter.{location_id}.{batch}" - cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{bundle_id}.{batch_run_id}" - startedAt := mustParse("2026-05-30T03:30:00Z") - runID := batchRunID(startedAt, BatchEvening) - - identity, err := renderBatchNotificationIdentity(cfg, BatchEvening, runID, startedAt) - if err != nil { - t.Fatalf("renderBatchNotificationIdentity() error = %v", err) - } - - if identity.PipelineID != "weatherreporter.2026-05-29" { - t.Fatalf("PipelineID = %q, want local batch date", identity.PipelineID) - } - if identity.BundleID != "weatherreporter.home.evening" { - t.Fatalf("BundleID = %q, want rendered bundle id", identity.BundleID) - } - wantKey := "weatherreporter.home.evening.20260530T033000.000000000Z_evening" - if identity.IdempotencyKey != wantKey { - t.Fatalf("IdempotencyKey = %q, want %q", identity.IdempotencyKey, wantKey) - } -} - -func TestBatchResultJSONOmitsNilNotification(t *testing.T) { - data, err := json.Marshal(BatchResult{ - Batch: BatchMorning, - Reports: []BatchReportResult{}, - }) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - if strings.Contains(string(data), "notification") { - t.Fatalf("BatchResult JSON = %s, want no notification field", data) - } -} - -func TestBatchResultJSONIncludesNotification(t *testing.T) { - result := BatchResult{ - Batch: BatchEvening, - Notification: &BatchNotificationResult{ - Status: "accepted", - RunID: "distributor-run", - PipelineID: "weatherreporter", - BundleID: "weatherreporter.home.evening", - IdempotencyKey: "weatherreporter.home.evening.20260529T233000.000000000Z_evening", - Path: "notifications/batches/evening/2026-05-29/distributor.20260529T233000.000000000Z_evening.json", - IncludedReports: []BatchNotificationReport{ - { - ReportID: report.Tomorrow, - RunID: "20260529T233000.000000000Z_tomorrow", - SourcePath: "reports/tomorrow/2026-05-30/report.20260529T233000.000000000Z_tomorrow.md", - BundlePaths: []string{"tomorrow/index.md"}, - }, - }, - }, - Reports: []BatchReportResult{}, - } - - data, err := json.Marshal(result) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - - for _, want := range []string{ - `"notification":{`, - `"status":"accepted"`, - `"runId":"distributor-run"`, - `"pipelineId":"weatherreporter"`, - `"bundleId":"weatherreporter.home.evening"`, - `"idempotencyKey":"weatherreporter.home.evening.20260529T233000.000000000Z_evening"`, - `"path":"notifications/batches/evening/2026-05-29/distributor.20260529T233000.000000000Z_evening.json"`, - `"includedReports":[`, - `"reportId":"tomorrow"`, - `"sourcePath":"reports/tomorrow/2026-05-30/report.20260529T233000.000000000Z_tomorrow.md"`, - `"bundlePaths":["tomorrow/index.md"]`, - } { - if !strings.Contains(string(data), want) { - t.Fatalf("BatchResult JSON = %s, want %s", data, want) - } - } -} - -func TestBuildBatchNotificationRequestIncludesEveningReports(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - cfg.Notify.Distributor.Batch.PipelineIDTemplate = "weatherreporter.{batch}.{batch_started_date}" - cfg.Notify.Distributor.Batch.BundleIDTemplate = "weatherreporter.{location_id}.{batch}" - cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate = "{bundle_id}.{batch_run_id}" - startedAt := mustParse("2026-05-29T18:00:00-05:00") - planned, reports := plannedBatchNotificationReports(t, cfg, BatchEvening, startedAt, "2026-05-31", "2026-06-01") - runID := batchRunID(startedAt, BatchEvening) - - req, err := buildBatchNotificationRequest(cfg, BatchEvening, runID, startedAt, reports, planned) - if err != nil { - t.Fatalf("buildBatchNotificationRequest() error = %v", err) - } - - if req.Batch != BatchEvening || req.RunID != runID { - t.Fatalf("batch identity = %s/%s, want %s/%s", req.Batch, req.RunID, BatchEvening, runID) - } - if req.PipelineID != "weatherreporter.evening.2026-05-29" { - t.Fatalf("PipelineID = %q, want rendered batch pipeline", req.PipelineID) - } - if req.BundleID != "weatherreporter.home.evening" { - t.Fatalf("BundleID = %q, want rendered batch bundle id", req.BundleID) - } - wantKey := "weatherreporter.home.evening." + runID - if req.IdempotencyKey != wantKey { - t.Fatalf("IdempotencyKey = %q, want %q", req.IdempotencyKey, wantKey) - } - if !req.CreatedAt.Equal(startedAt) { - t.Fatalf("CreatedAt = %s, want %s", req.CreatedAt, startedAt) - } - if len(req.IncludedReports) != 3 { - t.Fatalf("IncludedReports = %d, want 3", len(req.IncludedReports)) - } - if len(req.Files) != 7 { - t.Fatalf("Files = %d, want report-specific mappings", len(req.Files)) - } - - wantBundlePaths := map[string]struct{}{} - for _, plannedReport := range planned { - resolved := plannedReport.Resolved - runID := resolved.Metadata().RunID - validStart := resolved.ValidPeriod.Start.In(mustLoadTestLocation(t, cfg.WeatherAPI.Timezone)).Format(timeutil.DateLayout) - switch resolved.Definition.ID { - case report.Tomorrow: - wantBundlePaths["daily/"+validStart+"/"+runID+".md"] = struct{}{} - wantBundlePaths["daily/"+validStart+"/index.md"] = struct{}{} - wantBundlePaths["tomorrow/index.md"] = struct{}{} - case report.Daily: - wantBundlePaths["daily/"+validStart+"/"+runID+".md"] = struct{}{} - wantBundlePaths["daily/"+validStart+"/index.md"] = struct{}{} - default: - t.Fatalf("unexpected planned report %s", resolved.Definition.ID) - } - } - gotBundlePaths := map[string]struct{}{} - gotSourcePaths := map[string]struct{}{} - for _, file := range req.Files { - gotBundlePaths[file.BundlePath] = struct{}{} - gotSourcePaths[file.SourcePath] = struct{}{} - } - for want := range wantBundlePaths { - if _, ok := gotBundlePaths[want]; !ok { - t.Fatalf("bundle paths = %#v, missing %q", gotBundlePaths, want) - } - } - for _, item := range reports { - if _, ok := gotSourcePaths[item.ReportPath]; !ok { - t.Fatalf("source paths = %#v, missing managed report path %q", gotSourcePaths, item.ReportPath) - } - } - - uploadReq := batchDistributorUploadRequest(req) - if uploadReq.PipelineID != req.PipelineID || uploadReq.BundleID != req.BundleID || uploadReq.IdempotencyKey != req.IdempotencyKey || !uploadReq.CreatedAt.Equal(startedAt) { - t.Fatalf("upload request = %#v, want batch notification identity", uploadReq) - } - if len(uploadReq.Files) != len(req.Files) { - t.Fatalf("upload files = %d, want %d", len(uploadReq.Files), len(req.Files)) - } -} - -func TestBuildBatchNotificationRequestUsesReportOverridesAndDefaults(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - applyReportDistributorPathOverrides(t, &cfg, ` -reports: - daily: - distributor: - path_templates: - - "custom-daily/{valid_start_date}/{run_id}.md" -`) - startedAt := mustParse("2026-05-29T18:00:00-05:00") - planned, reports := plannedBatchNotificationReports(t, cfg, BatchEvening, startedAt, "2026-05-31") - runID := batchRunID(startedAt, BatchEvening) - - req, err := buildBatchNotificationRequest(cfg, BatchEvening, runID, startedAt, reports, planned) - if err != nil { - t.Fatalf("buildBatchNotificationRequest() error = %v", err) - } - gotBundlePaths := map[string]struct{}{} - for _, file := range req.Files { - gotBundlePaths[file.BundlePath] = struct{}{} - } - for _, plannedReport := range planned { - reportRunID := plannedReport.Resolved.Metadata().RunID - switch plannedReport.Resolved.Definition.ID { - case report.Tomorrow: - for _, want := range []string{ - "daily/2026-05-30/" + reportRunID + ".md", - "daily/2026-05-30/index.md", - "tomorrow/index.md", - } { - if _, ok := gotBundlePaths[want]; !ok { - t.Fatalf("bundle paths = %#v, missing default path %q", gotBundlePaths, want) - } - } - case report.Daily: - want := "custom-daily/2026-05-31/" + reportRunID + ".md" - if _, ok := gotBundlePaths[want]; !ok { - t.Fatalf("bundle paths = %#v, missing override path %q", gotBundlePaths, want) - } - if _, ok := gotBundlePaths["daily/2026-05-31/index.md"]; ok { - t.Fatalf("bundle paths = %#v, want daily defaults replaced by override", gotBundlePaths) - } - } - } -} - -func TestBuildBatchNotificationRequestRejectsDuplicateBundlePaths(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - applyReportDistributorPathOverrides(t, &cfg, ` -reports: - tomorrow: - distributor: - path_templates: - - "index.md" - daily: - distributor: - path_templates: - - "index.md" -`) - startedAt := mustParse("2026-05-29T18:00:00-05:00") - planned, reports := plannedBatchNotificationReports(t, cfg, BatchEvening, startedAt, "2026-05-31") - - _, err := buildBatchNotificationRequest(cfg, BatchEvening, batchRunID(startedAt, BatchEvening), startedAt, reports, planned) - if err == nil { - t.Fatal("buildBatchNotificationRequest() error = nil, want duplicate path error") - } - for _, want := range []string{"duplicate bundle path", "index.md", "report", "run", "source path"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("error = %q, want %q", err.Error(), want) - } - } -} - -func TestBuildBatchNotificationRequestRejectsMissingReportPath(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - startedAt := mustParse("2026-05-29T18:00:00-05:00") - planned, reports := plannedBatchNotificationReports(t, cfg, BatchEvening, startedAt, "2026-05-31") - reports[0].ReportPath = "" - - _, err := buildBatchNotificationRequest(cfg, BatchEvening, batchRunID(startedAt, BatchEvening), startedAt, reports, planned) - if err == nil { - t.Fatal("buildBatchNotificationRequest() error = nil, want missing path error") - } - for _, want := range []string{"missing managed report path", string(reports[0].ReportID), reports[0].RunID} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("error = %q, want %q", err.Error(), want) - } - } -} - -func TestRunBatchContinuesAfterReportFailure(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") - cfg.WeatherAPI.BaseURL = "" - collector := &recordingCollector{result: &collection} - notifier := &recordingNotifier{} - renderer := &selectiveRenderer{ - failRenderPrompt: "weather.tomorrow_generated_text", - runBody: "# Batch Report\n", - } - - result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchMorning, - Now: mustParse("2026-05-29T05:00:00-05:00"), - Collector: collector, - Renderer: renderer, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) - } - - if result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 { - t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", result.Total, result.Succeeded, result.Failed) - } - if len(collector.requests) != 1 { - t.Fatalf("collector requests = %d, want one collection for batch", len(collector.requests)) - } - if renderer.runCalls != 0 || renderer.structuredRunCalls != 2 { - t.Fatalf("renderer calls run=%d structured=%d, want successful reports to continue", renderer.runCalls, renderer.structuredRunCalls) - } - if len(notifier.requests) != 0 || len(notifier.batchRequests) != 0 { - t.Fatalf("notification requests report=%d batch=%d, want none after report failure", len(notifier.requests), len(notifier.batchRequests)) - } - if result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "one or more reports failed" { - t.Fatalf("batch notification = %#v, want skipped after report failure", result.Notification) - } - var failedTomorrow bool - var succeededDaily bool - for _, item := range result.Reports { - if item.ReportID == report.Tomorrow && item.Status == "failed" && strings.Contains(item.Error, "render failed") { - failedTomorrow = true - } - if item.ReportID == report.Daily && item.Status == "succeeded" { - succeededDaily = true - } - if item.ReportID != report.Tomorrow && item.Status != "succeeded" { - t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status) - } - } - if !failedTomorrow { - t.Fatalf("reports = %#v, want failed Tomorrow item", result.Reports) - } - if !succeededDaily { - t.Fatalf("reports = %#v, want Daily report to continue after Tomorrow failure", result.Reports) - } -} - -func TestRunBatchMorningSendsOneBatchNotification(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") - cfg.WeatherAPI.BaseURL = "" - collector := &recordingCollector{result: &collection} - notifier := &recordingNotifier{batchResult: successfulBatchNotificationResult()} - - result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchMorning, - Now: mustParse("2026-05-29T05:00:00-05:00"), - Collector: collector, - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) - } - if result.Total != 3 || result.Succeeded != 3 || result.Failed != 0 { - t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/3/0", result.Total, result.Succeeded, result.Failed) - } - if len(notifier.requests) != 0 { - t.Fatalf("per-report notification requests = %#v, want none", notifier.requests) - } - if len(notifier.batchRequests) != 1 { - t.Fatalf("batch notification requests = %d, want 1", len(notifier.batchRequests)) - } - req := notifier.batchRequests[0] - if req.Batch != BatchMorning || req.RunID != "20260529T100000.000000000Z_morning" { - t.Fatalf("batch request identity = %s/%s, want morning run id", req.Batch, req.RunID) - } - if len(req.IncludedReports) != 3 || len(req.Files) != 8 { - t.Fatalf("batch request reports/files = %d/%d, want 3/8", len(req.IncludedReports), len(req.Files)) - } - for _, file := range req.Files { - if file.SourcePath == "" || file.BundlePath == "" { - t.Fatalf("batch file = %#v, want source and bundle path", file) - } - } - if result.Notification == nil || result.Notification.Status != "succeeded" || result.Notification.RunID != "batch-distributor-run" || result.Notification.Path == "" { - t.Fatalf("batch notification = %#v, want succeeded result with artifact path", result.Notification) - } - if len(result.Notification.IncludedReports) != 3 { - t.Fatalf("batch notification included reports = %d, want 3", len(result.Notification.IncludedReports)) - } - artifact := readBatchNotificationForTest(t, result.Notification.Path) - if artifact.Status != "succeeded" || artifact.Upload == nil || artifact.Upload.RunID != "batch-distributor-run" || artifact.RunStatus == nil { - t.Fatalf("batch notification artifact = %#v, want succeeded upload and run status", artifact) - } - if len(artifact.Reports) != 3 { - t.Fatalf("artifact included reports = %d, want 3", len(artifact.Reports)) - } -} - -func TestRunBatchSuppressesPerReportNotification(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") - cfg.WeatherAPI.BaseURL = "" - collector := &recordingCollector{result: &collection} - store := recordingFilesystemStore(t, cfg) - notifier := &recordingNotifier{err: errors.New("distributor unavailable")} - - result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchEvening, - Now: mustParse("2026-05-29T18:00:00-05:00"), - Collector: collector, - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - Store: store, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) - } - - if result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 { - t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/2/0", result.Total, result.Succeeded, result.Failed) - } - if len(collector.requests) != 1 { - t.Fatalf("collector requests = %d, want one collection for batch", len(collector.requests)) - } - if len(notifier.requests) != 0 { - t.Fatalf("notification requests = %#v, want none for batch-generated reports", notifier.requests) - } - if len(notifier.batchRequests) != 1 { - t.Fatalf("batch notification requests = %d, want 1", len(notifier.batchRequests)) - } - for _, item := range result.Reports { - if item.Status != "succeeded" { - t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status) - } - if item.NotificationStatus != "" || item.NotificationRunID != "" || item.NotificationPipelineID != "" || item.NotificationError != "" || item.NotificationPath != "" { - t.Fatalf("report %s notification fields = %#v, want empty per-report notification fields", item.ReportID, item) - } - metadata := readMetadataForTest(t, item.MetadataPath) - if metadata.NotificationPath != "" { - t.Fatalf("report %s metadata NotificationPath = %q, want empty", item.ReportID, metadata.NotificationPath) - } - } - for _, item := range result.Reports { - reportNotificationDir := filepath.Join(cfg.Workspace.Root, cfg.Workspace.NotificationsDir, string(item.ReportID)) - if _, err := os.Stat(reportNotificationDir); err == nil { - t.Fatalf("per-report notification directory %q exists, want none", reportNotificationDir) - } else if !os.IsNotExist(err) { - t.Fatalf("stat per-report notification directory %q: %v", reportNotificationDir, err) - } - } -} - -func TestRunBatchNotificationFailureKeepsReportItemsSucceeded(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") - cfg.WeatherAPI.BaseURL = "" - notifier := &recordingNotifier{batchErr: errors.New("batch upload rejected")} - - result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchEvening, - Now: mustParse("2026-05-29T18:00:00-05:00"), - Collector: &recordingCollector{result: &collection}, - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) - } - if result.Failed != 1 || result.Succeeded != 2 { - t.Fatalf("summary succeeded/failed = %d/%d, want report successes plus notification failure", result.Succeeded, result.Failed) - } - for _, item := range result.Reports { - if item.Status != "succeeded" { - t.Fatalf("report %s status = %s, want succeeded despite batch notification failure", item.ReportID, item.Status) - } - } - if result.Notification == nil || result.Notification.Status != "failed" || !strings.Contains(result.Notification.Error, "batch upload rejected") || result.Notification.Path == "" { - t.Fatalf("batch notification = %#v, want failed upload result", result.Notification) - } - artifact := readBatchNotificationForTest(t, result.Notification.Path) - if artifact.Status != "failed" || !strings.Contains(artifact.Error, "batch upload rejected") { - t.Fatalf("batch notification artifact = %#v, want failed upload error", artifact) - } - - err = RunBatch(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchEvening, - Now: mustParse("2026-05-29T18:00:00-05:00"), - Collector: &recordingCollector{result: &collection}, - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - Notifier: &recordingNotifier{batchErr: errors.New("batch upload rejected")}, - }) - var batchErr BatchError - if !errors.As(err, &batchErr) { - t.Fatalf("RunBatch() error = %T %v, want BatchError", err, err) - } - if batchErr.Result == nil || !batchNotificationFailed(batchErr.Result) || batchReportFailures(batchErr.Result) != 0 { - t.Fatalf("RunBatch() result = %#v, want notification-only batch failure", batchErr.Result) - } -} - -func TestRunBatchNotificationStatusErrorPersistsStatusReport(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") - cfg.WeatherAPI.BaseURL = "" - notifier := &recordingNotifier{ - batchResult: &NotificationResult{ - RunID: "batch-distributor-run", - Status: "accepted", - UploadStatus: "accepted", - StatusError: "status lookup unavailable", - Report: []byte(`{"actions":[{"action":"replace_older"}]}`), - }, - } - - result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchMorning, - Now: mustParse("2026-05-29T05:00:00-05:00"), - Collector: &recordingCollector{result: &collection}, - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) - } - if result.Failed != 0 || result.Notification == nil || result.Notification.Path == "" { - t.Fatalf("result = %#v, want status-error notification artifact without batch failure", result) - } - artifact := readBatchNotificationForTest(t, result.Notification.Path) - if artifact.StatusError != "status lookup unavailable" || artifact.RunStatus == nil || !strings.Contains(string(artifact.RunStatus.Report), "replace_older") { - t.Fatalf("batch notification artifact = %#v, want status error and raw status report", artifact) - } -} - -func TestRunBatchDisabledDistributorSkipsBatchNotification(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") - cfg.WeatherAPI.BaseURL = "" - notifier := &recordingNotifier{} - - result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchEvening, - Now: mustParse("2026-05-29T18:00:00-05:00"), - Collector: &recordingCollector{result: &collection}, - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) - } - if result.Notification != nil || result.Failed != 0 { - t.Fatalf("result notification/failed = %#v/%d, want disabled notification omitted", result.Notification, result.Failed) - } - if len(notifier.requests) != 0 || len(notifier.batchRequests) != 0 { - t.Fatalf("notification requests report=%d batch=%d, want none when distributor disabled", len(notifier.requests), len(notifier.batchRequests)) - } -} - -func TestRunBatchDisabledBatchNotificationSkipsNotifier(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - cfg.Notify.Distributor.Batch.Enabled = false - collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") - cfg.WeatherAPI.BaseURL = "" - notifier := &recordingNotifier{} - - result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchEvening, - Now: mustParse("2026-05-29T18:00:00-05:00"), - Collector: &recordingCollector{result: &collection}, - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) - } - if result.Notification != nil || result.Failed != 0 { - t.Fatalf("result notification/failed = %#v/%d, want disabled batch notification omitted", result.Notification, result.Failed) - } - if len(notifier.requests) != 0 || len(notifier.batchRequests) != 0 { - t.Fatalf("notification requests report=%d batch=%d, want none when batch notification disabled", len(notifier.requests), len(notifier.batchRequests)) - } -} - -func TestRunBatchUsesOutputDirectory(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31") - cfg.WeatherAPI.BaseURL = "" - collector := &recordingCollector{result: &collection} - outputDir := filepath.Join(t.TempDir(), "reports") - - result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchEvening, - Now: mustParse("2026-05-29T18:00:00-05:00"), - OutputDir: outputDir, - Collector: collector, - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) - } - if result.Failed != 0 || len(result.Reports) != 2 { - t.Fatalf("summary = %#v, want two successful reports", result) - } - if len(collector.requests) != 1 { - t.Fatalf("collector requests = %d, want one collection for evening batch", len(collector.requests)) - } - wantByReport := map[report.ID]string{ - report.Tomorrow: filepath.Join(outputDir, "tomorrow.md"), - report.Daily: filepath.Join(outputDir, "daily-2026-05-31.md"), - } - for _, item := range result.Reports { - want := wantByReport[item.ReportID] - if want == "" { - t.Fatalf("unexpected report item = %#v", item) - } - if item.OutputPath != want { - t.Fatalf("%s OutputPath = %q, want %q", item.ReportID, item.OutputPath, want) - } - if _, err := os.Stat(want); err != nil { - t.Fatalf("expected output copy %q: %v", want, err) - } - reportData, err := os.ReadFile(item.ReportPath) - if err != nil { - t.Fatalf("read managed report: %v", err) - } - copyData, err := os.ReadFile(want) - if err != nil { - t.Fatalf("read output copy: %v", err) - } - if string(copyData) != string(reportData) { - t.Fatalf("batch output copy differs from managed report for %s", item.ReportID) - } - } -} - -func TestRunBatchDynamicDailyReportsHaveDistinctIdentity(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyNotificationConfig(t, server) - collection := collectionWithFutureDailyForTest(t, cfg, "2026-05-31", "2026-06-01") - cfg.WeatherAPI.BaseURL = "" - collector := &recordingCollector{result: &collection} - notifier := &recordingNotifier{} - outputDir := filepath.Join(t.TempDir(), "reports") - - result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchEvening, - Now: mustParse("2026-05-29T18:00:00-05:00"), - OutputDir: outputDir, - Collector: collector, - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - Notifier: notifier, - }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) - } - if result.Failed != 0 || len(result.Reports) != 3 { - t.Fatalf("summary = %#v, want three successful reports", result) - } - if len(notifier.requests) != 0 { - t.Fatalf("notification requests = %#v, want none for batch-generated reports", notifier.requests) - } - if len(notifier.batchRequests) != 1 { - t.Fatalf("batch notification requests = %d, want 1", len(notifier.batchRequests)) - } - - dailyByDate := map[string]BatchReportResult{} - runIDs := map[string]struct{}{} - reportPaths := map[string]struct{}{} - metadataPaths := map[string]struct{}{} - dataPackagePaths := map[string]struct{}{} - for _, item := range result.Reports { - if _, ok := runIDs[item.RunID]; ok { - t.Fatalf("duplicate RunID in batch result: %q", item.RunID) - } - runIDs[item.RunID] = struct{}{} - if _, ok := reportPaths[item.ReportPath]; ok { - t.Fatalf("duplicate ReportPath in batch result: %q", item.ReportPath) - } - reportPaths[item.ReportPath] = struct{}{} - if _, ok := metadataPaths[item.MetadataPath]; ok { - t.Fatalf("duplicate MetadataPath in batch result: %q", item.MetadataPath) - } - metadataPaths[item.MetadataPath] = struct{}{} - if _, ok := dataPackagePaths[item.DataPackagePath]; ok { - t.Fatalf("duplicate DataPackagePath in batch result: %q", item.DataPackagePath) - } - dataPackagePaths[item.DataPackagePath] = struct{}{} - if item.ReportID == report.Daily { - if !strings.HasPrefix(item.RunID, "20260529T230000.000000000Z_daily_") { - t.Fatalf("Daily RunID = %q, want dated daily run id", item.RunID) - } - date := strings.TrimPrefix(filepath.Base(item.OutputPath), "daily-") - date = strings.TrimSuffix(date, ".md") - dailyByDate[date] = item - } - } - - for _, date := range []string{"2026-05-31", "2026-06-01"} { - item, ok := dailyByDate[date] - if !ok { - t.Fatalf("daily outputs = %#v, want Daily output for %s", dailyByDate, date) - } - if item.RunID != "20260529T230000.000000000Z_daily_"+date { - t.Fatalf("Daily %s RunID = %q, want date disambiguator", date, item.RunID) - } - if item.OutputPath != filepath.Join(outputDir, "daily-"+date+".md") { - t.Fatalf("Daily %s OutputPath = %q, want date-qualified copy", date, item.OutputPath) - } - } - - if len(notifier.batchRequests[0].IncludedReports) != len(result.Reports) { - t.Fatalf("batch included reports = %d, want %d", len(notifier.batchRequests[0].IncludedReports), len(result.Reports)) - } - for _, included := range notifier.batchRequests[0].IncludedReports { - if strings.Contains(included.SourcePath, outputDir) { - t.Fatalf("batch notification source path = %q, want managed report path outside output dir", included.SourcePath) - } - if _, ok := reportPaths[included.SourcePath]; !ok { - t.Fatalf("batch notification source path = %q, want one of %#v", included.SourcePath, reportPaths) - } - } - -} - -func TestRunBatchMorningUsesTodayOutputName(t *testing.T) { - server := dailyBundleServer(t) - cfg := dailyWorkspaceConfig(t, server) - outputDir := filepath.Join(t.TempDir(), "reports") - - result, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchMorning, - Now: mustParse("2026-05-29T05:00:00-05:00"), - OutputDir: outputDir, - Renderer: &selectiveRenderer{runBody: "# Batch Report\n"}, - }) - if err != nil { - t.Fatalf("RunBatchDetailed() error = %v", err) - } - if result.Failed != 0 || len(result.Reports) != 2 { - t.Fatalf("summary = %#v, want successful morning batch", result) - } - var todayItem *BatchReportResult - for i := range result.Reports { - if result.Reports[i].ReportID == report.Today { - todayItem = &result.Reports[i] - break - } - } - if todayItem == nil { - t.Fatalf("reports = %#v, want Today item", result.Reports) - } - want := filepath.Join(outputDir, "today.md") - if todayItem.OutputPath != want { - t.Fatalf("Today OutputPath = %q, want %q", todayItem.OutputPath, want) - } - if _, err := os.Stat(want); err != nil { - t.Fatalf("expected Today output copy %q: %v", want, err) - } - if strings.Contains(todayItem.ReportPath, outputDir) { - t.Fatalf("Today ReportPath = %q, want managed report path separate from output copy", todayItem.ReportPath) - } -} - -func TestRunBatchDetailedUsesProvidedCollector(t *testing.T) { - cfg := config.Defaults() - cfg.WeatherAPI.BaseURL = "" - cfg.Workspace.Root = t.TempDir() - collector := &recordingCollector{err: errors.New("batch collector failed")} - renderer := &recordingRenderer{} - - _, err := RunBatchDetailed(context.Background(), BatchRequest{ - Config: cfg, - Batch: BatchMorning, - Now: mustParse("2026-05-29T05:00:00-05:00"), - Collector: collector, - Renderer: renderer, - }) - if err == nil || !strings.Contains(err.Error(), "batch collector failed") { - t.Fatalf("RunBatchDetailed() error = %v, want fake collector error", err) - } - if len(collector.requests) != 1 { - t.Fatalf("collector requests = %d, want one planning collection", len(collector.requests)) - } - if renderer.renderCalls != 0 || renderer.runCalls != 0 || renderer.structuredRunCalls != 0 { - t.Fatalf("renderer calls render=%d run=%d structured=%d, want none after collection failure", renderer.renderCalls, renderer.runCalls, renderer.structuredRunCalls) - } -} - -func mustParse(value string) time.Time { - parsed, err := time.Parse(time.RFC3339, value) - if err != nil { - panic(err) - } - return parsed -} - -func dailyTestConfig(t *testing.T, server *httptest.Server) config.Config { - t.Helper() - cfg := config.Defaults() - cfg.WeatherAPI.BaseURL = server.URL + "/" - cfg.WeatherAPI.Timezone = "America/Chicago" - return cfg -} - -func dailyWorkspaceConfig(t *testing.T, server *httptest.Server) config.Config { - t.Helper() - cfg := dailyTestConfig(t, server) - cfg.Workspace.Root = t.TempDir() - return cfg -} - -func dailyNotificationConfig(t *testing.T, server *httptest.Server) config.Config { - t.Helper() - cfg := dailyWorkspaceConfig(t, server) - cfg.Notify.Distributor.Enabled = true - cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}" - return cfg -} - -func notificationMetadataForTest(resolved report.Resolved) state.Metadata { - metadata := resolved.Metadata() - return state.Metadata{ - RunID: metadata.RunID, - ReportID: metadata.ReportID, - PromptID: metadata.PromptID, - GeneratedAt: metadata.GeneratedAt, - Timezone: metadata.Timezone, - ValidPeriod: metadata.ValidPeriod, - } -} - -func applyReportDistributorPathOverrides(t *testing.T, cfg *config.Config, data string) { - t.Helper() - path := filepath.Join(t.TempDir(), "config.yml") - if err := os.WriteFile(path, []byte(data), 0o600); err != nil { - t.Fatalf("write config fixture: %v", err) - } - loaded, err := config.LoadFile(path) - if err != nil { - t.Fatalf("LoadFile() error = %v", err) - } - if cfg.Reports == nil { - cfg.Reports = map[string]config.ReportConfig{} - } - for key, reportCfg := range loaded.Reports { - cfg.Reports[key] = reportCfg - } -} - -func plannedBatchNotificationReports(t *testing.T, cfg config.Config, batch BatchKind, now time.Time, futureDailyDates ...string) ([]plannedBatchReport, []BatchReportResult) { - t.Helper() - collection := collectionWithFutureDailyForTest(t, cfg, futureDailyDates...) - planned, err := planBatchRun(BatchRequest{Config: cfg, Batch: batch}, now, collection) - if err != nil { - t.Fatalf("planBatchRun() error = %v", err) - } - store, err := state.NewFilesystemStore(cfg.Workspace) - if err != nil { - t.Fatalf("NewFilesystemStore() error = %v", err) - } - results := make([]BatchReportResult, 0, len(planned)) - for _, item := range planned { - metadata := item.Resolved.Metadata() - paths, err := store.Paths(item.Resolved) - if err != nil { - t.Fatalf("Paths(%s) error = %v", item.Resolved.Definition.ID, err) - } - results = append(results, BatchReportResult{ - ReportID: item.Resolved.Definition.ID, - RunID: metadata.RunID, - Status: "succeeded", - ReportPath: paths.RenderedReport, - }) - } - return planned, results -} - -func collectionForTest(t *testing.T, cfg config.Config) collect.Result { - t.Helper() - bundle, err := FetchBundle(context.Background(), FetchBundleRequest{Config: cfg}) - if err != nil { - t.Fatalf("FetchBundle() error = %v", err) - } - return collect.Result{Bundle: bundle} -} - -func collectionWithFutureDailyForTest(t *testing.T, cfg config.Config, dates ...string) collect.Result { - t.Helper() - collection := collectionForTest(t, cfg) - location := mustLoadTestLocation(t, cfg.WeatherAPI.Timezone) - for _, date := range dates { - collection.Bundle.Hourly.Periods = append(collection.Bundle.Hourly.Periods, fullDayPeriods(t, date, location)...) - } - return collection -} - -func resolveGenerateForTest(t *testing.T, cfg config.Config, req GenerateRequest, now string) report.Resolved { - t.Helper() - req.Config = cfg - resolved, err := ResolveGenerate(req, mustParse(now)) - if err != nil { - t.Fatalf("ResolveGenerate() error = %v", err) - } - return resolved -} - -func recordingFilesystemStore(t *testing.T, cfg config.Config) *recordingStore { - t.Helper() - filesystemStore, err := state.NewFilesystemStore(cfg.Workspace) - if err != nil { - t.Fatalf("NewFilesystemStore() error = %v", err) - } - return &recordingStore{Store: filesystemStore} -} - -func successfulNotificationResult() *NotificationResult { - return &NotificationResult{ - RunID: "distributor-run", - Status: "succeeded", - UploadStatus: "accepted", - PipelineID: "reports", - Report: []byte(`{"actions":[{"action":"replace_older"}]}`), - } -} - -func successfulBatchNotificationResult() *NotificationResult { - return &NotificationResult{ - RunID: "batch-distributor-run", - Status: "succeeded", - UploadStatus: "accepted", - Report: []byte(`{"actions":[{"action":"replace_older"}]}`), - } -} - -func successfulGeneratedTextRenderer(body string) *recordingRenderer { - return &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, - structuredRunBody: body, - } -} - -func hourlyTestConfig(t *testing.T, server *httptest.Server) config.Config { - t.Helper() - cfg := config.Defaults() - cfg.WeatherAPI.BaseURL = server.URL + "/" - cfg.WeatherAPI.Timezone = "America/Chicago" - cfg.Workspace.Root = t.TempDir() - cfg.Location.ID = "home" - cfg.Location.Name = "Brentwood" - cfg.Location.Region = "MO" - return cfg -} - -func hourlyGeneratedTextFixture(t *testing.T) (config.Config, report.Resolved, *recordingStore, *recordingNotifier, string) { - t.Helper() - server := hourlyBundleServer(t) - cfg := hourlyGeneratedTextConfig(t, server) - resolved, store, notifier, outputPath := resolveHourlyGeneratedTextFixture(t, cfg) - return cfg, resolved, store, notifier, outputPath -} - -func hourlyGeneratedTextConfig(t *testing.T, server *httptest.Server) config.Config { - t.Helper() - cfg := config.Defaults() - applyHourlyGeneratedTextSettings(&cfg, t, server) - return cfg -} - -func hourlyGeneratedTextConfigWithModules(t *testing.T, server *httptest.Server, modules []string) config.Config { - t.Helper() - var data strings.Builder - data.WriteString("reports:\n hourly:\n deterministic_modules:\n") - for _, id := range modules { - _, _ = fmt.Fprintf(&data, " - %s\n", id) - } - path := filepath.Join(t.TempDir(), "config.yml") - if err := os.WriteFile(path, []byte(data.String()), 0o600); err != nil { - t.Fatalf("write config fixture: %v", err) - } - cfg, err := config.LoadFile(path) - if err != nil { - t.Fatalf("LoadFile() error = %v", err) - } - applyHourlyGeneratedTextSettings(&cfg, t, server) - return cfg -} - -func applyHourlyGeneratedTextSettings(cfg *config.Config, t *testing.T, server *httptest.Server) { - t.Helper() - base := hourlyTestConfig(t, server) - cfg.WeatherAPI = base.WeatherAPI - cfg.Workspace = base.Workspace - cfg.Location = base.Location - cfg.Notify.Distributor.Enabled = true - cfg.Notify.Distributor.PipelineIDTemplate = "weatherreporter.{artifact_group}" - cfg.Notify.Distributor.BundleIDTemplate = "weatherreporter.{location_id}.{report_id}" - cfg.Notify.Distributor.IdempotencyKeyTemplate = "weatherreporter.{location_id}.{report_id}.{run_id}" -} - -func resolveHourlyGeneratedTextFixture(t *testing.T, cfg config.Config) (report.Resolved, *recordingStore, *recordingNotifier, string) { - t.Helper() - resolved := resolveGenerateForTest(t, cfg, GenerateRequest{Report: ReportHourly}, "2026-05-29T08:30:00-05:00") - return resolved, recordingFilesystemStore(t, cfg), &recordingNotifier{}, filepath.Join(t.TempDir(), "hourly-copy.md") -} - -func fakeScriptoriumBinary(t *testing.T) string { - t.Helper() - path := filepath.Join(t.TempDir(), "scriptorium") - script := `#!/bin/sh -set -eu - -command_name="${1:-}" -shift || true -prompt="" -output="" -while [ "$#" -gt 0 ]; do - case "$1" in - --prompt) - shift - prompt="${1:-}" - ;; - --out) - shift - output="${1:-}" - ;; - esac - shift || true -done - -case "$command_name" in - render) - printf '{"prepared":true}\n' - ;; - run) - if [ -z "$output" ]; then - printf 'missing output path\n' >&2 - exit 2 - fi - case "$prompt" in - weather.hourly_generated_text) - cat > "$output" <<'EOF' -{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"} -EOF - ;; - *) - cat > "$output" <<'EOF' -{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"} -EOF - ;; - esac - ;; - *) - printf 'unknown command: %s\n' "$command_name" >&2 - exit 2 - ;; -esac -` - if err := os.WriteFile(path, []byte(script), 0o755); err != nil { - t.Fatalf("write fake scriptorium binary: %v", err) - } - return path -} - -func validHourlyGeneratedTextJSON() string { - return `{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region.","confidence":"Medium"}` -} - -func validTomorrowGeneratedTextJSON() string { - return `{"summary":"Tomorrow starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}` -} - -func validTodayGeneratedTextJSON() string { - return `{"summary":"Today starts with showers before improving.","forecast_discussion":["Morning showers should taper as drier air arrives.","Afternoon conditions trend quieter."],"precipitation_timing":"The best rain chance is during the morning."}` -} - -func validDailyGeneratedTextJSON() string { - return `{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast.","Temperatures stay seasonable by afternoon."],"precipitation_timing":"Rain is most likely during the afternoon.","confidence":"Medium"}` -} - -func generateDailyReportForTest(t *testing.T, cfg config.Config) *ReportResult { - t.Helper() - cfg.Workspace.Root = t.TempDir() - resolved, err := ResolveGenerate(GenerateRequest{ - Config: cfg, - Report: ReportDaily, - Date: mustParse("2026-05-29T12:00:00-05:00"), - }, mustParse("2026-05-29T05:00:00-05:00")) - if err != nil { - t.Fatalf("ResolveGenerate() error = %v", err) - } - result, err := generateLegacyBatchReport(context.Background(), ReportRequest{ - Config: cfg, - Collection: collectionForTest(t, cfg), - Resolved: resolved, - Renderer: successfulRenderer("# Daily Report\n"), - }) - if err != nil { - t.Fatalf("generateLegacyBatchReport() error = %v", err) - } - return result -} - -func readDataPackageForTest(t *testing.T, result *ReportResult) []byte { - t.Helper() - data, err := os.ReadFile(result.DataPackagePath) - if err != nil { - t.Fatalf("read data package: %v", err) - } - return data -} - -func assertRichPromptHelperArtifacts(t *testing.T, result *ReportResult) { - t.Helper() - snapshotData, err := os.ReadFile(result.ModuleSnapshotPath) - if err != nil { - t.Fatalf("read module snapshot: %v", err) - } - for _, want := range []string{ - `"condition_text_lower"`, - `"hour_label"`, - `"text_description_lower"`, - `"mention_precipitation"`, - `"temperature_phrase_f"`, - `"dominant_condition_lower"`, - `"dominant_condition_display"`, - `"max_pop_time_label"`, - } { - if !strings.Contains(string(snapshotData), want) { - t.Fatalf("module snapshot missing rich helper field %q:\n%s", want, string(snapshotData)) - } - } - - renderContext, err := os.ReadFile(result.RenderContextPath) - if err != nil { - t.Fatalf("read render context: %v", err) - } - for _, want := range []string{ - `"condition_text_lower"`, - `"hour_label"`, - `"text_description_lower"`, - `"mention_precipitation"`, - `"temperature_phrase_f"`, - `"dominant_condition_lower"`, - `"dominant_condition_display"`, - `"max_pop_time_label"`, - } { - if !strings.Contains(string(renderContext), want) { - t.Fatalf("render context missing rich helper field %q:\n%s", want, string(renderContext)) - } - } -} - -func assertCuratedPromptDataPackage(t *testing.T, result *ReportResult) { - t.Helper() - data := readDataPackageForTest(t, result) - pkg, err := promptinput.LoadYAML(data) - if err != nil { - t.Fatalf("decode data package: %v", err) - } - - current, ok := pkg.Briefing.Values["current_conditions"].(map[string]any) - if !ok { - t.Fatalf("current_conditions = %#v, want prompt map", pkg.Briefing.Values["current_conditions"]) - } - assertMapOmitsKeys(t, "current_conditions", current, "condition_text_lower", "wind_direction_text") - - hourly, ok := pkg.Briefing.Values["hourly_forecast"].(map[string]any) - if !ok { - t.Fatalf("hourly_forecast = %#v, want prompt map", pkg.Briefing.Values["hourly_forecast"]) - } - periods, ok := hourly["periods"].([]any) - if !ok || len(periods) == 0 { - t.Fatalf("hourly_forecast.periods = %#v, want prompt periods", hourly["periods"]) - } - firstPeriod, ok := periods[0].(map[string]any) - if !ok { - t.Fatalf("hourly first period = %#v, want prompt map", periods[0]) - } - assertMapOmitsKeys(t, "hourly_forecast.periods[0]", firstPeriod, "hour_label", "text_description_lower", "mention_precipitation") - - dayparts, ok := pkg.Briefing.Values["derived_daypart_summaries"].(map[string]any) - if !ok { - t.Fatalf("derived_daypart_summaries = %#v, want prompt map", pkg.Briefing.Values["derived_daypart_summaries"]) - } - morning, ok := dayparts["morning"].(map[string]any) - if !ok { - t.Fatalf("derived_daypart_summaries.morning = %#v, want prompt map", dayparts["morning"]) - } - if morning["max_pop_time"] != "6:00 AM" { - t.Fatalf("derived_daypart_summaries.morning.max_pop_time = %#v, want friendly label", morning["max_pop_time"]) - } - assertMapOmitsKeys(t, "derived_daypart_summaries.morning", morning, "temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label") -} - -func assertMapOmitsKeys(t *testing.T, name string, value map[string]any, keys ...string) { - t.Helper() - for _, key := range keys { - if _, ok := value[key]; ok { - t.Fatalf("%s contains helper field %q: %#v", name, key, value) - } - } -} - -func assertNoStaleModuleIntervalKeys(t *testing.T, values map[string]any) { - t.Helper() - for name, value := range values { - if name == "metadata" { - continue - } - assertNoStaleIntervalKeys(t, "briefing."+name, value) - } -} - -func assertNoStaleIntervalKeys(t *testing.T, path string, value any) { - t.Helper() - switch typed := value.(type) { - case map[string]any: - for key, child := range typed { - switch key { - case "start_time", "end_time", "period", "start", "end": - t.Fatalf("%s has stale interval key %q in %#v", path, key, typed) - } - assertNoStaleIntervalKeys(t, path+"."+key, child) - } - case []any: - for i, child := range typed { - assertNoStaleIntervalKeys(t, fmt.Sprintf("%s[%d]", path, i), child) - } - } -} - -func assertPathsExist(t *testing.T, paths ...string) { - t.Helper() - for _, path := range paths { - if _, err := os.Stat(path); err != nil { - t.Fatalf("expected artifact %q: %v", path, err) - } - } -} - -func assertPathsMissing(t *testing.T, paths ...string) { - t.Helper() - for _, path := range paths { - if _, err := os.Stat(path); err == nil { - t.Fatalf("artifact %q exists, want missing", path) - } else if !os.IsNotExist(err) { - t.Fatalf("stat artifact %q: %v", path, err) - } - } -} - -func hourlyArtifactPaths(t *testing.T, store state.Store, resolved report.Resolved) state.ArtifactPaths { - t.Helper() - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - return paths -} - -func readMetadataForTest(t *testing.T, path string) state.Metadata { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read metadata %q: %v", path, err) - } - var metadata state.Metadata - if err := json.Unmarshal(data, &metadata); err != nil { - t.Fatalf("decode metadata %q: %v", path, err) - } - return metadata -} - -func readBatchNotificationForTest(t *testing.T, path string) state.BatchDistributorNotificationArtifact { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read batch notification %q: %v", path, err) - } - var artifact state.BatchDistributorNotificationArtifact - if err := json.Unmarshal(data, &artifact); err != nil { - t.Fatalf("decode batch notification %q: %v", path, err) - } - return artifact -} - -func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) { - t.Helper() - if err == nil { - t.Fatal("generateLegacyBatchReport() error = nil, want generated-text report error") - } - text := err.Error() - for _, want := range []string{ - fmt.Sprintf("generate report %q", resolved.Definition.ID), - fmt.Sprintf("run %q", resolved.Metadata().RunID), - operation, - } { - if !strings.Contains(text, want) { - t.Fatalf("error = %q, want %q", text, want) - } - } -} - -func assertNoGeneratedFailureSideEffects(t *testing.T, notifier *recordingNotifier, outputPath string) { - t.Helper() - if len(notifier.requests) != 0 { - t.Fatalf("notification requests = %#v, want none after generated-text failure", notifier.requests) - } - assertPathsMissing(t, outputPath) -} - -func savePriorRun(t *testing.T, store state.Store, resolved report.Resolved, snapshot module.Snapshot) { - t.Helper() - moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot) - if err != nil { - t.Fatalf("SaveModuleSnapshot() error = %v", err) - } - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - _, err = store.SaveMetadata(context.Background(), state.BuildMetadataFromBriefingMetadata(resolved, appBriefingMetadata(resolved), state.ArtifactPaths{ - ModuleSnapshot: moduleSnapshotPath, - Metadata: paths.Metadata, - DataPackage: paths.DataPackage, - Preflight: paths.Preflight, - RenderedReport: paths.RenderedReport, - })) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } -} - -func priorDailyModuleSnapshot(t *testing.T, resolved report.Resolved) module.Snapshot { - t.Helper() - low := 50 - high := 58 - precip := 10 - snapshot, err := module.NewSnapshot([]module.Output{ - {ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: map[string]any{ - "date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout), - "low_temp_f": low, - "high_temp_f": high, - "daily_precipitation_probability": precip, - }}, - {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ - "morning": map[string]any{ - "date": resolved.ValidPeriod.Start.Format(timeutil.DateLayout), - "period_begins": resolved.ValidPeriod.Start.Add(6 * time.Hour).Format("2006-01-02 at 3:04 PM"), - "period_ends": resolved.ValidPeriod.Start.Add(10 * time.Hour).Format("2006-01-02 at 3:04 PM"), - "temp_range_f": "50-58", - }, - }}, - {ID: module.PrecipTiming, StanzaName: "precip_timing", Value: map[string]any{ - "max_pop_percent": precip, - "max_pop_time": "6 AM", - }}, - {ID: module.AlertDigest, StanzaName: "alert_digest", Value: map[string]any{}}, - }) - if err != nil { - t.Fatalf("NewSnapshot() error = %v", err) - } - return snapshot -} - -func priorOutlookModuleSnapshot(t *testing.T, date string) module.Snapshot { - t.Helper() - precip := 10 - snapshot, err := module.NewSnapshot([]module.Output{ - {ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: map[string]any{ - date + "_morning": map[string]any{ - "date": date, - "period_begins": date + " at 6:00 AM", - "period_ends": date + " at 10:00 AM", - "temp_range_f": "50-58", - "max_pop_percent": precip, - "max_pop_time": "6 AM", - }, - }}, - }) - if err != nil { - t.Fatalf("NewSnapshot() error = %v", err) - } - return snapshot -} - -func appBriefingMetadata(resolved report.Resolved) briefing.Metadata { - return briefing.Metadata{ - RunID: resolved.Metadata().RunID, - ReportID: resolved.Definition.ID, - Variant: "today", - PromptID: resolved.Definition.PromptID, - GeneratedAt: resolved.GeneratedAt, - Units: "us", - Timezone: resolved.Timezone, - ValidPeriod: resolved.ValidPeriod, - } -} - -type recordingRenderer struct { - renderCalls int - runCalls int - structuredRunCalls int - renderRequest scriptorium.RenderRequest - structuredRunRequest scriptorium.StructuredRunRequest - renderResult *scriptorium.RenderResult - structuredRunResult *scriptorium.StructuredRunResult - err error - structuredRunErr error - structuredRunBody string - runBody string -} - -type recordingCollector struct { - result *collect.Result - err error - requests []collect.Request -} - -func (c *recordingCollector) Run(_ context.Context, req collect.Request) (*collect.Result, error) { - c.requests = append(c.requests, req) - if c.err != nil { - return nil, c.err - } - return c.result, nil -} - -type recordingStore struct { - state.Store - calls []string -} - -func (s *recordingStore) SaveModuleSnapshot(ctx context.Context, resolved report.Resolved, snapshot module.Snapshot) (string, error) { - s.calls = append(s.calls, "module_snapshot") - return s.Store.SaveModuleSnapshot(ctx, resolved, snapshot) -} - -func (s *recordingStore) SaveDataPackage(ctx context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) { - s.calls = append(s.calls, "data_package") - return s.Store.SaveDataPackage(ctx, resolved, pkg) -} - -func (s *recordingStore) SavePreflight(ctx context.Context, resolved report.Resolved, artifact state.PreflightArtifact) (string, error) { - s.calls = append(s.calls, "preflight") - return s.Store.SavePreflight(ctx, resolved, artifact) -} - -func (s *recordingStore) SaveGeneratedTextRaw(ctx context.Context, resolved report.Resolved, data []byte) (string, error) { - s.calls = append(s.calls, "generated_text_raw") - return s.Store.SaveGeneratedTextRaw(ctx, resolved, data) -} - -func (s *recordingStore) SaveGeneratedTextResult(ctx context.Context, resolved report.Resolved, value any) (string, error) { - s.calls = append(s.calls, "generated_text_result") - return s.Store.SaveGeneratedTextResult(ctx, resolved, value) -} - -func (s *recordingStore) SaveGeneratedText(ctx context.Context, resolved report.Resolved, data []byte) (string, error) { - s.calls = append(s.calls, "generated_text") - return s.Store.SaveGeneratedText(ctx, resolved, data) -} - -func (s *recordingStore) SaveRenderContext(ctx context.Context, resolved report.Resolved, value any) (string, error) { - s.calls = append(s.calls, "render_context") - return s.Store.SaveRenderContext(ctx, resolved, value) -} - -func (s *recordingStore) PrepareRenderedReport(ctx context.Context, resolved report.Resolved) (string, error) { - s.calls = append(s.calls, "prepare_report") - return s.Store.PrepareRenderedReport(ctx, resolved) -} - -func (s *recordingStore) SaveMetadata(ctx context.Context, metadata state.Metadata) (string, error) { - s.calls = append(s.calls, "metadata") - return s.Store.SaveMetadata(ctx, metadata) -} - -func successfulRenderer(_ string) *recordingRenderer { - return &recordingRenderer{ - renderResult: &scriptorium.RenderResult{ExitCode: 0}, - structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, - } -} - -type selectiveRenderer struct { - renderCalls int - runCalls int - structuredRunCalls int - failRenderPrompt string - runBody string -} - -type recordingNotifier struct { - requests []NotificationRequest - batchRequests []batchNotificationRequest - result *NotificationResult - batchResult *NotificationResult - err error - batchErr error - errByReport map[report.ID]error -} - -func (n *recordingNotifier) Notify(_ context.Context, req NotificationRequest) (*NotificationResult, error) { - n.requests = append(n.requests, req) - if err := n.errByReport[req.ReportID]; err != nil { - return nil, err - } - if n.err != nil { - return nil, n.err - } - if n.result != nil { - result := *n.result - if result.BundleID == "" { - result.BundleID = req.BundleID - } - if result.IdempotencyKey == "" { - result.IdempotencyKey = req.IdempotencyKey - } - if result.PipelineID == "" { - result.PipelineID = req.PipelineID - } - return &result, nil - } - return &NotificationResult{ - PipelineID: req.PipelineID, - BundleID: req.BundleID, - IdempotencyKey: req.IdempotencyKey, - Status: "accepted", - UploadStatus: "accepted", - }, nil -} - -func (n *recordingNotifier) NotifyBatch(_ context.Context, req batchNotificationRequest) (*NotificationResult, error) { - n.batchRequests = append(n.batchRequests, req) - if n.batchErr != nil { - return nil, n.batchErr - } - if n.batchResult != nil { - result := *n.batchResult - if result.BundleID == "" { - result.BundleID = req.BundleID - } - if result.IdempotencyKey == "" { - result.IdempotencyKey = req.IdempotencyKey - } - if result.PipelineID == "" { - result.PipelineID = req.PipelineID - } - return &result, nil - } - return &NotificationResult{ - PipelineID: req.PipelineID, - BundleID: req.BundleID, - IdempotencyKey: req.IdempotencyKey, - RunID: "batch-distributor-run", - Status: "accepted", - UploadStatus: "accepted", - }, nil -} - -func (r *selectiveRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) { - r.renderCalls++ - if req.PromptID == r.failRenderPrompt { - return &scriptorium.RenderResult{ExitCode: 1, Stderr: "render failed"}, errors.New("render failed") - } - return &scriptorium.RenderResult{ExitCode: 0}, nil -} - -func (r *selectiveRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) { - r.structuredRunCalls++ - body := validHourlyGeneratedTextJSON() - if req.PromptID == "weather.today_generated_text" { - body = validTodayGeneratedTextJSON() - } - if req.PromptID == "weather.tomorrow_generated_text" { - body = validTomorrowGeneratedTextJSON() - } - if req.PromptID == "weather.daily_generated_text" { - body = validDailyGeneratedTextJSON() - } - if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil { - return nil, err - } - return &scriptorium.StructuredRunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil -} - -func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) { - r.renderCalls++ - r.renderRequest = req - return r.renderResult, r.err -} - -func (r *recordingRenderer) StructuredRun(_ context.Context, req scriptorium.StructuredRunRequest) (*scriptorium.StructuredRunResult, error) { - r.structuredRunCalls++ - r.structuredRunRequest = req - body := r.structuredRunBody - if body == "" { - body = validGeneratedTextJSONForPrompt(req.PromptID) - } - if body != "" { - if err := os.WriteFile(req.OutputPath, []byte(body), 0o600); err != nil { - return nil, err - } - } - if r.structuredRunResult != nil { - r.structuredRunResult.OutputPath = req.OutputPath - } - return r.structuredRunResult, r.structuredRunErr -} - -func validGeneratedTextJSONForPrompt(promptID string) string { - switch promptID { - case "weather.daily_generated_text": - return validDailyGeneratedTextJSON() - case "weather.today_generated_text": - return validTodayGeneratedTextJSON() - case "weather.tomorrow_generated_text": - return validTomorrowGeneratedTextJSON() - case "weather.hourly_generated_text": - return validHourlyGeneratedTextJSON() - default: - return "" - } -} diff --git a/internal/app/batch_execution_test.go b/internal/app/batch_execution_test.go new file mode 100644 index 0000000..8d8e88a --- /dev/null +++ b/internal/app/batch_execution_test.go @@ -0,0 +1,48 @@ +package app + +import ( + "context" + "errors" + "testing" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/collect" + "gitea.maximumdirect.net/eric/weatherreporter/internal/config" + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" +) + +func TestRunBatchDetailedInspectsEveryCandidateBeforeCollection(t *testing.T) { + cfg := config.Defaults() + cfg.Workspace.Root = t.TempDir() + now := mustParse("2026-05-29T08:00:00-05:00") + req := BatchRequest{Config: cfg, Batch: BatchMorning, Now: now} + candidates, err := batchInspectionCandidates(req, now) + if err != nil { + t.Fatalf("batchInspectionCandidates() error = %v", err) + } + executor := &inspectionExecutor{profiles: map[string]promptexec.ProfileInspection{ + "default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"}, + }, prompts: map[string]promptexec.PromptInspection{}} + for _, candidate := range candidates { + executor.prompts[candidate.Definition.PromptID] = validPromptInspection(candidate.Definition) + } + collector := collectorFunc(func(context.Context, collect.Request) (*collect.Result, error) { + return nil, errors.New("collection reached") + }) + req.Executor = executor + req.Collector = collector + _, err = RunBatchDetailed(context.Background(), req) + if err == nil || err.Error() != "collection reached" { + t.Fatalf("RunBatchDetailed() error = %v, want collection error", err) + } + if len(executor.promptRequests) != 3 || len(executor.profileRequests) != 1 { + t.Fatalf("inspection calls = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests) + } +} + +type collectorFunc func(context.Context, collect.Request) (*collect.Result, error) + +func (f collectorFunc) Run(ctx context.Context, req collect.Request) (*collect.Result, error) { + return f(ctx, req) +} + +var _ Collector = collectorFunc(nil) diff --git a/internal/app/prompt_generate.go b/internal/app/prompt_generate.go index 1f4cc59..94fc395 100644 --- a/internal/app/prompt_generate.go +++ b/internal/app/prompt_generate.go @@ -21,6 +21,7 @@ type promptReportRequest struct { Collection collect.Result Inspection PromptInspectionResult DebugWriter *state.PromptDebugWriter + noNotify bool } func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) { @@ -315,7 +316,7 @@ func generatePromptReport(ctx context.Context, req promptReportRequest) (*Report result.ReportPath = reportPath finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{ Config: req.Config, Store: store, Resolved: req.Resolved, Metadata: metadata, - ManagedReportPath: reportPath, OutputPath: req.OutputPath, Notifier: req.Notifier, + ManagedReportPath: reportPath, OutputPath: req.OutputPath, Notifier: req.Notifier, noNotify: req.noNotify, }) result.OutputPath, result.NotificationPath = finalized.OutputPath, finalized.NotificationPath result.Metadata, result.MetadataPath, result.Notification = finalized.Metadata, finalized.MetadataPath, finalized.Notification diff --git a/internal/app/prompt_inspection.go b/internal/app/prompt_inspection.go index ea8d47e..e060fde 100644 --- a/internal/app/prompt_inspection.go +++ b/internal/app/prompt_inspection.go @@ -30,64 +30,100 @@ type PromptInspectionResult struct { ModelName string } +// PromptExecutionsInspectionRequest validates all prompt/profile combinations +// needed by a batch before collection begins. +type PromptExecutionsInspectionRequest struct { + Resolved []report.Resolved + Executor promptexec.Executor + Promptkit config.PromptkitConfig + LookupEnv func(string) (string, bool) +} + // InspectPromptExecution validates the exact prompt and profile needed for a // report before collection, execution, or durable writes begin. func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (PromptInspectionResult, error) { + results, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{ + Resolved: []report.Resolved{req.Resolved}, + Executor: req.Executor, + Promptkit: req.Promptkit, + LookupEnv: req.LookupEnv, + }) + if err != nil { + return PromptInspectionResult{}, err + } + return results[req.Resolved.Definition.ID], nil +} + +// InspectPromptExecutions validates exact prompt contracts and their unique +// effective profiles. It performs no collection, execution, or durable write. +func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspectionRequest) (map[report.ID]PromptInspectionResult, error) { if req.Executor == nil { - return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil) + return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil) } - definition := req.Resolved.Definition - if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" { - return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil) + results := make(map[report.ID]PromptInspectionResult, len(req.Resolved)) + profiles := map[string]promptexec.ProfileInspection{} + for _, resolved := range req.Resolved { + definition := resolved.Definition + if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" { + return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil) + } + inspection, err := req.Executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion) + if err != nil { + return nil, promptInspectionError("prompt inspection failed", err) + } + if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion { + return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil) + } + if !validPromptInput(inspection.Inputs) { + return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil) + } + if !validPromptOutput(definition, inspection.Output) { + return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil) + } + profileID := req.Promptkit.Profile + if profileID == "" { + profileID = inspection.DefaultProfileID + } + if strings.TrimSpace(profileID) == "" { + return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt has no execution profile", nil) + } + profile, ok := profiles[profileID] + if !ok { + profile, err = inspectPromptProfile(ctx, req.Executor, profileID, req.LookupEnv) + if err != nil { + return nil, err + } + profiles[profileID] = profile + } + results[definition.ID] = PromptInspectionResult{ + PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash, + ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName, + } } - inspection, err := req.Executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion) + return results, nil +} + +func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, profileID string, lookupEnv func(string) (string, bool)) (promptexec.ProfileInspection, error) { + profile, err := executor.InspectProfile(ctx, profileID) if err != nil { - return PromptInspectionResult{}, promptInspectionError("prompt inspection failed", err) - } - if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion { - return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil) - } - if !validPromptInput(inspection.Inputs) { - return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil) - } - if !validPromptOutput(definition, inspection.Output) { - return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil) - } - profileID := req.Promptkit.Profile - if profileID == "" { - profileID = inspection.DefaultProfileID - } - if strings.TrimSpace(profileID) == "" { - return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt has no execution profile", nil) - } - profile, err := req.Executor.InspectProfile(ctx, profileID) - if err != nil { - return PromptInspectionResult{}, promptInspectionError("profile inspection failed", err) + return promptexec.ProfileInspection{}, promptInspectionError("profile inspection failed", err) } if profile.ProfileID != profileID { - return PromptInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return the selected profile", nil) + return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return the selected profile", nil) } if profile.CredentialRequired { - return PromptInspectionResult{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil) + return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil) } if strings.TrimSpace(profile.APIKeyEnv) != "" { - lookupEnv := req.LookupEnv if lookupEnv == nil { lookupEnv = os.LookupEnv } value, present := lookupEnv(profile.APIKeyEnv) if !present || strings.TrimSpace(value) == "" { - return PromptInspectionResult{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil) + return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", nil) } } - return PromptInspectionResult{ - PromptID: inspection.PromptID, - PromptVersion: inspection.PromptVersion, - PromptHash: inspection.PromptHash, - ProfileID: profile.ProfileID, - BackendID: profile.BackendID, - ModelName: profile.ModelName, - }, nil + return profile, nil } func validPromptInput(inputs []promptexec.InputDefinition) bool { diff --git a/internal/app/prompt_inspection_test.go b/internal/app/prompt_inspection_test.go index 4aa0307..89d5cdb 100644 --- a/internal/app/prompt_inspection_test.go +++ b/internal/app/prompt_inspection_test.go @@ -107,6 +107,30 @@ func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) { } } +func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) { + first := inspectionResolved(t) + second := first + second.Definition.ID = report.Today + second.Definition.PromptID = "weather.today" + executor := &inspectionExecutor{ + prompt: validPromptInspection(first.Definition), + profiles: map[string]promptexec.ProfileInspection{ + "default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"}, + }, + } + executor.prompts = map[string]promptexec.PromptInspection{ + first.Definition.PromptID: validPromptInspection(first.Definition), + second.Definition.PromptID: validPromptInspection(second.Definition), + } + results, err := InspectPromptExecutions(context.Background(), PromptExecutionsInspectionRequest{Resolved: []report.Resolved{first, second}, Executor: executor}) + if err != nil { + t.Fatalf("InspectPromptExecutions() error = %v", err) + } + if len(results) != 2 || len(executor.profileRequests) != 1 { + t.Fatalf("results/profile requests = %#v/%#v, want two results and one profile inspection", results, executor.profileRequests) + } +} + type inspectionPromptRequest struct { id string version string @@ -114,6 +138,7 @@ type inspectionPromptRequest struct { type inspectionExecutor struct { prompt promptexec.PromptInspection + prompts map[string]promptexec.PromptInspection profiles map[string]promptexec.ProfileInspection promptErr error promptRequests []inspectionPromptRequest @@ -125,6 +150,9 @@ func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version if e.promptErr != nil { return promptexec.PromptInspection{}, e.promptErr } + if prompt, ok := e.prompts[id]; ok { + return prompt, nil + } return e.prompt, nil } diff --git a/internal/app/test_helpers_test.go b/internal/app/test_helpers_test.go new file mode 100644 index 0000000..58f9534 --- /dev/null +++ b/internal/app/test_helpers_test.go @@ -0,0 +1,21 @@ +package app + +import ( + "testing" + "time" +) + +func mustParse(value string) time.Time { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + panic(err) + } + return parsed +} + +func requireNoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} diff --git a/internal/cli/result.go b/internal/cli/result.go index 6f5f19d..e119e0e 100644 --- a/internal/cli/result.go +++ b/internal/cli/result.go @@ -87,13 +87,7 @@ func newGenerateSummary(result *app.ReportResult, err error) generateSummary { summary.MetadataPath = result.MetadataPath summary.DataPackagePath = result.DataPackagePath summary.PreparationPath = result.PreparationPath - if summary.PreparationPath == "" { - summary.PreparationPath = result.PreflightPath - } summary.ExecutionPath = result.ExecutionPath - if summary.ExecutionPath == "" { - summary.ExecutionPath = result.Metadata.GeneratedTextResultPath - } summary.LLMDebugPath = result.LLMDebugPath summary.GeneratedTextRawPath = result.GeneratedTextRawPath summary.GeneratedTextPath = result.GeneratedTextPath diff --git a/internal/cli/root.go b/internal/cli/root.go index 40a9627..3902599 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -20,8 +20,8 @@ Usage: weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet] weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet] - weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet] - weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--quiet] + weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet] + weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet] weatherreporter inspect reports [--config PATH] [--limit N] weatherreporter inspect metadata [--config PATH] RUN_ID weatherreporter inspect modules [--config PATH] RUN_ID @@ -98,18 +98,18 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr } type commonOptions struct { - ConfigPath string - Units string - Timezone string - Output string - OutputDir string - Quiet bool + ConfigPath string + Units string + Timezone string + Output string + OutputDir string + LLMDebugDir string + Quiet bool } type generateOptions struct { commonOptions - Date string - LLMDebugDir string + Date string } type inspectOptions struct { @@ -285,7 +285,11 @@ func (r Runner) resolveRunAction(args []string) (app.BatchRequest, commonOptions if err != nil { return app.BatchRequest{}, commonOptions{}, err } - return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, opts, nil + executor, err := r.promptExecutor(cfg.Promptkit) + if err != nil { + return app.BatchRequest{}, commonOptions{}, err + } + return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir, LLMDebugDir: opts.LLMDebugDir, Executor: executor}, opts, nil } func resolveRun(args []string) (app.BatchRequest, error) { @@ -298,7 +302,6 @@ func parseGenerateFlags(report app.ReportKind, args []string) (generateOptions, opts := generateOptions{} addCommonFlags(fs, &opts.commonOptions, true) fs.BoolVar(&opts.Quiet, "quiet", false, "suppress successful action output") - fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH") if report == app.ReportDaily || report == app.ReportToday { fs.StringVar(&opts.Date, "date", "", "report date in YYYY-MM-DD") } @@ -364,6 +367,7 @@ func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") fs.StringVar(&opts.Units, "units", "", "weather API units") fs.StringVar(&opts.Timezone, "tz", "", "weather API timezone") + fs.StringVar(&opts.LLMDebugDir, "llm-debug-dir", "", "write sensitive prompt debug artifacts under PATH") if includeOutput { fs.StringVar(&opts.Output, "out", "", "extra Markdown report copy path") } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go deleted file mode 100644 index 6bd4dd6..0000000 --- a/internal/cli/root_test.go +++ /dev/null @@ -1,1630 +0,0 @@ -package cli - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/app" - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" - "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" -) - -func testRunner() Runner { - return testRunnerWithClock(fixedClock()) -} - -func testRunnerWithClock(clock timeutil.Clock) Runner { - return Runner{Clock: clock, ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { - return cliPromptExecutor{}, nil - }} -} - -type cliPromptExecutor struct{} - -func (cliPromptExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) { - name := strings.TrimSuffix(strings.TrimPrefix(id, "weather."), "_generated_text") - return promptexec.PromptInspection{ - PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", DefaultProfileID: "test-profile", - Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}}, - Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: name + ".generated_text.schema.json"}, - }, nil -} - -func (cliPromptExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) { - return promptexec.ProfileInspection{ProfileID: id, BackendID: "test", ModelName: "test-model"}, nil -} - -func (cliPromptExecutor) Execute(_ context.Context, request promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) { - now := time.Now().UTC() - if err := callback(promptexec.Preparation{PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", DataPackagePath: request.DataPackagePath, StartedAt: now, EndedAt: now}, nil); err != nil { - return nil, err - } - raw := []byte(`{"summary": "Showers are possible during the selected day.", "forecast_discussion": ["A front will keep rain chances in the forecast."], "precipitation_timing": "Rain is most likely during the afternoon."}`) - if request.PromptID == "weather.today_generated_text" { - raw = []byte(`{"summary": "Today starts with showers before improving.", "forecast_discussion": ["Morning showers should taper as drier air arrives.", "Afternoon conditions trend quieter."], "precipitation_timing": "The best rain chance is during the morning."}`) - } - if request.PromptID == "weather.hourly_generated_text" { - raw = []byte(`{"summary":"Storm chances increase through late morning.","forecast_discussion":"A front will keep the region unsettled.","precipitation_timing":"A cold front is moving into the region."}`) - } - return &promptexec.Execution{RunID: "provider-run", PromptID: request.PromptID, PromptVersion: request.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: request.ProfileID, BackendID: "test", ModelName: "test-model", GeneratedHash: "generated-hash", StartedAt: now, EndedAt: now, DataPackagePath: request.DataPackagePath, RawOutput: raw, Validation: promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "generated_text.schema.json", nil)}, nil -} - -func TestRunHelpLongFlag(t *testing.T) { - output, err := runRootCommand(t, "--help") - if err != nil { - t.Fatalf("Run() error = %v", err) - } - - if !strings.Contains(output.stdout, "weatherreporter generate daily --date YYYY-MM-DD") { - t.Fatalf("help output missing generate command:\n%s", output.stdout) - } - if !strings.Contains(output.stdout, "generate today") { - t.Fatalf("help output missing today generate command:\n%s", output.stdout) - } - if !strings.Contains(output.stdout, "weatherreporter generate hourly") { - t.Fatalf("help output missing hourly generate command:\n%s", output.stdout) - } - if strings.Count(output.stdout, "--llm-debug-dir PATH") != 5 { - t.Fatalf("help output = %q, want debug flag for four generate commands and its option", output.stdout) - } - if !strings.Contains(output.stdout, "generate today") || !strings.Contains(output.stdout, "[--quiet]") { - t.Fatalf("help output missing quiet generate usage:\n%s", output.stdout) - } - if !strings.Contains(output.stdout, "run morning") || !strings.Contains(output.stdout, "--quiet Suppress successful generate and run output.") { - t.Fatalf("help output missing quiet run option:\n%s", output.stdout) - } - removedGenerateCommand := "generate " + strings.Join([]string{"near", "term"}, "-") - if strings.Contains(output.stdout, removedGenerateCommand) { - t.Fatalf("help output includes retired generate command:\n%s", output.stdout) - } - removedInspectCommand := "inspect " + "briefing" - if !strings.Contains(output.stdout, "inspect modules") || strings.Contains(output.stdout, removedInspectCommand) { - t.Fatalf("help output has wrong inspect commands:\n%s", output.stdout) - } -} - -func TestRunHelpShortFlag(t *testing.T) { - output, err := runRootCommand(t, "-h") - if err != nil { - t.Fatalf("Run() error = %v", err) - } - - if !strings.Contains(output.stdout, "weatherreporter run evening") { - t.Fatalf("help output missing run command:\n%s", output.stdout) - } -} - -func TestRunUnknownCommand(t *testing.T) { - _, err := runRootCommand(t, "unknown") - if err == nil { - t.Fatal("Run() error = nil, want unknown command error") - } - - if !strings.Contains(err.Error(), `unknown command "unknown"`) { - t.Fatalf("Run() error = %q, want unknown command message", err.Error()) - } -} - -func TestRunGenerateTomorrowWritesMarkdownReport(t *testing.T) { - fixture := newCLIFixture(t, writeFakeScriptorium) - outPath := fixture.path("tomorrow.md") - runner := testRunner() - - _, err := runTestCommand(t, runner, - "generate", "tomorrow", - "--config", fixture.configPath, - "--out", outPath, - ) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - assertFileContains(t, outPath, "# Saturday's Weather") - dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml") - assertFileContains(t, dataPackagePath, "id: tomorrow") - assertFileContains(t, dataPackagePath, "tomorrow_planning:") - reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md") - if !strings.Contains(filepath.Base(reportPath), "tomorrow") { - t.Fatalf("managed report = %q, want tomorrow report", reportPath) - } -} - -func TestRunGenerateWritesRequestedDebugAndSafeSummary(t *testing.T) { - fixture := newCLIFixture(t, writeFakeScriptorium) - debugDir := fixture.path("prompt-debug") - output, err := runTestCommand(t, testRunner(), - "generate", "today", "--config", fixture.configPath, "--llm-debug-dir", debugDir, - ) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - summary := decodeGenerateSummary(t, output.stdout) - if summary.LLMDebugPath == "" || !strings.HasPrefix(summary.LLMDebugPath, debugDir+string(filepath.Separator)) { - t.Fatalf("debug path = %q, want isolated requested directory", summary.LLMDebugPath) - } - if strings.Contains(output.stdout, "Showers are possible during the selected day") || strings.Contains(output.stdout, "Today starts with showers before improving") || strings.Contains(output.stdout, "rendered-hash") { - t.Fatalf("summary contains prompt or generated content:\n%s", output.stdout) - } - assertFileContains(t, filepath.Join(summary.LLMDebugPath, "preparation.json"), "weather.today_generated_text") - assertFileContains(t, filepath.Join(summary.LLMDebugPath, "execution.json"), "Today starts with showers before improving.") -} - -func TestParseGenerateFlagsAcceptsDebugDirectoryForEveryReport(t *testing.T) { - for _, reportKind := range []app.ReportKind{app.ReportDaily, app.ReportToday, app.ReportTomorrow, app.ReportHourly} { - t.Run(string(reportKind), func(t *testing.T) { - opts, err := parseGenerateFlags(reportKind, []string{"--llm-debug-dir", "/tmp/prompt-debug"}) - if err != nil || opts.LLMDebugDir != "/tmp/prompt-debug" { - t.Fatalf("parseGenerateFlags() options/error = %#v/%v", opts, err) - } - }) - } -} - -func TestRunEveningGeneratesTomorrowReport(t *testing.T) { - fixture := newCLIFixture(t, writeFakeScriptorium) - runner := testRunner() - - _, err := runTestCommand(t, runner, - "run", "evening", - "--config", fixture.configPath, - ) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml") - reportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md") - if !strings.Contains(filepath.Base(reportPath), "tomorrow") { - t.Fatalf("managed report = %q, want only tomorrow report", reportPath) - } -} - -func TestRunMorningGeneratesTodayAndTomorrow(t *testing.T) { - fixture := newCLIFixture(t, writeFakeScriptorium) - runner := testRunner() - - _, err := runTestCommand(t, runner, - "run", "morning", - "--config", fixture.configPath, - ) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml") - _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml") - noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml") -} - -func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) { - fixture := newCLIFixture(t, writeFailingScriptorium) - runner := testRunner() - - output, err := runTestCommand(t, runner, - "run", "morning", - "--config", fixture.configPath, - ) - if err == nil { - t.Fatal("Run() error = nil, want aggregate failure") - } - if !strings.Contains(err.Error(), "1 of 2 reports failed") { - t.Fatalf("Run() error = %q, want aggregate failure", err.Error()) - } - - summary := decodeBatchSummary(t, output.stdout) - if summary.Command != "run" || summary.Status != "failed" { - t.Fatalf("summary command/status = %q/%q, want run/failed", summary.Command, summary.Status) - } - if summary.Total != 2 || summary.Succeeded != 1 || summary.Failed != 1 { - t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 2/1/1", summary.Total, summary.Succeeded, summary.Failed) - } - if !strings.Contains(output.stderr, "status=failed") || !strings.Contains(output.stderr, "status=succeeded") { - t.Fatalf("stderr missing structured report logs:\n%s", output.stderr) - } - _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml") - _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-05-30", "data_package.*.yaml") -} - -func TestBatchOutputIncludesTopLevelNotificationDetails(t *testing.T) { - result := &app.BatchResult{ - Batch: app.BatchMorning, - Total: 2, - Succeeded: 2, - Failed: 0, - Notification: &app.BatchNotificationResult{ - Status: "succeeded", - RunID: "batch-distributor-run", - PipelineID: "weatherreporter", - BundleID: "weatherreporter.home.morning", - IdempotencyKey: "weatherreporter.home.morning.20260529T120000.000000000Z_morning", - Path: "/tmp/distributor.batch.json", - IncludedReports: []app.BatchNotificationReport{ - {ReportID: "daily", RunID: "daily-run", SourcePath: "/tmp/daily.md", BundlePaths: []string{"daily.md"}}, - }, - }, - Reports: []app.BatchReportResult{ - { - ReportID: "daily", - Status: "succeeded", - OutputPath: "/tmp/daily.md", - }, - { - ReportID: "tomorrow", - Status: "succeeded", - OutputPath: "/tmp/tomorrow.md", - }, - }, - } - var stdout bytes.Buffer - var stderr bytes.Buffer - - if err := writeJSON(&stdout, result); err != nil { - t.Fatalf("writeJSON() error = %v", err) - } - writeBatchStatus(&stderr, result) - - var decoded app.BatchResult - if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { - t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String()) - } - if decoded.Notification == nil || decoded.Notification.Status != "succeeded" || decoded.Notification.RunID != "batch-distributor-run" || decoded.Notification.PipelineID != "weatherreporter" || len(decoded.Notification.IncludedReports) != 1 { - t.Fatalf("top-level notification = %#v, want succeeded batch notification", decoded.Notification) - } - for _, report := range decoded.Reports { - if report.NotificationStatus != "" || report.NotificationRunID != "" || report.NotificationError != "" { - t.Fatalf("report notification fields = %#v, want empty", report) - } - } - if count := strings.Count(stderr.String(), "batchNotification "); count != 1 { - t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String()) - } - if !strings.Contains(stderr.String(), `batchNotification status="succeeded"`) || !strings.Contains(stderr.String(), `runId="batch-distributor-run"`) || !strings.Contains(stderr.String(), `pipelineId="weatherreporter"`) { - t.Fatalf("stderr missing batch notification details:\n%s", stderr.String()) - } - if strings.Contains(stderr.String(), "notificationStatus") || strings.Contains(stderr.String(), "notificationRunId") { - t.Fatalf("stderr includes per-report notification fields:\n%s", stderr.String()) - } -} - -func TestBatchOutputDoesNotExposeSecretLikeNotificationErrors(t *testing.T) { - result := &app.BatchResult{ - Batch: app.BatchMorning, - Total: 1, - Failed: 1, - Notification: &app.BatchNotificationResult{ - Status: "failed", - Error: "notify batch morning: upload failed: [redacted]", - }, - Reports: []app.BatchReportResult{ - { - ReportID: "daily", - Status: "succeeded", - }, - }, - } - var stdout bytes.Buffer - var stderr bytes.Buffer - - if err := writeJSON(&stdout, result); err != nil { - t.Fatalf("writeJSON() error = %v", err) - } - writeBatchStatus(&stderr, result) - - for _, output := range []string{stdout.String(), stderr.String()} { - if strings.Contains(output, "DISTRIBUTOR_SECRET_TOKEN") { - t.Fatalf("output contains token value:\n%s", output) - } - if !strings.Contains(output, "[redacted]") { - t.Fatalf("output missing redacted marker:\n%s", output) - } - } -} - -func TestBatchStatusIncludesSkippedBatchNotification(t *testing.T) { - result := &app.BatchResult{ - Batch: app.BatchMorning, - Total: 2, - Succeeded: 1, - Failed: 1, - Notification: &app.BatchNotificationResult{ - Status: "skipped", - Reason: "one or more reports failed", - }, - Reports: []app.BatchReportResult{ - {ReportID: "today", Status: "succeeded", OutputPath: "/tmp/today.md"}, - {ReportID: "tomorrow", Status: "failed", Error: "render failed"}, - }, - } - var stderr bytes.Buffer - var stdout bytes.Buffer - - if err := writeJSON(&stdout, result); err != nil { - t.Fatalf("writeJSON() error = %v", err) - } - writeBatchStatus(&stderr, result) - - var decoded app.BatchResult - if err := json.Unmarshal(stdout.Bytes(), &decoded); err != nil { - t.Fatalf("decode batch JSON: %v\n%s", err, stdout.String()) - } - if decoded.Notification == nil || decoded.Notification.Status != "skipped" || decoded.Notification.Reason != "one or more reports failed" { - t.Fatalf("top-level notification = %#v, want skipped notification", decoded.Notification) - } - if count := strings.Count(stderr.String(), "batchNotification "); count != 1 { - t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String()) - } - if !strings.Contains(stderr.String(), `batchNotification status="skipped" reason="one or more reports failed"`) { - t.Fatalf("stderr missing skipped batch notification:\n%s", stderr.String()) - } -} - -func TestBatchStatusDoesNotRepeatBatchNotificationErrorPerReport(t *testing.T) { - result := &app.BatchResult{ - Batch: app.BatchEvening, - Total: 1, - Succeeded: 1, - Failed: 1, - Notification: &app.BatchNotificationResult{ - Status: "failed", - Error: "notify batch evening: upload failed", - }, - Reports: []app.BatchReportResult{ - {ReportID: "tomorrow", Status: "succeeded", OutputPath: "/tmp/tomorrow.md"}, - }, - } - var stderr bytes.Buffer - - writeBatchStatus(&stderr, result) - - if count := strings.Count(stderr.String(), "batchNotification "); count != 1 { - t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String()) - } - if count := strings.Count(stderr.String(), "notify batch evening: upload failed"); count != 1 { - t.Fatalf("stderr batch notification error occurrences = %d, want one:\n%s", count, stderr.String()) - } - reportLine := firstLineWithPrefix(stderr.String(), "report=tomorrow ") - if strings.Contains(reportLine, "notify batch evening") || strings.Contains(reportLine, "notificationError") { - t.Fatalf("report line repeats batch notification error:\n%s", reportLine) - } -} - -func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) { - fixture := newCLIFixture(t, writeFakeScriptorium) - outputDir := fixture.path("copies") - runner := testRunner() - - output, err := runTestCommand(t, runner, - "run", "evening", - "--config", fixture.configPath, - "--out-dir", outputDir, - ) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - summary := decodeBatchSummary(t, output.stdout) - if summary.Command != "run" || summary.Status != "succeeded" { - t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status) - } - if summary.Total != 1 || summary.Failed != 0 { - t.Fatalf("summary total/failed = %d/%d, want 1/0", summary.Total, summary.Failed) - } - if _, err := os.Stat(filepath.Join(outputDir, "tomorrow.md")); err != nil { - t.Fatalf("expected copied report: %v", err) - } - if len(summary.Reports) != 1 || summary.Reports[0].OutputPath != filepath.Join(outputDir, "tomorrow.md") { - t.Fatalf("summary reports = %#v, want output path", summary.Reports) - } -} - -func TestRunQuietSuppressesSuccessfulOutput(t *testing.T) { - fixture := newCLIFixture(t, writeFakeScriptorium) - runner := testRunner() - - output, err := runTestCommand(t, runner, - "run", "evening", - "--config", fixture.configPath, - "--quiet", - ) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - if output.stdout != "" || output.stderr != "" { - t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr) - } - _ = oneArtifact(t, fixture.workspaceRoot, "reports", "tomorrow", "2026-05-30", "report.*.md") -} - -func TestRunEveningReportsOmitsPerReportNotification(t *testing.T) { - server := dailyServer(t) - var uploadCount int - distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/runs/batch-distributor-run" { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"run_id":"batch-distributor-run","pipeline_id":"weatherreporter","status":"succeeded","report":{"actions":[{"action":"replace_older"}]}}`)) - return - } - if r.URL.Path != "/v1/pipelines/weatherreporter/upload" { - http.NotFound(w, r) - return - } - uploadCount++ - w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte(`{"run_id":"batch-distributor-run","status":"accepted"}`)) - })) - t.Cleanup(distributorServer.Close) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) - t.Setenv("CLI_DISTRIBUTOR_TOKEN", "cli-secret-token") - var stdout bytes.Buffer - var stderr bytes.Buffer - runner := testRunner() - - err := runner.Run(context.Background(), []string{ - "run", "evening", - "--config", configPath, - }, &stdout, &stderr) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - - summary := decodeBatchSummary(t, stdout.String()) - if summary.Command != "run" || summary.Status != "succeeded" { - t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status) - } - if len(summary.Reports) != 1 { - t.Fatalf("reports = %#v, want one report", summary.Reports) - } - if uploadCount != 1 { - t.Fatalf("batch upload count = %d, want 1", uploadCount) - } - if summary.Notification == nil || summary.Notification.Status != "succeeded" || summary.Notification.RunID != "batch-distributor-run" { - t.Fatalf("batch notification = %#v, want succeeded batch notification", summary.Notification) - } - if count := strings.Count(stderr.String(), "batchNotification "); count != 1 { - t.Fatalf("stderr batch notification lines = %d, want one:\n%s", count, stderr.String()) - } - if !strings.Contains(stderr.String(), `batchNotification status="succeeded"`) || !strings.Contains(stderr.String(), `runId="batch-distributor-run"`) { - t.Fatalf("stderr missing batch notification success:\n%s", stderr.String()) - } - if summary.Reports[0].NotificationStatus != "" || summary.Reports[0].NotificationRunID != "" || summary.Reports[0].NotificationPipelineID != "" || summary.Reports[0].NotificationError != "" || summary.Reports[0].NotificationPath != "" { - t.Fatalf("notification fields = %#v, want empty per-report notification fields", summary.Reports[0]) - } - if strings.Contains(stderr.String(), "notificationStatus") || strings.Contains(stderr.String(), "notificationRunId") { - t.Fatalf("stderr includes per-report notification fields:\n%s", stderr.String()) - } - if strings.Contains(stdout.String(), "cli-secret-token") || strings.Contains(stderr.String(), "cli-secret-token") { - t.Fatalf("output contains token value\nstdout=%s\nstderr=%s", stdout.String(), stderr.String()) - } -} - -func TestRunEveningReportsDoesNotRequirePerReportDistributorToken(t *testing.T) { - server := dailyServer(t) - distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected distributor request %s", r.URL.Path) - })) - t.Cleanup(distributorServer.Close) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfigWithDisabledBatchDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) - var stdout bytes.Buffer - var stderr bytes.Buffer - runner := testRunner() - - err := runner.Run(context.Background(), []string{ - "run", "evening", - "--config", configPath, - }, &stdout, &stderr) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - - summary := decodeBatchSummary(t, stdout.String()) - if summary.Command != "run" || summary.Status != "succeeded" { - t.Fatalf("summary command/status = %q/%q, want run/succeeded", summary.Command, summary.Status) - } - if len(summary.Reports) != 1 { - t.Fatalf("summary reports = %#v, want one report", summary.Reports) - } - if summary.Notification != nil { - t.Fatalf("batch notification = %#v, want omitted when disabled", summary.Notification) - } - if summary.Reports[0].NotificationStatus != "" || summary.Reports[0].NotificationError != "" { - t.Fatalf("notification fields = %#v, want empty per-report notification fields", summary.Reports[0]) - } -} - -func TestRunMorningGeneratesTodayAndTomorrowOnSunday(t *testing.T) { - fixture := newCLIFixture(t, writeFakeScriptorium) - var stdout bytes.Buffer - var stderr bytes.Buffer - runner := Runner{Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)}} - - err := runner.Run(context.Background(), []string{ - "run", "morning", - "--config", fixture.configPath, - }, &stdout, &stderr) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-31", "data_package.*.yaml") - _ = oneArtifact(t, fixture.workspaceRoot, "data-packages", "tomorrow", "2026-06-01", "data_package.*.yaml") - noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-31", "data_package.*.yaml") -} - -func TestRunGenerateDailyWritesMarkdownReport(t *testing.T) { - fixture := newCLIFixture(t, writeFakeScriptorium) - outPath := fixture.path("daily.md") - var stdout bytes.Buffer - var stderr bytes.Buffer - runner := testRunner() - - err := runner.Run(context.Background(), []string{ - "generate", "daily", - "--config", fixture.configPath, - "--date", "2026-05-29", - "--tz", "UTC", - "--out", outPath, - }, &stdout, &stderr) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - reportData, err := os.ReadFile(outPath) - if err != nil { - t.Fatalf("read report: %v", err) - } - if !strings.Contains(string(reportData), "# Friday's Weather") { - t.Fatalf("report output missing markdown:\n%s", string(reportData)) - } - dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml") - data, err := os.ReadFile(dataPackagePath) - if err != nil { - t.Fatalf("read managed data package: %v", err) - } - if !strings.Contains(string(data), "schema_version: weatherreporter.data_package.v3") || !strings.Contains(string(data), "id: daily") { - t.Fatalf("data package output missing expected content:\n%s", string(data)) - } - if !strings.Contains(string(data), "location:") || - !strings.Contains(string(data), "id: home") || - !strings.Contains(string(data), "name: Brentwood") || - !strings.Contains(string(data), "region: St. Louis Metro") || - !strings.Contains(string(data), "timezone: UTC") { - t.Fatalf("data package missing configured location with overridden timezone:\n%s", string(data)) - } - preparationPath := oneArtifact(t, fixture.workspaceRoot, "preflight", "daily", "2026-05-29", "prompt_preparation.*.json") - preparation, err := os.ReadFile(preparationPath) - if err != nil { - t.Fatalf("read preparation: %v", err) - } - if !strings.Contains(string(preparation), `"status": "succeeded"`) { - t.Fatalf("preparation missing successful status:\n%s", string(preparation)) - } - _ = oneArtifact(t, fixture.workspaceRoot, "reports", "daily", "2026-05-29", "report.*.md") - rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text_raw.*.json") - validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "generated_text.*.json") - renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "render_context.*.json") - metadataPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "daily", "2026-05-29", "metadata.*.json") - assertFileContains(t, rawGeneratedTextPath, `"summary": "Showers are possible during the selected day."`) - assertFileContains(t, validatedGeneratedTextPath, `"summary":"Showers are possible during the selected day."`) - assertFileContains(t, renderContextPath, `"Title": "Friday's Weather"`) - assertFileContains(t, metadataPath, `"generatedTextSchemaId": "daily"`) -} - -func TestRunGenerateTodayWritesGeneratedTextReport(t *testing.T) { - fixture := newCLIFixture(t, writeStructuredOutputScriptorium) - outPath := fixture.path("today.md") - var stdout bytes.Buffer - var stderr bytes.Buffer - runner := testRunner() - - err := runner.Run(context.Background(), []string{ - "generate", "today", - "--config", fixture.configPath, - "--date", "2026-05-29", - "--out", outPath, - }, &stdout, &stderr) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - reportData, err := os.ReadFile(outPath) - if err != nil { - t.Fatalf("read report: %v", err) - } - for _, want := range []string{ - "# Today's Weather", - "Today starts with showers before improving.", - "Morning showers should taper as drier air arrives.", - } { - if !strings.Contains(string(reportData), want) { - t.Fatalf("today report output missing %q:\n%s", want, string(reportData)) - } - } - dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml") - dataPackage, err := os.ReadFile(dataPackagePath) - if err != nil { - t.Fatalf("read managed data package: %v", err) - } - if !strings.Contains(string(dataPackage), "id: today") || - !strings.Contains(string(dataPackage), "prompt_id: weather.today_generated_text") || - !strings.Contains(string(dataPackage), "today_planning:") { - t.Fatalf("data package output missing Today content:\n%s", string(dataPackage)) - } - noArtifacts(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml") - rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "generated_text_raw.*.json") - validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "generated_text.*.json") - renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "today", "2026-05-29", "render_context.*.json") - managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "today", "2026-05-29", "report.*.md") - assertFileContains(t, rawGeneratedTextPath, `"summary": "Today starts with showers before improving."`) - assertFileContains(t, validatedGeneratedTextPath, `"summary":"Today starts with showers before improving."`) - assertFileContains(t, renderContextPath, `"Title": "Today's Weather"`) - assertFileContains(t, managedReportPath, "# Today's Weather") - - summary := decodeGenerateSummary(t, stdout.String()) - if summary.Command != "generate" || summary.Status != "succeeded" || summary.ReportID != report.Today { - t.Fatalf("generate summary = %#v, want successful Today summary", summary) - } - if summary.RunID == "" || summary.ReportPath == "" || summary.MetadataPath == "" || summary.DataPackagePath == "" || summary.PreparationPath == "" { - t.Fatalf("summary identity/paths = %#v, want run id and managed artifact paths", summary) - } - if summary.ExecutionPath == "" || summary.GeneratedTextRawPath == "" || summary.GeneratedTextPath == "" || summary.RenderContextPath == "" { - t.Fatalf("generated-text paths = %#v, want generated-text artifact paths", summary) - } - if summary.OutputPath != outPath { - t.Fatalf("summary OutputPath = %q, want %q", summary.OutputPath, outPath) - } -} - -func TestRunGenerateHourlyWritesGeneratedTextReport(t *testing.T) { - fixture := newCLIFixture(t, writeStructuredOutputScriptorium) - outPath := fixture.path("hourly.md") - var stdout bytes.Buffer - var stderr bytes.Buffer - runner := testRunnerWithClock(timeutil.FixedClock{Time: time.Date(2026, 5, 29, 11, 0, 0, 0, time.UTC)}) - - err := runner.Run(context.Background(), []string{ - "generate", "hourly", - "--config", fixture.configPath, - "--out", outPath, - }, &stdout, &stderr) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - reportData, err := os.ReadFile(outPath) - if err != nil { - t.Fatalf("read report: %v", err) - } - for _, want := range []string{ - "# Hourly Report", - "Storm chances increase through late morning.", - "A cold front is moving into the region.", - "A front will keep the region unsettled.", - } { - if !strings.Contains(string(reportData), want) { - t.Fatalf("report output missing %q:\n%s", want, string(reportData)) - } - } - dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "hourly", "2026-05-29", "data_package.*.yaml") - dataPackage, err := os.ReadFile(dataPackagePath) - if err != nil { - t.Fatalf("read managed data package: %v", err) - } - if !strings.Contains(string(dataPackage), "id: hourly") || - !strings.Contains(string(dataPackage), "prompt_id: weather.hourly_generated_text") || - !strings.Contains(string(dataPackage), "hourly_forecast:") { - t.Fatalf("data package output missing hourly content:\n%s", string(dataPackage)) - } - rawGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text_raw.*.json") - validatedGeneratedTextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "generated_text.*.json") - renderContextPath := oneArtifact(t, fixture.workspaceRoot, "snapshots", "hourly", "2026-05-29", "render_context.*.json") - managedReportPath := oneArtifact(t, fixture.workspaceRoot, "reports", "hourly", "2026-05-29", "report.*.md") - assertFileContains(t, rawGeneratedTextPath, `"summary":"Storm chances increase through late morning."`) - assertFileContains(t, validatedGeneratedTextPath, `"summary":"Storm chances increase through late morning."`) - assertFileContains(t, renderContextPath, `"Report": {`) - assertFileContains(t, renderContextPath, `"Title": "Hourly Report"`) - assertFileContains(t, renderContextPath, `"Modules": {`) - assertFileContains(t, renderContextPath, `"Collected": {`) - assertFileContains(t, renderContextPath, `"Derived": {`) - assertFileContains(t, managedReportPath, "# Hourly Report") -} - -func TestRunGenerateQuietSuppressesSuccessfulOutput(t *testing.T) { - fixture := newCLIFixture(t, writeStructuredOutputScriptorium) - outPath := fixture.path("today.md") - debugDir := fixture.path("prompt-debug") - runner := testRunner() - - output, err := runTestCommand(t, runner, - "generate", "today", - "--config", fixture.configPath, - "--date", "2026-05-29", - "--out", outPath, - "--llm-debug-dir", debugDir, - "--quiet", - ) - if err != nil { - t.Fatalf("Run() error = %v", err) - } - if output.stdout != "" || output.stderr != "" { - t.Fatalf("stdout/stderr = %q/%q, want quiet success output", output.stdout, output.stderr) - } - assertFileContains(t, outPath, "# Today's Weather") - _ = oneArtifact(t, debugDir, "today", "2026-05-29", "*", "preparation.json") -} - -func TestRunGeneratePreRunErrorEmitsNoJSON(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - runner := testRunner() - - err := runner.Run(context.Background(), []string{"generate", "daily"}, &stdout, &stderr) - if err == nil { - t.Fatal("Run() error = nil, want required date error") - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want no partial JSON", stdout.String()) - } -} - -func TestRunGenerateNotificationFailureEmitsFailureSummary(t *testing.T) { - server := dailyServer(t) - distributorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - t.Fatalf("unexpected distributor request with unset token: %s", r.URL.Path) - })) - t.Cleanup(distributorServer.Close) - tempDir := t.TempDir() - scriptoriumPath := writeFakeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - configPath := writeTestConfigWithDistributor(t, server, scriptoriumPath, workspaceRoot, distributorServer.URL) - t.Setenv("CLI_DISTRIBUTOR_TOKEN", "") - runner := testRunner() - - output, err := runTestCommand(t, runner, - "generate", "daily", - "--config", configPath, - "--date", "2026-05-29", - ) - if err == nil { - t.Fatal("Run() error = nil, want notification failure") - } - summary := decodeGenerateSummary(t, output.stdout) - if summary.Command != "generate" || summary.Status != "failed" || summary.Error == "" { - t.Fatalf("summary = %#v, want failed generate summary", summary) - } - if !strings.Contains(summary.Error, "token environment variable") { - t.Fatalf("summary error = %q, want token environment context", summary.Error) - } - if summary.ReportPath == "" || summary.MetadataPath == "" || summary.NotificationPath == "" { - t.Fatalf("summary paths = %#v, want inspectable report, metadata, and notification paths", summary) - } - if strings.Contains(output.stdout, "CLI_DISTRIBUTOR_TOKEN_VALUE") || strings.Contains(output.stderr, "CLI_DISTRIBUTOR_TOKEN_VALUE") { - t.Fatalf("output contains distributor token value\nstdout=%s\nstderr=%s", output.stdout, output.stderr) - } -} - -func TestRunInspectTodayArtifacts(t *testing.T) { - fixture := newCLIFixture(t, writeFakeScriptorium) - runner := testRunner() - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := runner.Run(context.Background(), []string{ - "generate", "today", - "--config", fixture.configPath, - "--date", "2026-05-29", - }, &stdout, &stderr) - if err != nil { - t.Fatalf("Run(generate) error = %v", err) - } - dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "today", "2026-05-29", "data_package.*.yaml") - runID := runIDFromDataPackagePath(t, dataPackagePath) - - stdout.Reset() - err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr) - if err != nil { - t.Fatalf("Run(inspect reports) error = %v", err) - } - if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"reportId": "today"`) { - t.Fatalf("inspect reports output missing Today run:\n%s", stdout.String()) - } - - var sourcesOutput string - for _, command := range []string{"metadata", "modules", "data-package", "sources"} { - stdout.Reset() - err = runner.Run(context.Background(), []string{"inspect", command, "--config", fixture.configPath, runID}, &stdout, &stderr) - if err != nil { - t.Fatalf("Run(inspect %s) error = %v", command, err) - } - if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), "today") { - t.Fatalf("inspect %s output missing Today run id:\n%s", command, stdout.String()) - } - if command == "sources" { - sourcesOutput = stdout.String() - } - } - if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) { - t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput) - } -} - -func TestRunInspectGeneratedArtifacts(t *testing.T) { - fixture := newCLIFixture(t, writeFakeScriptorium) - runner := testRunner() - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := runner.Run(context.Background(), []string{ - "generate", "daily", - "--config", fixture.configPath, - "--date", "2026-05-29", - }, &stdout, &stderr) - if err != nil { - t.Fatalf("Run(generate) error = %v", err) - } - dataPackagePath := oneArtifact(t, fixture.workspaceRoot, "data-packages", "daily", "2026-05-29", "data_package.*.yaml") - runID := runIDFromDataPackagePath(t, dataPackagePath) - - stdout.Reset() - err = runner.Run(context.Background(), []string{"inspect", "reports", "--config", fixture.configPath, "--limit", "1"}, &stdout, &stderr) - if err != nil { - t.Fatalf("Run(inspect reports) error = %v", err) - } - if !strings.Contains(stdout.String(), runID) || !strings.Contains(stdout.String(), `"metadataPath"`) { - t.Fatalf("inspect reports output missing run:\n%s", stdout.String()) - } - - var sourcesOutput string - for _, command := range []string{"metadata", "modules", "data-package", "sources"} { - stdout.Reset() - err = runner.Run(context.Background(), []string{"inspect", command, "--config", fixture.configPath, runID}, &stdout, &stderr) - if err != nil { - t.Fatalf("Run(inspect %s) error = %v", command, err) - } - if !strings.Contains(stdout.String(), runID) { - t.Fatalf("inspect %s output missing run id:\n%s", command, stdout.String()) - } - if command == "sources" { - sourcesOutput = stdout.String() - } - } - if !strings.Contains(sourcesOutput, `"name": "narrative"`) || strings.Contains(sourcesOutput, `"name": "daily"`) { - t.Fatalf("inspect sources output missing narrative source or has unexpected daily source:\n%s", sourcesOutput) - } -} - -func TestRunInspectMissingMetadata(t *testing.T) { - tempDir := t.TempDir() - configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) - var stdout bytes.Buffer - var stderr bytes.Buffer - runner := testRunner() - - err := runner.Run(context.Background(), []string{"inspect", "metadata", "--config", configPath, "missing"}, &stdout, &stderr) - if err == nil { - t.Fatal("Run(inspect metadata) error = nil, want missing metadata error") - } - if !strings.Contains(err.Error(), "metadata for run id") { - t.Fatalf("error = %q, want missing run id context", err.Error()) - } -} - -func TestRunInspectRejectsQuiet(t *testing.T) { - tempDir := t.TempDir() - configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) - var stdout bytes.Buffer - var stderr bytes.Buffer - runner := testRunner() - - err := runner.Run(context.Background(), []string{"inspect", "reports", "--config", configPath, "--quiet"}, &stdout, &stderr) - if err == nil { - t.Fatal("Run(inspect reports --quiet) error = nil, want unexpected flag error") - } - if !strings.Contains(err.Error(), "flag provided but not defined") { - t.Fatalf("error = %q, want unexpected quiet flag", err.Error()) - } -} - -func TestRunInspectRunCommandsParseRunIDAndConfig(t *testing.T) { - tempDir := t.TempDir() - configPath := writeWorkspaceConfig(t, filepath.Join(tempDir, "workspace")) - runner := testRunner() - commands := []string{"metadata", "modules", "data-package", "prior", "sources"} - - for _, command := range commands { - t.Run(command+" requires run id", func(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath}, &stdout, &stderr) - if err == nil { - t.Fatal("Run() error = nil, want missing run id error") - } - if !strings.Contains(err.Error(), "requires a run id") { - t.Fatalf("error = %q, want missing run id context", err.Error()) - } - }) - - t.Run(command+" accepts config", func(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - - err := runner.Run(context.Background(), []string{"inspect", command, "--config", configPath, "missing"}, &stdout, &stderr) - if err == nil { - t.Fatal("Run() error = nil, want missing metadata error") - } - if !strings.Contains(err.Error(), "metadata for run id") { - t.Fatalf("error = %q, want missing metadata context", err.Error()) - } - }) - } -} - -func TestResolveGenerateCommands(t *testing.T) { - runner := testRunner() - tests := []struct { - name string - args []string - want app.ReportKind - }{ - {name: "daily", args: []string{"daily", "--date", "2026-05-29"}, want: app.ReportDaily}, - {name: "today", args: []string{"today", "--date", "2026-05-29"}, want: app.ReportToday}, - {name: "tomorrow", args: []string{"tomorrow"}, want: app.ReportTomorrow}, - {name: "hourly", args: []string{"hourly"}, want: app.ReportHourly}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req, err := runner.resolveGenerate(tt.args) - if err != nil { - t.Fatalf("resolveGenerate() error = %v", err) - } - if req.Report != tt.want { - t.Fatalf("Report = %q, want %q", req.Report, tt.want) - } - }) - } -} - -func TestResolveGenerateSupportsEveryReportCommandName(t *testing.T) { - runner := testRunner() - for _, name := range report.CommandNames() { - t.Run(name, func(t *testing.T) { - args := []string{name} - if name == report.CommandNameDaily || name == report.CommandNameToday { - args = append(args, "--date", "2026-05-29") - } - req, err := runner.resolveGenerate(args) - if err != nil { - t.Fatalf("resolveGenerate() error = %v", err) - } - want, err := report.IDForCommandName(name) - if err != nil { - t.Fatalf("IDForCommandName() error = %v", err) - } - resolved, err := app.ResolveGenerate(req, req.Now) - if err != nil { - t.Fatalf("ResolveGenerate() error = %v", err) - } - if resolved.Definition.ID != want { - t.Fatalf("resolved ID = %q, want %q", resolved.Definition.ID, want) - } - }) - } -} - -func TestResolveGenerateHourlyAppliesSharedFlags(t *testing.T) { - runner := testRunner() - configPath := writeConfigFile(t, "weather_api:\n units: metric\n timezone: UTC\n") - - req, err := runner.resolveGenerate([]string{"hourly", "--config", configPath, "--units", "us", "--tz", "America/Chicago", "--out", "./hourly.md"}) - if err != nil { - t.Fatalf("resolveGenerate() error = %v", err) - } - - if req.Report != app.ReportHourly { - t.Fatalf("Report = %q, want hourly", req.Report) - } - if req.Config.WeatherAPI.Units != "us" { - t.Fatalf("Units = %q, want us", req.Config.WeatherAPI.Units) - } - if req.Config.WeatherAPI.Timezone != "America/Chicago" { - t.Fatalf("Timezone = %q, want America/Chicago", req.Config.WeatherAPI.Timezone) - } - if req.OutputPath != "./hourly.md" { - t.Fatalf("OutputPath = %q, want ./hourly.md", req.OutputPath) - } - if !req.Date.IsZero() { - t.Fatalf("Date = %s, want unset for hourly", req.Date) - } -} - -func TestResolveGenerateHourlyRejectsDateAndStormBounds(t *testing.T) { - runner := testRunner() - - for _, args := range [][]string{ - {"hourly", "--date", "2026-05-29"}, - {"hourly", "--start", "2026-05-29T18:00"}, - {"hourly", "--end", "2026-05-29T20:00"}, - {"hourly", "--hours", "6"}, - {"hourly", "--duration", "6h"}, - } { - _, err := runner.resolveGenerate(args) - if err == nil { - t.Fatalf("resolveGenerate(%v) error = nil, want flag error", args) - } - if !strings.Contains(err.Error(), "flag provided but not defined") { - t.Fatalf("resolveGenerate(%v) error = %q, want undefined flag error", args, err.Error()) - } - } -} - -func TestResolveGenerateDailyRequiresDate(t *testing.T) { - runner := testRunner() - - req, err := runner.resolveGenerate([]string{"daily"}) - if err == nil { - t.Fatal("resolveGenerate() error = nil, want required date error") - } - if !strings.Contains(err.Error(), "generate daily requires --date YYYY-MM-DD") { - t.Fatalf("resolveGenerate() error = %q, want required date context", err.Error()) - } - if !req.Date.IsZero() { - t.Fatalf("Date = %s, want unset on error", req.Date) - } -} - -func TestResolveGenerateDailyRejectsMalformedDate(t *testing.T) { - runner := testRunner() - - _, err := runner.resolveGenerate([]string{"daily", "--date", "bad-date"}) - if err == nil { - t.Fatal("resolveGenerate() error = nil, want date parse error") - } - if !strings.Contains(err.Error(), `parse date "bad-date" as YYYY-MM-DD`) { - t.Fatalf("resolveGenerate() error = %q, want date parse context", err.Error()) - } -} - -func TestResolveGenerateDailyParsesDate(t *testing.T) { - runner := testRunner() - - req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29"}) - if err != nil { - t.Fatalf("resolveGenerate() error = %v", err) - } - - if req.Report != app.ReportDaily { - t.Fatalf("Report = %q, want daily", req.Report) - } - if got := req.Date.Format(timeutil.DateLayout); got != "2026-05-29" { - t.Fatalf("Date = %s, want 2026-05-29", got) - } -} - -func TestResolveGenerateTodayDate(t *testing.T) { - runner := testRunner() - - defaultReq, err := runner.resolveGenerate([]string{"today"}) - if err != nil { - t.Fatalf("resolveGenerate(default) error = %v", err) - } - if defaultReq.Report != app.ReportToday { - t.Fatalf("Report = %q, want today", defaultReq.Report) - } - if got := defaultReq.Date.Format(timeutil.DateLayout); got != "2026-05-29" { - t.Fatalf("default Date = %s, want 2026-05-29", got) - } - - explicitReq, err := runner.resolveGenerate([]string{"today", "--date", "2026-05-30", "--tz", "UTC"}) - if err != nil { - t.Fatalf("resolveGenerate(explicit) error = %v", err) - } - if got := explicitReq.Date.Format(timeutil.DateLayout); got != "2026-05-30" { - t.Fatalf("explicit Date = %s, want 2026-05-30", got) - } - resolved, err := app.ResolveGenerate(explicitReq, explicitReq.Now) - if err != nil { - t.Fatalf("ResolveGenerate() error = %v", err) - } - if resolved.Definition.ID != report.Today { - t.Fatalf("resolved ID = %q, want today", resolved.Definition.ID) - } - if got := resolved.ValidPeriod.Start.Format(time.RFC3339); got != "2026-05-30T00:00:00Z" { - t.Fatalf("valid period start = %s, want explicit UTC date", got) - } -} - -func TestResolveGenerateAppliesSharedFlags(t *testing.T) { - runner := testRunner() - - req, err := runner.resolveGenerate([]string{"daily", "--date", "2026-05-29", "--units", "metric", "--tz", "UTC", "--out", "./daily.md"}) - if err != nil { - t.Fatalf("resolveGenerate() error = %v", err) - } - - if req.Config.WeatherAPI.Units != "metric" { - t.Fatalf("Units = %q, want metric", req.Config.WeatherAPI.Units) - } - if req.Config.WeatherAPI.Timezone != "UTC" { - t.Fatalf("Timezone = %q, want UTC", req.Config.WeatherAPI.Timezone) - } - if req.OutputPath != "./daily.md" { - t.Fatalf("OutputPath = %q, want ./daily.md", req.OutputPath) - } -} - -func TestResolveGenerateRejectsRetiredHourlyCommand(t *testing.T) { - runner := testRunner() - retired := strings.Join([]string{"near", "term"}, "-") - - _, err := runner.resolveGenerate([]string{retired}) - if err == nil { - t.Fatal("resolveGenerate() error = nil, want unknown report") - } - if !strings.Contains(err.Error(), "unknown generate report") { - t.Fatalf("error = %q, want unknown generate report", err.Error()) - } -} - -func TestResolveGenerateRejectsRetiredReports(t *testing.T) { - runner := testRunner() - for _, name := range []string{"three-day", "weekend", "storm"} { - if _, err := runner.resolveGenerate([]string{name}); err == nil { - t.Fatalf("resolveGenerate(%q) error = nil, want unknown report", name) - } - } -} - -func TestResolveRunCommands(t *testing.T) { - tests := []struct { - name string - args []string - want app.BatchKind - }{ - {name: "morning", args: []string{"morning"}, want: app.BatchMorning}, - {name: "evening", args: []string{"evening", "--tz", "UTC"}, want: app.BatchEvening}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req, err := resolveRun(tt.args) - if err != nil { - t.Fatalf("resolveRun() error = %v", err) - } - if req.Batch != tt.want { - t.Fatalf("Batch = %q, want %q", req.Batch, tt.want) - } - }) - } -} - -func TestResolveRunRejectsOutputFlag(t *testing.T) { - _, err := resolveRun([]string{"morning", "--out", "./report.md"}) - if err == nil { - t.Fatal("resolveRun() error = nil, want flag error") - } - if !strings.Contains(err.Error(), "flag provided but not defined") { - t.Fatalf("error = %q, want undefined flag error", err.Error()) - } -} - -func TestResolveRunAppliesOutputDirectory(t *testing.T) { - req, err := resolveRun([]string{"evening", "--out-dir", "./reports"}) - if err != nil { - t.Fatalf("resolveRun() error = %v", err) - } - if req.OutputDir != "./reports" { - t.Fatalf("OutputDir = %q, want ./reports", req.OutputDir) - } -} - -func fixedClock() timeutil.Clock { - return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)} -} - -type commandOutput struct { - stdout string - stderr string -} - -func runRootCommand(t *testing.T, args ...string) (commandOutput, error) { - t.Helper() - return runTestCommand(t, Runner{}, args...) -} - -func runTestCommand(t *testing.T, runner Runner, args ...string) (commandOutput, error) { - t.Helper() - var stdout bytes.Buffer - var stderr bytes.Buffer - err := runner.Run(context.Background(), args, &stdout, &stderr) - return commandOutput{ - stdout: stdout.String(), - stderr: stderr.String(), - }, err -} - -func decodeGenerateSummary(t *testing.T, text string) generateSummary { - t.Helper() - var summary generateSummary - if err := json.Unmarshal([]byte(text), &summary); err != nil { - t.Fatalf("decode generate summary: %v\n%s", err, text) - } - return summary -} - -func decodeBatchSummary(t *testing.T, text string) batchSummary { - t.Helper() - var summary batchSummary - if err := json.Unmarshal([]byte(text), &summary); err != nil { - t.Fatalf("decode batch summary: %v\n%s", err, text) - } - return summary -} - -func dailyServer(t *testing.T) *httptest.Server { - t.Helper() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/observations": - _, _ = w.Write([]byte(`{"data":{"timestamp":"2026-05-29T14:00:00Z","conditionCode":3}}`)) - case "/conditions/current": - _, _ = w.Write([]byte(`{"data":{"conditionText":"Clear"}}`)) - case "/forecast/hourly": - _, _ = w.Write([]byte(`{"data":{"locationId":"test-grid","locationName":"Testville","issuedAt":"2026-05-29T10:30:00-05:00","product":"hourly","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T07:00:00-05:00","textDescription":"Showers and thunderstorms","temperatureF":66,"probabilityOfPrecipitationPercent":80,"windGustMph":32}]}}`)) - case "/forecast/narrative": - _, _ = w.Write([]byte(`{"data":{"issuedAt":"2026-05-29T10:30:00-05:00","product":"narrative","periods":[{"startTime":"2026-05-29T06:00:00-05:00","endTime":"2026-05-29T18:00:00-05:00","textDescription":"Morning storms, then partly sunny."},{"startTime":"2026-05-30T06:00:00-05:00","endTime":"2026-05-30T18:00:00-05:00","textDescription":"Tomorrow starts stormy."}]}}`)) - case "/alerts/active": - _, _ = w.Write([]byte(`{"data":{"alerts":[]}}`)) - case "/discussion": - _, _ = w.Write([]byte(`{"data":{"product":"discussion","issuedAt":"2026-05-29T09:25:00-05:00","keyMessages":["Storms are most likely during the morning."]}}`)) - case "/weatherstories/latest": - _, _ = w.Write([]byte(`{"data":{"officeId":"LSX","startTime":"2026-05-30T08:46:00Z","endTime":"2026-05-31T11:00:00Z","updatedAt":"2026-05-30T09:00:34Z","title":"Several Chances for Rain Through Monday","description":"Scattered showers and thunderstorms remain possible.","altText":"Forecast weather story graphic.","priority":false,"order":1,"downloadUrl":"https://api.weather.gov/offices/LSX/weatherstories/download/test"}}`)) - case "/outlooks/convective": - _, _ = w.Write([]byte(`{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`)) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(server.Close) - return server -} - -type cliFixture struct { - tempDir string - workspaceRoot string - configPath string -} - -func newCLIFixture(t *testing.T, writeScriptorium func(*testing.T, string) string) cliFixture { - t.Helper() - server := dailyServer(t) - tempDir := t.TempDir() - scriptoriumPath := writeScriptorium(t, tempDir) - workspaceRoot := filepath.Join(tempDir, "workspace") - - return cliFixture{ - tempDir: tempDir, - workspaceRoot: workspaceRoot, - configPath: writeTestConfig(t, server, scriptoriumPath, workspaceRoot), - } -} - -func (f cliFixture) path(name string) string { - return filepath.Join(f.tempDir, name) -} - -func writeConfigFile(t *testing.T, body string) string { - t.Helper() - configPath := filepath.Join(t.TempDir(), "config.yml") - if err := os.WriteFile(configPath, []byte(body), 0o600); err != nil { - t.Fatalf("write config: %v", err) - } - return configPath -} - -func writeTestConfig(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string) string { - t.Helper() - configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n" - return writeConfigFile(t, configBody) -} - -func writeTestConfigWithDistributor(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string, distributorEndpoint string) string { - t.Helper() - configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorEndpoint + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n" - return writeConfigFile(t, configBody) -} - -func writeTestConfigWithDisabledBatchDistributor(t *testing.T, server *httptest.Server, scriptoriumPath string, workspaceRoot string, distributorEndpoint string) string { - t.Helper() - configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\nnotify:\n distributor:\n enabled: true\n endpoint: " + distributorEndpoint + "\n token_env: CLI_DISTRIBUTOR_TOKEN\n pipeline_id_template: weatherreporter.{artifact_group}\n batch:\n enabled: false\n" - return writeConfigFile(t, configBody) -} - -func writeWorkspaceConfig(t *testing.T, workspaceRoot string) string { - t.Helper() - return writeConfigFile(t, "workspace:\n root: "+workspaceRoot+"\n") -} - -func oneArtifact(t *testing.T, root string, parts ...string) string { - t.Helper() - matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...)) - if err != nil { - t.Fatalf("glob artifact: %v", err) - } - if len(matches) != 1 { - t.Fatalf("artifact matches = %#v, want one", matches) - } - return matches[0] -} - -func noArtifacts(t *testing.T, root string, parts ...string) { - t.Helper() - matches, err := filepath.Glob(filepath.Join(append([]string{root}, parts...)...)) - if err != nil { - t.Fatalf("glob artifact: %v", err) - } - if len(matches) != 0 { - t.Fatalf("artifact matches = %#v, want none", matches) - } -} - -func runIDFromDataPackagePath(t *testing.T, path string) string { - t.Helper() - base := filepath.Base(path) - runID := strings.TrimSuffix(strings.TrimPrefix(base, "data_package."), ".yaml") - if runID == base || runID == "" { - t.Fatalf("data package path = %q, want data_package..yaml", path) - } - return runID -} - -func firstLineWithPrefix(text string, prefix string) string { - for _, line := range strings.Split(text, "\n") { - if strings.HasPrefix(line, prefix) { - return line - } - } - return "" -} - -func assertFileContains(t *testing.T, path string, want string) { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read %s: %v", path, err) - } - if !strings.Contains(string(data), want) { - t.Fatalf("%s missing %q:\n%s", path, want, string(data)) - } -} - -func writeFakeScriptorium(t *testing.T, dir string) string { - t.Helper() - path := filepath.Join(dir, "scriptorium") - body := `#!/bin/sh -if [ "$1" = "render" ]; then - printf '{"ok":true,"argv":"%s"}' "$*" - exit 0 -fi -if [ "$1" = "run" ]; then - out="" - prompt="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then - shift - out="$1" - elif [ "$1" = "--prompt" ]; then - shift - prompt="$1" - fi - shift - done - if [ "$prompt" = "weather.today_generated_text" ]; then - cat > "$out" <<'JSON' -{ - "summary": "Today starts with showers before improving.", - "forecast_discussion": [ - "Morning showers should taper as drier air arrives.", - "Afternoon conditions trend quieter." - ], - "precipitation_timing": "The best rain chance is during the morning." -} -JSON - printf 'wrote generated text\n' >&2 - exit 0 - fi - if [ "$prompt" = "weather.tomorrow_generated_text" ]; then - cat > "$out" <<'JSON' -{ - "summary": "Tomorrow starts with showers before improving.", - "forecast_discussion": [ - "Morning showers should taper as drier air arrives.", - "Afternoon conditions trend quieter." - ], - "precipitation_timing": "The best rain chance is during the morning." -} -JSON - printf 'wrote generated text\n' >&2 - exit 0 - fi - if [ "$prompt" = "weather.daily_generated_text" ]; then - cat > "$out" <<'JSON' -{ - "summary": "Showers are possible during the selected day.", - "forecast_discussion": [ - "A front will keep rain chances in the forecast.", - "Temperatures stay seasonable by afternoon." - ], - "precipitation_timing": "Rain is most likely during the afternoon.", - "confidence": "Medium" -} -JSON - printf 'wrote generated text\n' >&2 - exit 0 - fi - printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out" - printf 'wrote report\n' >&2 - exit 0 -fi -printf 'unexpected command\n' >&2 -exit 1 -` - if err := os.WriteFile(path, []byte(body), 0o700); err != nil { - t.Fatalf("write fake scriptorium: %v", err) - } - return path -} - -func writeStructuredOutputScriptorium(t *testing.T, dir string) string { - t.Helper() - path := filepath.Join(dir, "scriptorium") - body := `#!/bin/sh -if [ "$1" = "render" ]; then - printf '{"ok":true,"argv":"%s"}' "$*" - exit 0 -fi -if [ "$1" = "run" ]; then - out="" - prompt="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then - shift - out="$1" - elif [ "$1" = "--prompt" ]; then - shift - prompt="$1" - fi - shift - done - if [ "$prompt" = "weather.hourly_generated_text" ]; then - cat > "$out" <<'JSON' -{ - "summary": " Storm chances increase through late morning. ", - "forecast_discussion": "A front will keep the region unsettled.", - "precipitation_timing": "A cold front is moving into the region.", - "confidence": "Medium" -} -JSON - printf 'wrote generated text\n' >&2 - exit 0 - fi - if [ "$prompt" = "weather.daily_generated_text" ]; then - cat > "$out" <<'JSON' -{ - "summary": "Showers are possible during the selected day.", - "forecast_discussion": [ - "A front will keep rain chances in the forecast.", - "Temperatures stay seasonable by afternoon." - ], - "precipitation_timing": "Rain is most likely during the afternoon.", - "confidence": "Medium" -} -JSON - printf 'wrote generated text\n' >&2 - exit 0 - fi - if [ "$prompt" = "weather.today_generated_text" ]; then - cat > "$out" <<'JSON' -{ - "summary": "Today starts with showers before improving.", - "forecast_discussion": [ - "Morning showers should taper as drier air arrives.", - "Afternoon conditions trend quieter." - ], - "precipitation_timing": "The best rain chance is during the morning." -} -JSON - printf 'wrote generated text\n' >&2 - exit 0 - fi - if [ "$prompt" = "weather.tomorrow_generated_text" ]; then - cat > "$out" <<'JSON' -{ - "summary": "Tomorrow starts with showers before improving.", - "forecast_discussion": [ - "Morning showers should taper as drier air arrives.", - "Afternoon conditions trend quieter." - ], - "precipitation_timing": "The best rain chance is during the morning." -} -JSON - printf 'wrote generated text\n' >&2 - exit 0 - fi - printf '# Daily Report\n\nGenerated by fake scriptorium.\n' > "$out" - printf 'wrote report\n' >&2 - exit 0 -fi -printf 'unexpected command\n' >&2 -exit 1 -` - if err := os.WriteFile(path, []byte(body), 0o700); err != nil { - t.Fatalf("write fake scriptorium: %v", err) - } - return path -} - -func writeFailingScriptorium(t *testing.T, dir string) string { - t.Helper() - path := filepath.Join(dir, "scriptorium") - body := `#!/bin/sh -if [ "$1" = "render" ]; then - prompt="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--prompt" ]; then - shift - prompt="$1" - fi - shift - done - if [ "$prompt" = "weather.tomorrow_generated_text" ]; then - printf 'render failed\n' >&2 - exit 1 - fi - printf '{"ok":true,"prompt":"%s"}' "$prompt" - exit 0 -fi -if [ "$1" = "run" ]; then - out="" - prompt="" - while [ "$#" -gt 0 ]; do - if [ "$1" = "--out" ]; then - shift - out="$1" - elif [ "$1" = "--prompt" ]; then - shift - prompt="$1" - fi - shift - done - if [ "$prompt" = "weather.today_generated_text" ]; then - cat > "$out" <<'JSON' -{ - "summary": "Today starts with showers before improving.", - "forecast_discussion": [ - "Morning showers should taper as drier air arrives.", - "Afternoon conditions trend quieter." - ], - "precipitation_timing": "The best rain chance is during the morning." -} -JSON - exit 0 - fi - printf '# Batch Report\n\nGenerated by fake scriptorium.\n' > "$out" - exit 0 -fi -printf 'unexpected command\n' >&2 -exit 1 -` - if err := os.WriteFile(path, []byte(body), 0o700); err != nil { - t.Fatalf("write fake scriptorium: %v", err) - } - return path -} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go new file mode 100644 index 0000000..9c05caa --- /dev/null +++ b/internal/cli/run_test.go @@ -0,0 +1,37 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + "time" + + "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" + "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" +) + +func TestParseRunFlagsAcceptsPromptDebugDirectory(t *testing.T) { + opts, err := parseRunFlags([]string{"--llm-debug-dir", "/tmp/prompt-debug"}) + if err != nil || opts.LLMDebugDir != "/tmp/prompt-debug" { + t.Fatalf("parseRunFlags() = %#v, %v", opts, err) + } +} + +func TestResolveRunActionConstructsOneExecutor(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yml") + if err := os.WriteFile(configPath, []byte("workspace:\n root: "+filepath.Join(t.TempDir(), "workspace")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + calls := 0 + runner := Runner{ + Clock: timeutil.FixedClock{Time: time.Date(2026, 5, 29, 8, 0, 0, 0, time.UTC)}, + ExecutorFactory: func(PromptExecutorConfig) (promptexec.Executor, error) { + calls++ + return factoryExecutor{}, nil + }, + } + req, _, err := runner.resolveRunAction([]string{"morning", "--config", configPath, "--llm-debug-dir", "/tmp/debug"}) + if err != nil || calls != 1 || req.Executor == nil || req.LLMDebugDir != "/tmp/debug" { + t.Fatalf("resolveRunAction() request/error/calls = %#v/%v/%d", req, err, calls) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 06c8795..9aeb59d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,7 +27,6 @@ type Config struct { Secrets SecretsConfig `yaml:"secrets"` Notify NotifyConfig `yaml:"notify"` MissingSource MissingSourceConfig `yaml:"missing_source"` - Scriptorium ScriptoriumConfig `yaml:"scriptorium"` Promptkit PromptkitConfig `yaml:"promptkit"` Workspace WorkspaceConfig `yaml:"workspace"` Dayparts []DaypartConfig `yaml:"dayparts"` @@ -82,14 +81,6 @@ type MissingSourceConfig struct { Sources map[string]MissingSourcePolicy `yaml:"sources"` } -type ScriptoriumConfig struct { - Binary string `yaml:"binary"` - ConfigPath string `yaml:"config_path"` - Profile string `yaml:"profile"` - Timeout time.Duration `yaml:"timeout"` - ExtraArgs []string `yaml:"extra_args"` -} - type PromptkitConfig struct { Profile string `yaml:"profile"` ProfileFile string `yaml:"profile_file"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 41b5a91..39aa591 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -144,8 +144,8 @@ func TestLoadMinimalExampleConfig(t *testing.T) { if cfg.WeatherAPI.Units != "us" { t.Fatalf("Units = %q, want default us", cfg.WeatherAPI.Units) } - if cfg.Scriptorium.Binary != "scriptorium" { - t.Fatalf("Scriptorium.Binary = %q, want default scriptorium", cfg.Scriptorium.Binary) + if cfg.Promptkit.Timeout != 2*time.Minute || cfg.Promptkit.Local.ConcurrencyLimit != 1 { + t.Fatalf("Promptkit defaults = %#v", cfg.Promptkit) } if cfg.Workspace.Root != "workspace" { t.Fatalf("Workspace.Root = %q, want default workspace", cfg.Workspace.Root) @@ -158,6 +158,13 @@ func TestLoadMinimalExampleConfig(t *testing.T) { } } +func TestLoadRejectsRetiredExecutionConfiguration(t *testing.T) { + _, err := LoadFile(writeConfig(t, "scriptorium:\n binary: scriptorium\n")) + if err == nil || !strings.Contains(err.Error(), "migrate to promptkit") { + t.Fatalf("LoadFile() error = %v, want actionable migration error", err) + } +} + func TestLoadReportModuleOverrides(t *testing.T) { path := writeConfig(t, ` reports: diff --git a/internal/config/defaults.go b/internal/config/defaults.go index e8cb114..31f0889 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -43,10 +43,6 @@ func Defaults() Config { Default: MissingSourceWarn, Sources: map[string]MissingSourcePolicy{}, }, - Scriptorium: ScriptoriumConfig{ - Binary: "scriptorium", - Timeout: 2 * time.Minute, - }, Promptkit: PromptkitConfig{ Timeout: 2 * time.Minute, Local: PromptkitLocalConfig{ diff --git a/internal/config/load.go b/internal/config/load.go index fa4216d..828d38c 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -59,6 +59,9 @@ func mergeFile(cfg *Config, path string) error { if err != nil { return fmt.Errorf("read config %q: %w", path, err) } + if err := rejectRetiredExecutionConfig(data); err != nil { + return fmt.Errorf("parse config %q: %w", path, err) + } if err := yaml.Unmarshal(data, cfg); err != nil { return fmt.Errorf("parse config %q: %w", path, err) } @@ -70,3 +73,20 @@ func mergeFile(cfg *Config, path string) error { } return nil } + +func rejectRetiredExecutionConfig(data []byte) error { + var document yaml.Node + if err := yaml.Unmarshal(data, &document); err != nil { + return err + } + if len(document.Content) == 0 || document.Content[0].Kind != yaml.MappingNode { + return nil + } + root := document.Content[0] + for i := 0; i+1 < len(root.Content); i += 2 { + if root.Content[i].Value == "scriptorium" { + return fmt.Errorf("scriptorium configuration is no longer supported; migrate to promptkit configuration") + } + } + return nil +} diff --git a/internal/config/validate.go b/internal/config/validate.go index f28bd26..a2fcac7 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -59,12 +59,6 @@ func Validate(cfg Config) error { return err } - if cfg.Scriptorium.Binary == "" { - return fmt.Errorf("scriptorium.binary is required") - } - if cfg.Scriptorium.Timeout <= 0 { - return fmt.Errorf("scriptorium.timeout must be greater than zero") - } if err := validatePromptkit(cfg.Promptkit); err != nil { return err } diff --git a/internal/state/filesystem.go b/internal/state/filesystem.go index 245adef..ab8bed7 100644 --- a/internal/state/filesystem.go +++ b/internal/state/filesystem.go @@ -27,22 +27,16 @@ type FilesystemStore struct { } type ArtifactPaths struct { - ModuleSnapshot string `json:"moduleSnapshot"` - Metadata string `json:"metadata"` - DataPackage string `json:"dataPackage"` - Preparation string `json:"preparation,omitempty"` - Execution string `json:"execution,omitempty"` - // Preflight is retained for the temporary Scriptorium write path. Remove it - // with that integration's cutover. - Preflight string `json:"preflight"` + ModuleSnapshot string `json:"moduleSnapshot"` + Metadata string `json:"metadata"` + DataPackage string `json:"dataPackage"` + Preparation string `json:"preparation,omitempty"` + Execution string `json:"execution,omitempty"` Notification string `json:"notification,omitempty"` RenderedReport string `json:"renderedReport,omitempty"` GeneratedTextRaw string `json:"generatedTextRaw,omitempty"` - // GeneratedTextResult is retained for the temporary Scriptorium write path. - // Remove it with that integration's cutover. - GeneratedTextResult string `json:"generatedTextResult,omitempty"` - GeneratedText string `json:"generatedText,omitempty"` - RenderContext string `json:"renderContext,omitempty"` + GeneratedText string `json:"generatedText,omitempty"` + RenderContext string `json:"renderContext,omitempty"` } type ReportRecord struct { @@ -98,18 +92,16 @@ func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error) } validDate := resolved.ValidPeriod.Start.Format("2006-01-02") return ArtifactPaths{ - ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, "modules."+metadata.RunID+".json"), - Metadata: s.join(s.snapshotsDir, group, validDate, "metadata."+metadata.RunID+".json"), - DataPackage: s.join(s.dataPackagesDir, group, validDate, "data_package."+metadata.RunID+".yaml"), - Preparation: s.join(s.preflightDir, group, validDate, "prompt_preparation."+metadata.RunID+".json"), - Execution: s.join(s.snapshotsDir, group, validDate, "prompt_execution."+metadata.RunID+".json"), - Preflight: s.join(s.preflightDir, group, validDate, "render."+metadata.RunID+".json"), - Notification: s.join(s.notificationsDir, group, validDate, "distributor."+metadata.RunID+".json"), - RenderedReport: s.join(s.reportsDir, group, validDate, "report."+metadata.RunID+".md"), - GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, "generated_text_raw."+metadata.RunID+".json"), - GeneratedTextResult: s.join(s.snapshotsDir, group, validDate, "generated_text_result."+metadata.RunID+".json"), - GeneratedText: s.join(s.snapshotsDir, group, validDate, "generated_text."+metadata.RunID+".json"), - RenderContext: s.join(s.snapshotsDir, group, validDate, "render_context."+metadata.RunID+".json"), + ModuleSnapshot: s.join(s.snapshotsDir, group, validDate, "modules."+metadata.RunID+".json"), + Metadata: s.join(s.snapshotsDir, group, validDate, "metadata."+metadata.RunID+".json"), + DataPackage: s.join(s.dataPackagesDir, group, validDate, "data_package."+metadata.RunID+".yaml"), + Preparation: s.join(s.preflightDir, group, validDate, "prompt_preparation."+metadata.RunID+".json"), + Execution: s.join(s.snapshotsDir, group, validDate, "prompt_execution."+metadata.RunID+".json"), + Notification: s.join(s.notificationsDir, group, validDate, "distributor."+metadata.RunID+".json"), + RenderedReport: s.join(s.reportsDir, group, validDate, "report."+metadata.RunID+".md"), + GeneratedTextRaw: s.join(s.snapshotsDir, group, validDate, "generated_text_raw."+metadata.RunID+".json"), + GeneratedText: s.join(s.snapshotsDir, group, validDate, "generated_text."+metadata.RunID+".json"), + RenderContext: s.join(s.snapshotsDir, group, validDate, "render_context."+metadata.RunID+".json"), }, nil } @@ -141,12 +133,6 @@ func (s *FilesystemStore) SaveDataPackageBytes(_ context.Context, resolved repor return paths.DataPackage, nil } -func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, artifact PreflightArtifact) (string, error) { - return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string { - return paths.Preflight - }, artifact) -} - func (s *FilesystemStore) SavePromptPreparation(_ context.Context, resolved report.Resolved, artifact PromptPreparationArtifact) (string, error) { if artifact.SchemaVersion == "" { artifact.SchemaVersion = PromptPreparationSchemaVersion @@ -213,12 +199,6 @@ func (s *FilesystemStore) SaveGeneratedTextRaw(_ context.Context, resolved repor }, data) } -func (s *FilesystemStore) SaveGeneratedTextResult(_ context.Context, resolved report.Resolved, value any) (string, error) { - return s.saveResolvedJSON(resolved, func(paths ArtifactPaths) string { - return paths.GeneratedTextResult - }, value) -} - func (s *FilesystemStore) SaveGeneratedText(_ context.Context, resolved report.Resolved, data []byte) (string, error) { return s.saveResolvedBytes(resolved, func(paths ArtifactPaths) string { return paths.GeneratedText @@ -431,16 +411,6 @@ func (s *FilesystemStore) LoadGeneratedText(_ context.Context, path string) ([]b return data, nil } -func (s *FilesystemStore) LoadGeneratedTextResult(_ context.Context, path string, target any) error { - if path == "" { - return fmt.Errorf("generated text result path is required") - } - if target == nil { - return fmt.Errorf("generated text result target is required") - } - return readJSON(path, target) -} - func (s *FilesystemStore) LoadPromptPreparation(_ context.Context, path string) (PromptPreparationArtifact, error) { if path == "" { return PromptPreparationArtifact{}, fmt.Errorf("prompt preparation path is required") diff --git a/internal/state/filesystem_test.go b/internal/state/filesystem_test.go deleted file mode 100644 index 99b4da4..0000000 --- a/internal/state/filesystem_test.go +++ /dev/null @@ -1,1116 +0,0 @@ -package state - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "gitea.maximumdirect.net/eric/weatherreporter/internal/briefing" - "gitea.maximumdirect.net/eric/weatherreporter/internal/config" - "gitea.maximumdirect.net/eric/weatherreporter/internal/module" - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec" - "gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput" - "gitea.maximumdirect.net/eric/weatherreporter/internal/report" - "gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil" -) - -func TestPathsUseRunIDAndWorkspace(t *testing.T) { - store := newTestStore(t) - resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - - for _, want := range []string{ - filepath.Join("snapshots", "daily", "2026-05-29", "modules.20260529T100000.000000000Z_daily_2026-05-29.json"), - filepath.Join("snapshots", "daily", "2026-05-29", "metadata.20260529T100000.000000000Z_daily_2026-05-29.json"), - filepath.Join("data-packages", "daily", "2026-05-29", "data_package.20260529T100000.000000000Z_daily_2026-05-29.yaml"), - filepath.Join("preflight", "daily", "2026-05-29", "prompt_preparation.20260529T100000.000000000Z_daily_2026-05-29.json"), - filepath.Join("snapshots", "daily", "2026-05-29", "prompt_execution.20260529T100000.000000000Z_daily_2026-05-29.json"), - filepath.Join("preflight", "daily", "2026-05-29", "render.20260529T100000.000000000Z_daily_2026-05-29.json"), - filepath.Join("notifications", "daily", "2026-05-29", "distributor.20260529T100000.000000000Z_daily_2026-05-29.json"), - filepath.Join("reports", "daily", "2026-05-29", "report.20260529T100000.000000000Z_daily_2026-05-29.md"), - } { - if !strings.Contains(pathsString(paths), want) { - t.Fatalf("paths = %#v, want component %q", paths, want) - } - } -} - -func TestPromptArtifactsAndV2MetadataRoundTrip(t *testing.T) { - store := newTestStore(t) - resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - metadata := resolved.Metadata() - preparationPath, err := store.SavePromptPreparation(context.Background(), resolved, PromptPreparationArtifact{ - Status: PromptPreparationSucceeded, - ReportID: metadata.ReportID, - RunID: metadata.RunID, - PromptID: metadata.PromptID, - PromptVersion: "v1", - DataPackagePath: paths.DataPackage, - Preparation: &promptexec.Preparation{ - PromptID: metadata.PromptID, PromptVersion: "v1", PromptHash: "prompt-hash", - DataPackagePath: paths.DataPackage, - }, - }) - if err != nil { - t.Fatalf("SavePromptPreparation() error = %v", err) - } - executionPath, err := store.SavePromptExecution(context.Background(), resolved, PromptExecutionArtifact{ - Status: PromptExecutionSucceeded, - ReportID: metadata.ReportID, - RunID: metadata.RunID, - PromptID: metadata.PromptID, - PromptVersion: "v1", - Provenance: &PromptExecutionProvenance{ - RunID: metadata.RunID, PromptID: metadata.PromptID, PromptVersion: "v1", - PromptHash: "prompt-hash", DataPackagePath: paths.DataPackage, - }, - Validation: func() *promptexec.Validation { - value := promptexec.NewValidation(promptexec.ValidationPassed, "json_schema", "schemas/report.json", nil) - return &value - }(), - Paths: PromptExecutionPaths{RawOutputPath: paths.GeneratedTextRaw, RenderedReportPath: paths.RenderedReport}, - }) - if err != nil { - t.Fatalf("SavePromptExecution() error = %v", err) - } - loadedPreparation, err := store.LoadPromptPreparation(context.Background(), preparationPath) - if err != nil || loadedPreparation.Preparation == nil || loadedPreparation.Preparation.PromptHash != "prompt-hash" { - t.Fatalf("LoadPromptPreparation() = %#v, %v", loadedPreparation, err) - } - loadedExecution, err := store.LoadPromptExecution(context.Background(), executionPath) - if err != nil || loadedExecution.Provenance == nil || loadedExecution.Validation == nil { - t.Fatalf("LoadPromptExecution() = %#v, %v", loadedExecution, err) - } - executionData, err := os.ReadFile(executionPath) - if err != nil { - t.Fatalf("read execution artifact: %v", err) - } - if strings.Contains(string(executionData), `"RawOutput"`) || strings.Contains(string(executionData), `"Debug"`) { - t.Fatalf("execution artifact contains sensitive content fields: %s", executionData) - } - - v2 := BuildPromptMetadataFromBriefingMetadata(resolved, stateBriefingMetadata(resolved), paths) - v2.PreparationPath = preparationPath - v2.ExecutionPath = executionPath - metadataPath, err := store.SaveMetadata(context.Background(), v2) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } - data, err := os.ReadFile(metadataPath) - if err != nil { - t.Fatalf("read v2 metadata: %v", err) - } - if strings.Contains(string(data), "preflightPath") || strings.Contains(string(data), "generatedTextResultPath") { - t.Fatalf("v2 metadata contains deprecated aliases: %s", data) - } - loadedMetadata, _, err := store.LoadMetadataByRunID(context.Background(), metadata.RunID) - if err != nil || loadedMetadata.PreparationPath != preparationPath || loadedMetadata.ExecutionPath != executionPath { - t.Fatalf("LoadMetadataByRunID() = %#v, %v", loadedMetadata, err) - } -} - -func TestMetadataV1CompatibilityAndUnknownVersion(t *testing.T) { - legacy := Metadata{ - SchemaVersion: MetadataSchemaVersionV1, - RunID: "legacy-run", - MetadataPath: "/tmp/metadata.legacy-run.json", - ReportID: report.Daily, - PromptID: "weather.daily", - ModuleSnapshotPath: "/tmp/modules.json", - DataPackagePath: "/tmp/data.yaml", - PreflightPath: "/tmp/render.json", - GeneratedTextResultPath: "/tmp/generated-result.json", - } - data, err := json.Marshal(legacy) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - if !strings.Contains(string(data), "preflightPath") || !strings.Contains(string(data), "generatedTextResultPath") || strings.Contains(string(data), "preparationPath") || strings.Contains(string(data), "executionPath") { - t.Fatalf("legacy metadata JSON = %s", data) - } - var decoded Metadata - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - if decoded.PreparationPath != legacy.PreflightPath || decoded.ExecutionPath != legacy.GeneratedTextResultPath { - t.Fatalf("decoded compatibility paths = %#v", decoded) - } - remarshaled, err := json.Marshal(decoded) - if err != nil || !strings.Contains(string(remarshaled), "preflightPath") || strings.Contains(string(remarshaled), "preparationPath") { - t.Fatalf("remarshaled legacy metadata = %s, %v", remarshaled, err) - } - if err := json.Unmarshal([]byte(`{"schemaVersion":"weatherreporter.metadata.v99"}`), &decoded); err == nil { - t.Fatal("Unmarshal() error = nil, want unsupported schema version") - } -} - -func TestPromptArtifactRequiredFieldsAreRejected(t *testing.T) { - store := newTestStore(t) - resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - if _, err := store.SavePromptPreparation(context.Background(), resolved, PromptPreparationArtifact{}); err == nil { - t.Fatal("SavePromptPreparation() error = nil, want required-field error") - } - if _, err := store.SavePromptExecution(context.Background(), resolved, PromptExecutionArtifact{}); err == nil { - t.Fatal("SavePromptExecution() error = nil, want required-field error") - } - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - metadata := resolved.Metadata() - if _, err := store.SaveMetadata(context.Background(), Metadata{ - SchemaVersion: MetadataSchemaVersion, - RunID: metadata.RunID, - MetadataPath: paths.Metadata, - ReportID: metadata.ReportID, - PromptID: metadata.PromptID, - ModuleSnapshotPath: paths.ModuleSnapshot, - DataPackagePath: paths.DataPackage, - }); err == nil { - t.Fatal("SaveMetadata() error = nil, want v2 preparation-path error") - } -} - -func TestDailyPathsUseRunIDValidDateDisambiguator(t *testing.T) { - store := newTestStore(t) - first := resolveDailyForDateAt(t, "2026-05-29T05:00:00-05:00", "2026-05-31T12:00:00-05:00") - second := resolveDailyForDateAt(t, "2026-05-29T05:00:00-05:00", "2026-06-01T12:00:00-05:00") - - firstPaths, err := store.Paths(first) - if err != nil { - t.Fatalf("Paths(first) error = %v", err) - } - secondPaths, err := store.Paths(second) - if err != nil { - t.Fatalf("Paths(second) error = %v", err) - } - - if first.Metadata().RunID == second.Metadata().RunID { - t.Fatalf("RunIDs both = %q, want distinct daily run ids", first.Metadata().RunID) - } - for name, values := range map[string][2]string{ - "RenderedReport": {firstPaths.RenderedReport, secondPaths.RenderedReport}, - "Metadata": {firstPaths.Metadata, secondPaths.Metadata}, - "DataPackage": {firstPaths.DataPackage, secondPaths.DataPackage}, - "Notification": {firstPaths.Notification, secondPaths.Notification}, - } { - if values[0] == values[1] { - t.Fatalf("%s paths both = %q, want distinct daily artifact paths", name, values[0]) - } - } -} - -func TestPathsRejectRunIDPathSeparators(t *testing.T) { - store := newTestStore(t) - resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - resolved.Definition.ID = report.ID("daily/bad") - - _, err := store.Paths(resolved) - if err == nil { - t.Fatal("Paths() error = nil, want invalid run id error") - } - if !strings.Contains(err.Error(), "run id must not contain path separators") { - t.Fatalf("error = %q, want run id path separator context", err.Error()) - } -} - -func TestBatchDistributorNotificationPathUsesWorkspaceBatchDateAndRunID(t *testing.T) { - store := newTestStore(t) - location := mustLoadStateLocation(t, "America/Chicago") - startedAt := time.Date(2026, 6, 18, 3, 30, 0, 123456789, time.UTC) - - path, err := store.BatchDistributorNotificationPath(BatchDistributorNotificationRef{ - Batch: "evening", - BatchRunID: "20260618T033000.123456789Z_evening", - StartedAt: startedAt, - Location: location, - }) - if err != nil { - t.Fatalf("BatchDistributorNotificationPath() error = %v", err) - } - want := filepath.Join("notifications", "batches", "evening", "2026-06-17", "distributor.20260618T033000.123456789Z_evening.json") - if !strings.Contains(path, want) { - t.Fatalf("path = %q, want component %q", path, want) - } - if !strings.HasPrefix(path, store.root) { - t.Fatalf("path = %q, want workspace root prefix %q", path, store.root) - } -} - -func TestSaveBatchDistributorNotificationRoundTrip(t *testing.T) { - store := newTestStore(t) - location := mustLoadStateLocation(t, "America/Chicago") - startedAt := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC) - bundleCreated := startedAt.Add(2 * time.Second) - attemptedAt := startedAt.Add(3 * time.Second) - acceptedAt := startedAt.Add(4 * time.Second) - finishedAt := startedAt.Add(5 * time.Second) - ref := BatchDistributorNotificationRef{ - Batch: "morning", - BatchRunID: "20260617T120000.000000000Z_morning", - StartedAt: startedAt, - Location: location, - } - - path, err := store.SaveBatchDistributorNotification(context.Background(), ref, BatchDistributorNotificationArtifact{ - AttemptedAt: attemptedAt, - Endpoint: "https://distributor.example.test", - PipelineID: "weatherreporter", - BundleID: "weatherreporter.home.morning", - IdempotencyKey: "weatherreporter.home.morning.20260617T120000.000000000Z_morning", - BundleCreated: bundleCreated, - Reports: []BatchDistributorNotificationReportArtifact{ - { - ReportID: report.Today, - RunID: "20260617T120000.000000000Z_today", - SourcePath: "/workspace/reports/today/2026-06-17/report.20260617T120000.000000000Z_today.md", - BundlePaths: []string{"2026-06-17/today/report.md"}, - }, - { - ReportID: report.Daily, - RunID: "20260617T120000.000000000Z_daily_2026-06-19", - SourcePath: "/workspace/reports/daily/2026-06-19/report.20260617T120000.000000000Z_daily_2026-06-19.md", - BundlePaths: []string{"2026-06-19/daily/report.md"}, - }, - }, - Status: "failed", - Upload: &DistributorUploadResult{ - RunID: "distributor-run", - Status: "accepted", - }, - RunStatus: &DistributorRunStatus{ - RunID: "distributor-run", - PipelineID: "weatherreporter", - Status: "failed", - AcceptedAt: acceptedAt, - FinishedAt: &finishedAt, - Report: json.RawMessage(`{"actions":[{"action":"failed"}]}`), - Error: "destination conflict", - }, - StatusError: "status lookup failed", - Error: "batch upload failed", - }) - if err != nil { - t.Fatalf("SaveBatchDistributorNotification() error = %v", err) - } - wantPath := filepath.Join("notifications", "batches", "morning", "2026-06-17", "distributor.20260617T120000.000000000Z_morning.json") - if !strings.Contains(path, wantPath) { - t.Fatalf("path = %q, want component %q", path, wantPath) - } - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read batch notification: %v", err) - } - var artifact BatchDistributorNotificationArtifact - if err := json.Unmarshal(data, &artifact); err != nil { - t.Fatalf("decode batch notification: %v", err) - } - if artifact.SchemaVersion != BatchDistributorNotificationSchemaVersion { - t.Fatalf("SchemaVersion = %q, want %q", artifact.SchemaVersion, BatchDistributorNotificationSchemaVersion) - } - if artifact.Batch != "morning" || artifact.BatchRunID != ref.BatchRunID { - t.Fatalf("artifact batch identity = %q/%q, want ref values", artifact.Batch, artifact.BatchRunID) - } - if artifact.Endpoint != "https://distributor.example.test" || artifact.PipelineID != "weatherreporter" || artifact.BundleID != "weatherreporter.home.morning" || artifact.IdempotencyKey == "" { - t.Fatalf("artifact identity = %#v, want distributor identity", artifact) - } - if len(artifact.Reports) != 2 || artifact.Reports[0].ReportID != report.Today || strings.Join(artifact.Reports[1].BundlePaths, ",") != "2026-06-19/daily/report.md" { - t.Fatalf("Reports = %#v, want included report records", artifact.Reports) - } - if artifact.Upload == nil || artifact.Upload.RunID != "distributor-run" { - t.Fatalf("Upload = %#v, want accepted upload result", artifact.Upload) - } - if artifact.RunStatus == nil || artifact.RunStatus.Status != "failed" || !strings.Contains(string(artifact.RunStatus.Report), "failed") || artifact.RunStatus.FinishedAt == nil { - t.Fatalf("RunStatus = %#v, want failed run status with raw report", artifact.RunStatus) - } - if artifact.StatusError != "status lookup failed" || artifact.Error != "batch upload failed" { - t.Fatalf("errors = %q/%q, want persisted error fields", artifact.StatusError, artifact.Error) - } -} - -func TestBatchDistributorNotificationPathRejectsInvalidIdentity(t *testing.T) { - store := newTestStore(t) - location := mustLoadStateLocation(t, "America/Chicago") - valid := BatchDistributorNotificationRef{ - Batch: "morning", - BatchRunID: "20260617T120000.000000000Z_morning", - StartedAt: time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC), - Location: location, - } - tests := []struct { - name string - mutate func(*BatchDistributorNotificationRef) - wantErr string - }{ - { - name: "Batch", - mutate: func(ref *BatchDistributorNotificationRef) { - ref.Batch = "" - }, - wantErr: "batch kind is required", - }, - { - name: "BatchSeparator", - mutate: func(ref *BatchDistributorNotificationRef) { - ref.Batch = "../morning" - }, - wantErr: "batch kind must not contain path separators", - }, - { - name: "BatchRunID", - mutate: func(ref *BatchDistributorNotificationRef) { - ref.BatchRunID = "" - }, - wantErr: "batch run id is required", - }, - { - name: "BatchRunIDSeparator", - mutate: func(ref *BatchDistributorNotificationRef) { - ref.BatchRunID = "nested/run" - }, - wantErr: "batch run id must not contain path separators", - }, - { - name: "StartedAt", - mutate: func(ref *BatchDistributorNotificationRef) { - ref.StartedAt = time.Time{} - }, - wantErr: "batch started time is required", - }, - { - name: "Location", - mutate: func(ref *BatchDistributorNotificationRef) { - ref.Location = nil - }, - wantErr: "batch location is required", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ref := valid - tt.mutate(&ref) - _, err := store.BatchDistributorNotificationPath(ref) - if err == nil { - t.Fatal("BatchDistributorNotificationPath() error = nil, want error") - } - if !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) - } - }) - } -} - -func TestGeneratedTextArtifactPathsUseSnapshotTree(t *testing.T) { - store := newTestStore(t) - tests := []struct { - name string - resolved report.Resolved - group string - validDate string - runID string - }{ - { - name: "daily", - resolved: resolveDailyAt(t, "2026-05-29T05:00:00-05:00"), - group: "daily", - validDate: "2026-05-29", - runID: "20260529T100000.000000000Z_daily_2026-05-29", - }, - { - name: "hourly", - resolved: resolveHourlyAt(t, "2026-05-29T05:00:00-05:00"), - group: "hourly", - validDate: "2026-05-29", - runID: "20260529T100000.000000000Z_hourly", - }, - { - name: "tomorrow", - resolved: resolveTomorrowAt(t, "2026-05-29T18:00:00-05:00"), - group: "tomorrow", - validDate: "2026-05-30", - runID: "20260529T230000.000000000Z_tomorrow", - }, - { - name: "today", - resolved: resolveTodayAt(t, "2026-05-29T05:00:00-05:00"), - group: "today", - validDate: "2026-05-29", - runID: "20260529T100000.000000000Z_today", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - paths, err := store.Paths(tt.resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - wants := map[string]string{ - "ModuleSnapshot": filepath.Join("snapshots", tt.group, tt.validDate, "modules."+tt.runID+".json"), - "Metadata": filepath.Join("snapshots", tt.group, tt.validDate, "metadata."+tt.runID+".json"), - "DataPackage": filepath.Join("data-packages", tt.group, tt.validDate, "data_package."+tt.runID+".yaml"), - "Preflight": filepath.Join("preflight", tt.group, tt.validDate, "render."+tt.runID+".json"), - "Notification": filepath.Join("notifications", tt.group, tt.validDate, "distributor."+tt.runID+".json"), - "RenderedReport": filepath.Join("reports", tt.group, tt.validDate, "report."+tt.runID+".md"), - "GeneratedTextRaw": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text_raw."+tt.runID+".json"), - "GeneratedTextResult": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text_result."+tt.runID+".json"), - "GeneratedText": filepath.Join("snapshots", tt.group, tt.validDate, "generated_text."+tt.runID+".json"), - "RenderContext": filepath.Join("snapshots", tt.group, tt.validDate, "render_context."+tt.runID+".json"), - } - got := map[string]string{ - "ModuleSnapshot": paths.ModuleSnapshot, - "Metadata": paths.Metadata, - "DataPackage": paths.DataPackage, - "Preflight": paths.Preflight, - "Notification": paths.Notification, - "RenderedReport": paths.RenderedReport, - "GeneratedTextRaw": paths.GeneratedTextRaw, - "GeneratedTextResult": paths.GeneratedTextResult, - "GeneratedText": paths.GeneratedText, - "RenderContext": paths.RenderContext, - } - for name, want := range wants { - if !strings.Contains(got[name], want) { - t.Fatalf("%s path = %q, want component %q", name, got[name], want) - } - } - }) - } -} - -func TestSaveArtifactsAndMetadataRoundTrip(t *testing.T) { - store := newTestStore(t) - resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - briefingMetadata := stateBriefingMetadata(resolved) - snapshot, err := module.NewSnapshot([]module.Output{{ - ID: module.Metadata, - StanzaName: "metadata", - Value: map[string]string{"run_id": resolved.Metadata().RunID}, - PromptValue: map[string]string{"prompt_run_id": resolved.Metadata().RunID}, - }}) - if err != nil { - t.Fatalf("NewSnapshot() error = %v", err) - } - dataPackage, err := promptinput.Build(promptinput.BuildRequest{ - Metadata: promptinput.Metadata{ - RunID: resolved.Metadata().RunID, - ReportID: resolved.Definition.ID, - Variant: briefingMetadata.Variant, - PromptID: resolved.Definition.PromptID, - GeneratedAt: resolved.GeneratedAt, - Timezone: resolved.Timezone, - ValidPeriod: resolved.ValidPeriod, - }, - Modules: snapshot, - }) - if err != nil { - t.Fatalf("Build() error = %v", err) - } - - dataPackagePath, err := store.SaveDataPackage(context.Background(), resolved, dataPackage) - if err != nil { - t.Fatalf("SaveDataPackage() error = %v", err) - } - moduleSnapshotPath, err := store.SaveModuleSnapshot(context.Background(), resolved, snapshot) - if err != nil { - t.Fatalf("SaveModuleSnapshot() error = %v", err) - } - preflightPath, err := store.SavePreflight(context.Background(), resolved, PreflightArtifact{Stdout: `{"ok":true}`}) - if err != nil { - t.Fatalf("SavePreflight() error = %v", err) - } - notificationPath, err := store.SaveDistributorNotification(context.Background(), resolved, DistributorNotificationArtifact{ - RunID: resolved.Metadata().RunID, - ReportID: resolved.Definition.ID, - AttemptedAt: resolved.GeneratedAt, - Endpoint: "https://distributor.example.test", - PipelineID: "weatherreporter.daily", - BundleID: "weatherreporter.home.daily.run", - IdempotencyKey: "weatherreporter.home.daily.run", - SourcePath: "/tmp/report.md", - BundlePaths: []string{"2026-05-29/daily/report.md"}, - BundleCreated: resolved.GeneratedAt, - Status: "succeeded", - RunStatus: &DistributorRunStatus{RunID: "distributor-run", Status: "succeeded"}, - }) - if err != nil { - t.Fatalf("SaveDistributorNotification() error = %v", err) - } - renderedReportPath, err := store.PrepareRenderedReport(context.Background(), resolved) - if err != nil { - t.Fatalf("PrepareRenderedReport() error = %v", err) - } - if err := os.WriteFile(renderedReportPath, []byte("# Daily Report\n"), 0o600); err != nil { - t.Fatalf("write rendered report: %v", err) - } - var preflight PreflightArtifact - preflightData, err := os.ReadFile(preflightPath) - if err != nil { - t.Fatalf("read preflight: %v", err) - } - if err := json.Unmarshal(preflightData, &preflight); err != nil { - t.Fatalf("decode preflight: %v", err) - } - if preflight.Stdout != `{"ok":true}` { - t.Fatalf("preflight stdout = %q, want render stdout", preflight.Stdout) - } - var notification DistributorNotificationArtifact - notificationData, err := os.ReadFile(notificationPath) - if err != nil { - t.Fatalf("read notification: %v", err) - } - if err := json.Unmarshal(notificationData, ¬ification); err != nil { - t.Fatalf("decode notification: %v", err) - } - if notification.SchemaVersion != DistributorNotificationSchemaVersion || notification.PipelineID != "weatherreporter.daily" || len(notification.BundlePaths) != 1 || notification.RunStatus == nil || notification.RunStatus.Status != "succeeded" { - t.Fatalf("notification = %#v, want persisted distributor status", notification) - } - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{ - ModuleSnapshot: moduleSnapshotPath, - Metadata: paths.Metadata, - DataPackage: dataPackagePath, - Preflight: preflightPath, - RenderedReport: renderedReportPath, - }) - metadataPath, err := store.SaveMetadata(context.Background(), metadata) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } - - for _, path := range []string{moduleSnapshotPath, dataPackagePath, preflightPath, notificationPath, renderedReportPath, metadataPath} { - if _, err := os.Stat(path); err != nil { - t.Fatalf("expected artifact %q: %v", path, err) - } - } - loadedSnapshot, err := store.LoadModuleSnapshot(context.Background(), moduleSnapshotPath) - if err != nil { - t.Fatalf("LoadModuleSnapshot() error = %v", err) - } - if loadedSnapshot.SchemaVersion != module.SnapshotSchemaVersion || len(loadedSnapshot.Outputs) != 1 { - t.Fatalf("loaded module snapshot = %#v, want one metadata output", loadedSnapshot) - } - if loadedSnapshot.Outputs[0].PromptValue != nil { - t.Fatalf("loaded module snapshot PromptValue = %#v, want omitted runtime value", loadedSnapshot.Outputs[0].PromptValue) - } - snapshotData, err := os.ReadFile(moduleSnapshotPath) - if err != nil { - t.Fatalf("read module snapshot: %v", err) - } - if strings.Contains(string(snapshotData), "prompt_run_id") || strings.Contains(string(snapshotData), "promptValue") || strings.Contains(string(snapshotData), "PromptValue") { - t.Fatalf("module snapshot JSON includes runtime-only prompt value:\n%s", string(snapshotData)) - } - loadedDataPackage, err := store.LoadDataPackage(context.Background(), dataPackagePath) - if err != nil { - t.Fatalf("LoadDataPackage() error = %v", err) - } - if loadedDataPackage.SchemaVersion != promptinput.SchemaVersion || loadedDataPackage.Briefing.Order[0] != "metadata" { - t.Fatalf("loaded data package = %#v, want YAML package with metadata stanza", loadedDataPackage) - } - metadataStanza, ok := loadedDataPackage.Briefing.Values["metadata"].(map[string]any) - if !ok || metadataStanza["prompt_run_id"] != resolved.Metadata().RunID { - t.Fatalf("loaded data package metadata = %#v, want runtime prompt value", loadedDataPackage.Briefing.Values["metadata"]) - } - var decoded Metadata - data, err := os.ReadFile(metadataPath) - if err != nil { - t.Fatalf("read metadata: %v", err) - } - if err := json.Unmarshal(data, &decoded); err != nil { - t.Fatalf("decode metadata: %v", err) - } - if decoded.RunID != resolved.Metadata().RunID { - t.Fatalf("RunID = %q, want %q", decoded.RunID, resolved.Metadata().RunID) - } - if decoded.ModuleSnapshotPath != moduleSnapshotPath || decoded.DataPackagePath != dataPackagePath || decoded.PreflightPath != preflightPath { - t.Fatalf("metadata paths = %#v, want saved artifact paths", decoded) - } - if decoded.RenderedReportPath != renderedReportPath { - t.Fatalf("RenderedReportPath = %q, want %q", decoded.RenderedReportPath, renderedReportPath) - } - if decoded.Location == nil || decoded.Location.Name != "Brentwood" || decoded.Location.Timezone != "America/Chicago" { - t.Fatalf("metadata location = %#v, want briefing location", decoded.Location) - } - if strings.Contains(string(data), "MetadataPath") || strings.Contains(string(data), "metadataPath") { - t.Fatalf("metadata JSON includes runtime-only MetadataPath:\n%s", string(data)) - } - if decoded.GeneratedTextSchemaID != "daily" { - t.Fatalf("GeneratedTextSchemaID = %q, want daily", decoded.GeneratedTextSchemaID) - } - for _, unexpected := range []string{"generatedTextRawPath", "generatedTextResultPath", "generatedTextPath", "renderContextPath"} { - if strings.Contains(string(data), unexpected) { - t.Fatalf("metadata JSON includes unsaved generated-text path field %q:\n%s", unexpected, string(data)) - } - } -} - -func TestSaveGeneratedTextArtifactsAndMetadataRoundTrip(t *testing.T) { - store := newTestStore(t) - resolved := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00") - briefingMetadata := stateBriefingMetadata(resolved) - - rawPath, err := store.SaveGeneratedTextRaw(context.Background(), resolved, []byte(`{"summary":"raw"}`)) - if err != nil { - t.Fatalf("SaveGeneratedTextRaw() error = %v", err) - } - resultPath, err := store.SaveGeneratedTextResult(context.Background(), resolved, map[string]any{ - "command": []string{"scriptorium", "run"}, - "status": "succeeded", - }) - if err != nil { - t.Fatalf("SaveGeneratedTextResult() error = %v", err) - } - generatedPath, err := store.SaveGeneratedText(context.Background(), resolved, []byte(`{"summary":"Storm chances increase.","forecast_discussion":"A front will keep the region unsettled."}`)) - if err != nil { - t.Fatalf("SaveGeneratedText() error = %v", err) - } - contextPath, err := store.SaveRenderContext(context.Background(), resolved, struct { - ReportTitle string `json:"reportTitle"` - Location string `json:"location"` - }{ - ReportTitle: "Hourly Report", - Location: "Brentwood", - }) - if err != nil { - t.Fatalf("SaveRenderContext() error = %v", err) - } - - for _, path := range []string{rawPath, resultPath, generatedPath, contextPath} { - if _, err := os.Stat(path); err != nil { - t.Fatalf("expected generated-text artifact %q: %v", path, err) - } - } - rawData, err := store.LoadGeneratedText(context.Background(), rawPath) - if err != nil { - t.Fatalf("LoadGeneratedText() raw error = %v", err) - } - if string(rawData) != `{"summary":"raw"}` { - t.Fatalf("raw data = %q, want saved bytes", rawData) - } - generatedData, err := store.LoadGeneratedText(context.Background(), generatedPath) - if err != nil { - t.Fatalf("LoadGeneratedText() generated error = %v", err) - } - if !strings.Contains(string(generatedData), `"forecast_discussion":"A front will keep the region unsettled."`) { - t.Fatalf("generated text = %q, want saved normalized JSON", generatedData) - } - var runResult struct { - Command []string `json:"command"` - Status string `json:"status"` - } - if err := store.LoadGeneratedTextResult(context.Background(), resultPath, &runResult); err != nil { - t.Fatalf("LoadGeneratedTextResult() error = %v", err) - } - if runResult.Status != "succeeded" || strings.Join(runResult.Command, " ") != "scriptorium run" { - t.Fatalf("run result = %#v, want saved result", runResult) - } - var renderContext struct { - ReportTitle string `json:"reportTitle"` - Location string `json:"location"` - } - if err := store.LoadRenderContext(context.Background(), contextPath, &renderContext); err != nil { - t.Fatalf("LoadRenderContext() error = %v", err) - } - if renderContext.ReportTitle != "Hourly Report" || renderContext.Location != "Brentwood" { - t.Fatalf("render context = %#v, want saved context", renderContext) - } - - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{ - ModuleSnapshot: paths.ModuleSnapshot, - Metadata: paths.Metadata, - DataPackage: paths.DataPackage, - Preflight: paths.Preflight, - RenderedReport: paths.RenderedReport, - GeneratedTextRaw: rawPath, - GeneratedTextResult: resultPath, - GeneratedText: generatedPath, - RenderContext: contextPath, - }) - metadataPath, err := store.SaveMetadata(context.Background(), metadata) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } - var decoded Metadata - metadataData, err := os.ReadFile(metadataPath) - if err != nil { - t.Fatalf("read metadata: %v", err) - } - if err := json.Unmarshal(metadataData, &decoded); err != nil { - t.Fatalf("decode metadata: %v", err) - } - if decoded.GeneratedTextSchemaID != "hourly" { - t.Fatalf("GeneratedTextSchemaID = %q, want hourly", decoded.GeneratedTextSchemaID) - } - if decoded.GeneratedTextRawPath != rawPath || decoded.GeneratedTextResultPath != resultPath || decoded.GeneratedTextPath != generatedPath || decoded.RenderContextPath != contextPath { - t.Fatalf("metadata generated-text paths = %#v, want saved artifact paths", decoded) - } -} - -func TestSaveMetadataUsesExplicitMetadataPath(t *testing.T) { - store := newTestStore(t) - resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - briefingMetadata := stateBriefingMetadata(resolved) - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - otherDir := filepath.Join(t.TempDir(), "other-artifacts") - derivedMetadataPath := filepath.Join(otherDir, "metadata."+resolved.Metadata().RunID+".json") - - metadata := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, ArtifactPaths{ - ModuleSnapshot: paths.ModuleSnapshot, - Metadata: paths.Metadata, - DataPackage: paths.DataPackage, - Preflight: paths.Preflight, - RenderedReport: paths.RenderedReport, - }) - metadataPath, err := store.SaveMetadata(context.Background(), metadata) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } - if metadataPath != paths.Metadata { - t.Fatalf("SaveMetadata() path = %q, want explicit metadata path %q", metadataPath, paths.Metadata) - } - if _, err := os.Stat(paths.Metadata); err != nil { - t.Fatalf("expected explicit metadata path %q: %v", paths.Metadata, err) - } - if _, err := os.Stat(derivedMetadataPath); !os.IsNotExist(err) { - t.Fatalf("derived metadata path stat error = %v, want not exist", err) - } -} - -func TestListReportsDiscoversNewMetadataFilename(t *testing.T) { - store := newTestStore(t) - older := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - newer := resolveTodayAt(t, "2026-05-29T08:00:00-05:00") - olderPaths := savePriorMetadata(t, store, older, stateBriefingMetadata(older)) - newerPaths := savePriorMetadata(t, store, newer, stateBriefingMetadata(newer)) - - records, err := store.ListReports(context.Background(), 0) - if err != nil { - t.Fatalf("ListReports() error = %v", err) - } - if len(records) != 2 { - t.Fatalf("ListReports() len = %d, want 2: %#v", len(records), records) - } - if records[0].RunID != newer.Metadata().RunID || records[0].MetadataPath != newerPaths.Metadata { - t.Fatalf("first record = %#v, want newer metadata path %q", records[0], newerPaths.Metadata) - } - if records[1].RunID != older.Metadata().RunID || records[1].MetadataPath != olderPaths.Metadata { - t.Fatalf("second record = %#v, want older metadata path %q", records[1], olderPaths.Metadata) - } - - metadata, metadataPath, err := store.LoadMetadataByRunID(context.Background(), older.Metadata().RunID) - if err != nil { - t.Fatalf("LoadMetadataByRunID() error = %v", err) - } - if metadata.RunID != older.Metadata().RunID || metadataPath != olderPaths.Metadata { - t.Fatalf("loaded metadata = %#v path %q, want run %q path %q", metadata, metadataPath, older.Metadata().RunID, olderPaths.Metadata) - } - if want := "metadata." + older.Metadata().RunID + ".json"; filepath.Base(metadataPath) != want { - t.Fatalf("metadata filename = %q, want %q", filepath.Base(metadataPath), want) - } -} - -func TestListReportsIgnoresNonMetadataJSON(t *testing.T) { - store := newTestStore(t) - resolved := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - paths := savePriorMetadata(t, store, resolved, stateBriefingMetadata(resolved)) - for _, path := range []string{ - paths.ModuleSnapshot, - paths.GeneratedTextRaw, - paths.GeneratedTextResult, - paths.GeneratedText, - paths.RenderContext, - filepath.Join(filepath.Dir(paths.Metadata), resolved.Metadata().RunID+"."+"metadata.json"), - } { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("create non-metadata artifact directory: %v", err) - } - if err := os.WriteFile(path, []byte(`{not json`), 0o600); err != nil { - t.Fatalf("write non-metadata artifact %q: %v", path, err) - } - } - - records, err := store.ListReports(context.Background(), 0) - if err != nil { - t.Fatalf("ListReports() error = %v", err) - } - if len(records) != 1 { - t.Fatalf("ListReports() len = %d, want only metadata record: %#v", len(records), records) - } - if records[0].MetadataPath != paths.Metadata { - t.Fatalf("MetadataPath = %q, want %q", records[0].MetadataPath, paths.Metadata) - } -} - -func TestFindPriorSnapshot(t *testing.T) { - store := newTestStore(t) - first := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - second := resolveDailyAt(t, "2026-05-29T08:00:00-05:00") - paths := savePriorMetadata(t, store, first, stateBriefingMetadata(first)) - if want := "metadata." + first.Metadata().RunID + ".json"; filepath.Base(paths.Metadata) != want { - t.Fatalf("metadata filename = %q, want %q", filepath.Base(paths.Metadata), want) - } - - prior, err := store.FindPriorSnapshot(context.Background(), second) - if err != nil { - t.Fatalf("FindPriorSnapshot() error = %v", err) - } - if prior == nil { - t.Fatal("FindPriorSnapshot() = nil, want prior snapshot") - } - if prior.Metadata.RunID != first.Metadata().RunID { - t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID) - } - if prior.ModuleSnapshotPath != paths.ModuleSnapshot { - t.Fatalf("ModuleSnapshotPath = %q, want %q", prior.ModuleSnapshotPath, paths.ModuleSnapshot) - } -} - -func TestFindPriorSnapshotSupportsToday(t *testing.T) { - store := newTestStore(t) - first := resolveTodayAt(t, "2026-05-29T05:00:00-05:00") - second := resolveTodayAt(t, "2026-05-29T08:00:00-05:00") - savePriorMetadata(t, store, first, stateBriefingMetadata(first)) - - prior, err := store.FindPriorSnapshot(context.Background(), second) - if err != nil { - t.Fatalf("FindPriorSnapshot() error = %v", err) - } - if prior == nil { - t.Fatal("FindPriorSnapshot() = nil, want prior Today snapshot") - } - if prior.Metadata.RunID != first.Metadata().RunID { - t.Fatalf("RunID = %q, want %q", prior.Metadata.RunID, first.Metadata().RunID) - } -} - -func TestFindPriorSnapshotUsesValidDate(t *testing.T) { - store := newTestStore(t) - previousDate := resolveDailyAt(t, "2026-05-28T05:00:00-05:00") - currentDate := resolveDailyAt(t, "2026-05-29T05:00:00-05:00") - savePriorMetadata(t, store, previousDate, stateBriefingMetadata(previousDate)) - - prior, err := store.FindPriorSnapshot(context.Background(), currentDate) - if err != nil { - t.Fatalf("FindPriorSnapshot() error = %v", err) - } - if prior != nil { - t.Fatalf("FindPriorSnapshot() = %#v, want nil for different valid date", prior) - } -} - -func TestFindPriorSnapshotIgnoresRollingWindowReports(t *testing.T) { - store := newTestStore(t) - first := resolveHourlyAt(t, "2026-05-29T05:00:00-05:00") - second := resolveHourlyAt(t, "2026-05-29T06:00:00-05:00") - savePriorMetadata(t, store, first, stateBriefingMetadata(first)) - - prior, err := store.FindPriorSnapshot(context.Background(), second) - if err != nil { - t.Fatalf("FindPriorSnapshot() error = %v", err) - } - if prior != nil { - t.Fatalf("FindPriorSnapshot() = %#v, want nil for rolling-window comparison", prior) - } -} - -func TestFilesystemStoreRejectsUnsafeDirs(t *testing.T) { - cfg := config.Defaults().Workspace - cfg.Root = t.TempDir() - cfg.SnapshotsDir = "../snapshots" - - _, err := NewFilesystemStore(cfg) - if err == nil { - t.Fatal("NewFilesystemStore() error = nil, want unsafe path error") - } - if !strings.Contains(err.Error(), "within workspace root") { - t.Fatalf("error = %q, want path safety context", err.Error()) - } -} - -func newTestStore(t *testing.T) *FilesystemStore { - t.Helper() - cfg := config.Defaults().Workspace - cfg.Root = t.TempDir() - store, err := NewFilesystemStore(cfg) - if err != nil { - t.Fatalf("NewFilesystemStore() error = %v", err) - } - return store -} - -func mustLoadStateLocation(t *testing.T, name string) *time.Location { - t.Helper() - location, err := time.LoadLocation(name) - if err != nil { - t.Fatalf("LoadLocation(%q) error = %v", name, err) - } - return location -} - -func resolveDailyAt(t *testing.T, value string) report.Resolved { - t.Helper() - return resolveDailyForDateAt(t, value, value) -} - -func resolveDailyForDateAt(t *testing.T, nowValue string, dateValue string) report.Resolved { - t.Helper() - location, err := timeutil.LoadLocation("America/Chicago") - if err != nil { - t.Fatalf("LoadLocation() error = %v", err) - } - now, err := time.Parse(time.RFC3339, nowValue) - if err != nil { - t.Fatalf("parse now time: %v", err) - } - date, err := time.Parse(time.RFC3339, dateValue) - if err != nil { - t.Fatalf("parse date time: %v", err) - } - resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{ - Now: now, - Location: location, - Date: date, - }) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - return resolved -} - -func resolveTodayAt(t *testing.T, value string) report.Resolved { - t.Helper() - location, err := timeutil.LoadLocation("America/Chicago") - if err != nil { - t.Fatalf("LoadLocation() error = %v", err) - } - now, err := time.Parse(time.RFC3339, value) - if err != nil { - t.Fatalf("parse time: %v", err) - } - resolved, err := report.DefaultRegistry().Resolve(report.Today, report.ResolveRequest{ - Now: now, - Location: location, - }) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - return resolved -} - -func resolveHourlyAt(t *testing.T, value string) report.Resolved { - t.Helper() - location, err := timeutil.LoadLocation("America/Chicago") - if err != nil { - t.Fatalf("LoadLocation() error = %v", err) - } - now, err := time.Parse(time.RFC3339, value) - if err != nil { - t.Fatalf("parse time: %v", err) - } - resolved, err := report.DefaultRegistry().Resolve(report.Hourly, report.ResolveRequest{ - Now: now, - Location: location, - }) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - return resolved -} - -func resolveTomorrowAt(t *testing.T, value string) report.Resolved { - t.Helper() - location, err := timeutil.LoadLocation("America/Chicago") - if err != nil { - t.Fatalf("LoadLocation() error = %v", err) - } - now, err := time.Parse(time.RFC3339, value) - if err != nil { - t.Fatalf("parse time: %v", err) - } - resolved, err := report.DefaultRegistry().Resolve(report.Tomorrow, report.ResolveRequest{ - Now: now, - Location: location, - }) - if err != nil { - t.Fatalf("Resolve() error = %v", err) - } - return resolved -} - -func stateBriefingMetadata(resolved report.Resolved) briefing.Metadata { - return briefing.Metadata{ - RunID: resolved.Metadata().RunID, - ReportID: resolved.Definition.ID, - Variant: "today", - PromptID: resolved.Definition.PromptID, - GeneratedAt: resolved.GeneratedAt, - Units: "us", - Timezone: resolved.Timezone, - Location: &briefing.LocationContext{ - ID: "home", - Name: "Brentwood", - Region: "St. Louis Metro", - Timezone: resolved.Timezone, - }, - ValidPeriod: resolved.ValidPeriod, - } -} - -func savePriorMetadata(t *testing.T, store *FilesystemStore, resolved report.Resolved, metadata briefing.Metadata) ArtifactPaths { - t.Helper() - paths, err := store.Paths(resolved) - if err != nil { - t.Fatalf("Paths() error = %v", err) - } - _, err = store.SaveMetadata(context.Background(), BuildMetadataFromBriefingMetadata(resolved, metadata, ArtifactPaths{ - ModuleSnapshot: paths.ModuleSnapshot, - Metadata: paths.Metadata, - DataPackage: paths.DataPackage, - Preflight: paths.Preflight, - RenderedReport: paths.RenderedReport, - })) - if err != nil { - t.Fatalf("SaveMetadata() error = %v", err) - } - return paths -} - -func pathsString(paths ArtifactPaths) string { - return strings.Join([]string{ - paths.Metadata, - paths.ModuleSnapshot, - paths.DataPackage, - paths.Preparation, - paths.Execution, - paths.Preflight, - paths.Notification, - paths.RenderedReport, - paths.GeneratedTextRaw, - paths.GeneratedTextResult, - paths.GeneratedText, - paths.RenderContext, - }, "\n") -} diff --git a/internal/state/metadata.go b/internal/state/metadata.go index 8b91dc2..930fbda 100644 --- a/internal/state/metadata.go +++ b/internal/state/metadata.go @@ -46,8 +46,8 @@ type Metadata struct { GeneratedTextPath string `json:"generatedTextPath,omitempty"` RenderContextPath string `json:"renderContextPath,omitempty"` - // These paths are retained only to preserve records written by the temporary - // Scriptorium flow. They are never emitted in V2 metadata. + // These paths are retained only to read legacy V1 records. They are never + // emitted in V2 metadata. PreflightPath string `json:"-"` GeneratedTextResultPath string `json:"-"` } @@ -170,47 +170,22 @@ func (m Metadata) Validate() error { return nil } -func BuildMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata { - metadata := resolved.Metadata() - out := Metadata{ - SchemaVersion: MetadataSchemaVersionV1, - RunID: metadata.RunID, - MetadataPath: paths.Metadata, - ReportID: metadata.ReportID, - Variant: briefingMetadata.Variant, - PromptID: metadata.PromptID, - GeneratedAt: metadata.GeneratedAt, - Timezone: metadata.Timezone, - ValidPeriod: metadata.ValidPeriod, - Location: copyLocation(briefingMetadata.Location), - SourceLocationID: briefingMetadata.SourceLocationID, - SourceLocation: briefingMetadata.SourceLocation, - Sources: briefingMetadata.Sources, - SourceWarnings: briefingMetadata.SourceWarnings, - ModuleSnapshotPath: paths.ModuleSnapshot, - DataPackagePath: paths.DataPackage, - PreflightPath: paths.Preflight, - RenderedReportPath: paths.RenderedReport, - } - out.GeneratedTextSchemaID = resolved.Definition.GeneratedTextSchemaID - out.GeneratedTextRawPath = paths.GeneratedTextRaw - out.GeneratedTextResultPath = paths.GeneratedTextResult - out.GeneratedTextPath = paths.GeneratedText - out.RenderContextPath = paths.RenderContext - return out -} - // BuildPromptMetadataFromBriefingMetadata creates the V2 record used by the // prompt execution workflow. Callers populate preparation and execution paths // only after their corresponding artifacts have been saved. func BuildPromptMetadataFromBriefingMetadata(resolved report.Resolved, briefingMetadata briefing.Metadata, paths ArtifactPaths) Metadata { - legacy := BuildMetadataFromBriefingMetadata(resolved, briefingMetadata, paths) - legacy.SchemaVersion = MetadataSchemaVersion - legacy.PreparationPath = "" - legacy.ExecutionPath = "" - legacy.PreflightPath = "" - legacy.GeneratedTextResultPath = "" - return legacy + metadata := resolved.Metadata() + return Metadata{ + SchemaVersion: MetadataSchemaVersion, RunID: metadata.RunID, MetadataPath: paths.Metadata, + ReportID: metadata.ReportID, Variant: briefingMetadata.Variant, PromptID: metadata.PromptID, + GeneratedAt: metadata.GeneratedAt, Timezone: metadata.Timezone, ValidPeriod: metadata.ValidPeriod, + Location: copyLocation(briefingMetadata.Location), SourceLocationID: briefingMetadata.SourceLocationID, + SourceLocation: briefingMetadata.SourceLocation, Sources: briefingMetadata.Sources, + SourceWarnings: briefingMetadata.SourceWarnings, ModuleSnapshotPath: paths.ModuleSnapshot, + DataPackagePath: paths.DataPackage, RenderedReportPath: paths.RenderedReport, + GeneratedTextSchemaID: resolved.Definition.GeneratedTextSchemaID, GeneratedTextRawPath: paths.GeneratedTextRaw, + GeneratedTextPath: paths.GeneratedText, RenderContextPath: paths.RenderContext, + } } func copyLocation(location *briefing.LocationContext) *briefing.LocationContext { diff --git a/internal/state/store.go b/internal/state/store.go index c9648ff..415e65b 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -16,13 +16,11 @@ type Store interface { SaveModuleSnapshot(context.Context, report.Resolved, module.Snapshot) (string, error) SaveDataPackage(context.Context, report.Resolved, promptinput.Package) (string, error) SaveDataPackageBytes(context.Context, report.Resolved, []byte) (string, error) - SavePreflight(context.Context, report.Resolved, PreflightArtifact) (string, error) SavePromptPreparation(context.Context, report.Resolved, PromptPreparationArtifact) (string, error) SavePromptExecution(context.Context, report.Resolved, PromptExecutionArtifact) (string, error) SaveDistributorNotification(context.Context, report.Resolved, DistributorNotificationArtifact) (string, error) SaveBatchDistributorNotification(context.Context, BatchDistributorNotificationRef, BatchDistributorNotificationArtifact) (string, error) SaveGeneratedTextRaw(context.Context, report.Resolved, []byte) (string, error) - SaveGeneratedTextResult(context.Context, report.Resolved, any) (string, error) SaveGeneratedText(context.Context, report.Resolved, []byte) (string, error) SaveRenderContext(context.Context, report.Resolved, any) (string, error) PrepareRenderedReport(context.Context, report.Resolved) (string, error) @@ -30,7 +28,6 @@ type Store interface { FindPriorSnapshot(context.Context, report.Resolved) (*PriorSnapshot, error) LoadModuleSnapshot(context.Context, string) (module.Snapshot, error) LoadGeneratedText(context.Context, string) ([]byte, error) - LoadGeneratedTextResult(context.Context, string, any) error LoadPromptPreparation(context.Context, string) (PromptPreparationArtifact, error) LoadPromptExecution(context.Context, string) (PromptExecutionArtifact, error) LoadRenderContext(context.Context, string, any) error @@ -41,15 +38,6 @@ type PriorSnapshot struct { ModuleSnapshotPath string } -type PreflightArtifact struct { - Command []string `json:"command"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - StdoutTruncated bool `json:"stdoutTruncated,omitempty"` - StderrTruncated bool `json:"stderrTruncated,omitempty"` - ExitCode int `json:"exitCode"` -} - const DistributorNotificationSchemaVersion = "weatherreporter.distributor_notification.v1" const BatchDistributorNotificationSchemaVersion = "weatherreporter.batch_distributor_notification.v1"