Harden scheduled report runs

This commit is contained in:
2026-05-29 18:20:39 +00:00
parent 5d543b6b4d
commit 108a1618f6
6 changed files with 416 additions and 19 deletions

View File

@@ -52,8 +52,10 @@ weatherreporter run evening
preflight output, Markdown report, and metadata file under the configured preflight output, Markdown report, and metadata file under the configured
workspace. `run evening` generates the Tomorrow Planning Brief. `run morning` workspace. `run evening` generates the Tomorrow Planning Brief. `run morning`
generates Daily Today and the 3-Day Outlook, plus Weekend Outlook except on generates Daily Today and the 3-Day Outlook, plus Weekend Outlook except on
Sunday. Other `generate` commands resolve one report request and stop before Sunday. Run commands continue remaining reports after an independent report
report generation. failure, print a JSON aggregate summary to stdout, write compact report status
logs to stderr, and return nonzero when any report failed. Other `generate`
commands resolve one report request and stop before report generation.
## Flags ## Flags
@@ -62,6 +64,7 @@ report generation.
- `--units VALUE`: override configured Weather API units. - `--units VALUE`: override configured Weather API units.
- `--tz NAME`: override configured Weather API timezone. - `--tz NAME`: override configured Weather API timezone.
- `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, and `generate weekend`; reserved for later generated report output on other `generate` commands. - `--out PATH`: optional Markdown report copy for `generate daily`, `generate tomorrow`, `generate three-day`, and `generate weekend`; reserved for later generated report output on other `generate` commands.
- `--out-dir PATH`: optional directory for extra Markdown report copies from `run morning` and `run evening`.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone. - `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`. - `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`. - `--end TIME`: required end time for `generate storm`.

View File

@@ -20,6 +20,11 @@ workspace. The evening run resolves only the Tomorrow Planning Brief. The
morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook morning run generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. except on Sunday.
Scheduled run commands print a JSON aggregate summary to stdout and compact
per-report status lines to stderr. If one report fails, remaining independent
reports are still attempted. The command returns nonzero after the run when any
report failed.
## Filesystem Layout ## Filesystem Layout
The default workspace root is `workspace`. The default workspace root is `workspace`.
@@ -72,6 +77,10 @@ The Markdown report is written to a RunID-managed report path. When `--out` is
provided to `generate daily`, `generate tomorrow`, `generate three-day`, or provided to `generate daily`, `generate tomorrow`, `generate three-day`, or
`generate weekend`, the managed report is also copied to that path. `generate weekend`, the managed report is also copied to that path.
For `run morning` and `run evening`, `--out-dir PATH` writes extra Markdown
copies using each report definition's default filename, such as `daily.md`,
`three-day.md`, `weekend.md`, or `tomorrow.md`.
## Run Identifiers ## Run Identifiers
Run IDs are based on generation time plus report ID, such as: Run IDs are based on generation time plus report ID, such as:
@@ -96,6 +105,9 @@ Each generated report writes metadata that links:
- preflight output path - preflight output path
- rendered report path - rendered report path
Run summaries include each report ID, prompt ID, RunID, status, error text when
applicable, valid period, and artifact paths known to the application.
## Recent Changes ## Recent Changes
When a prior comparable Daily briefing snapshot exists for the same valid local When a prior comparable Daily briefing snapshot exists for the same valid local
@@ -124,5 +136,9 @@ If `scriptorium run` exits nonzero after writing a report, the generated report
and metadata remain available for inspection. Exit code `2` is still returned as and metadata remain available for inspection. Exit code `2` is still returned as
an error because it indicates validation failed, even if report output exists. an error because it indicates validation failed, even if report output exists.
For scheduled runs, inspect stdout first for the aggregate JSON summary, then
use the per-report artifact paths in that summary to inspect briefing,
data-package, preflight, metadata, and rendered report files.
The application does not currently implement resume, cleanup, archive, or The application does not currently implement resume, cleanup, archive, or
remote storage behavior. remote storage behavior.

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium" "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
@@ -51,6 +52,9 @@ type BatchRequest struct {
Config config.Config Config config.Config
Batch BatchKind Batch BatchKind
Now time.Time Now time.Time
OutputDir string
Renderer Renderer
Store state.Store
} }
type FetchBundleRequest struct { type FetchBundleRequest struct {
@@ -101,6 +105,44 @@ type ReportResult struct {
type DailyReportResult = ReportResult type DailyReportResult = ReportResult
type BatchResult struct {
Batch BatchKind `json:"batch"`
StartedAt time.Time `json:"startedAt"`
FinishedAt time.Time `json:"finishedAt"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Reports []BatchReportResult `json:"reports"`
}
type BatchReportResult struct {
ReportID report.ID `json:"reportId"`
ReportName string `json:"reportName"`
PromptID string `json:"promptId"`
RunID string `json:"runId"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
GeneratedAt time.Time `json:"generatedAt"`
ValidPeriod timeutil.Period `json:"validPeriod"`
BriefingPath string `json:"briefingPath,omitempty"`
DataPackagePath string `json:"dataPackagePath,omitempty"`
PreflightPath string `json:"preflightPath,omitempty"`
ReportPath string `json:"reportPath,omitempty"`
OutputPath string `json:"outputPath,omitempty"`
MetadataPath string `json:"metadataPath,omitempty"`
}
type BatchError struct {
Result *BatchResult
}
func (e BatchError) Error() string {
if e.Result == nil {
return "batch failed"
}
return fmt.Sprintf("batch %s failed: %d of %d reports failed", e.Result.Batch, e.Result.Failed, e.Result.Total)
}
type Renderer interface { type Renderer interface {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error) Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, error)
Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error) Run(context.Context, scriptorium.RunRequest) (*scriptorium.RunResult, error)
@@ -127,31 +169,99 @@ func Generate(ctx context.Context, req GenerateRequest) error {
} }
func RunBatch(ctx context.Context, req BatchRequest) error { func RunBatch(ctx context.Context, req BatchRequest) error {
result, err := RunBatchDetailed(ctx, req)
if err != nil {
return err
}
if result.Failed > 0 {
return BatchError{Result: result}
}
return nil
}
func RunBatchDetailed(ctx context.Context, req BatchRequest) (*BatchResult, error) {
now := req.Now now := req.Now
if now.IsZero() { if now.IsZero() {
now = time.Now() now = time.Now()
} }
resolvedReports, err := ResolveBatch(req, now) resolvedReports, err := ResolveBatch(req, now)
if err != nil { if err != nil {
return err return nil, err
} }
if req.Batch == BatchEvening || req.Batch == BatchMorning { if req.Batch == BatchEvening || req.Batch == BatchMorning {
store := req.Store
if store == nil {
defaultStore, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
store = defaultStore
}
startedAt := now
result := &BatchResult{Batch: req.Batch, StartedAt: startedAt}
for _, resolved := range resolvedReports { for _, resolved := range resolvedReports {
if !isGeneratedReport(resolved.Definition.ID) { if !isGeneratedReport(resolved.Definition.ID) {
return fmt.Errorf("run is not implemented") return nil, fmt.Errorf("run is not implemented")
} }
} }
for _, resolved := range resolvedReports { for _, resolved := range resolvedReports {
if _, err := GenerateReport(ctx, ReportRequest{ item := batchReportResult(resolved)
if paths, err := store.Paths(resolved); err == nil {
item.BriefingPath = paths.Briefing
item.DataPackagePath = paths.DataPackage
item.PreflightPath = paths.Preflight
item.ReportPath = paths.RenderedReport
item.MetadataPath = paths.Metadata
}
outputPath := batchOutputPath(req.OutputDir, resolved.Definition)
reportResult, err := GenerateReport(ctx, ReportRequest{
Config: req.Config, Config: req.Config,
Resolved: resolved, Resolved: resolved,
}); err != nil { OutputPath: outputPath,
return err Renderer: req.Renderer,
Store: store,
})
if err != nil {
item.Status = "failed"
item.Error = err.Error()
result.Failed++
} else {
item.Status = "succeeded"
item.BriefingPath = reportResult.BriefingPath
item.DataPackagePath = reportResult.DataPackagePath
item.PreflightPath = reportResult.PreflightPath
item.ReportPath = reportResult.ReportPath
item.OutputPath = reportResult.OutputPath
item.MetadataPath = reportResult.MetadataPath
result.Succeeded++
}
result.Reports = append(result.Reports, item)
}
result.Total = len(result.Reports)
result.FinishedAt = time.Now()
return result, nil
}
return nil, fmt.Errorf("run is not implemented")
}
func batchReportResult(resolved report.Resolved) BatchReportResult {
metadata := resolved.Metadata()
return BatchReportResult{
ReportID: resolved.Definition.ID,
ReportName: resolved.Definition.Name,
PromptID: resolved.Definition.PromptID,
RunID: metadata.RunID,
GeneratedAt: metadata.GeneratedAt,
ValidPeriod: metadata.ValidPeriod,
} }
} }
return nil
func batchOutputPath(outputDir string, definition report.Definition) string {
if outputDir == "" || definition.DefaultOutputName == "" {
return ""
} }
return fmt.Errorf("run is not implemented") name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-")
return filepath.Join(outputDir, name)
} }
func isGeneratedReport(id report.ID) bool { func isGeneratedReport(id report.ID) bool {

View File

@@ -763,6 +763,77 @@ func TestResolveBatchMorningSkipsWeekendOnSunday(t *testing.T) {
} }
} }
func TestRunBatchContinuesAfterReportFailure(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
renderer := &selectiveRenderer{
failRenderPrompt: "weather.three_day_outlook",
runBody: "# Batch Report\n",
}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Renderer: renderer,
})
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 renderer.runCalls != 2 {
t.Fatalf("run calls = %d, want successful reports to continue", renderer.runCalls)
}
var failedThreeDay bool
for _, item := range result.Reports {
if item.ReportID == report.ThreeDay && item.Status == "failed" && strings.Contains(item.Error, "render failed") {
failedThreeDay = true
}
if item.ReportID != report.ThreeDay && item.Status != "succeeded" {
t.Fatalf("report %s status = %s, want succeeded", item.ReportID, item.Status)
}
}
if !failedThreeDay {
t.Fatalf("reports = %#v, want failed 3-day item", result.Reports)
}
}
func TestRunBatchUsesOutputDirectory(t *testing.T) {
server := dailyBundleServer(t)
cfg := config.Defaults()
cfg.WeatherAPI.BaseURL = server.URL + "/"
cfg.WeatherAPI.Timezone = "America/Chicago"
cfg.Workspace.Root = t.TempDir()
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,
Renderer: &selectiveRenderer{runBody: "# Tomorrow\n"},
})
if err != nil {
t.Fatalf("RunBatchDetailed() error = %v", err)
}
if result.Failed != 0 || len(result.Reports) != 1 {
t.Fatalf("summary = %#v, want one successful report", result)
}
want := filepath.Join(outputDir, "tomorrow.md")
if result.Reports[0].OutputPath != want {
t.Fatalf("OutputPath = %q, want %q", result.Reports[0].OutputPath, want)
}
if _, err := os.Stat(want); err != nil {
t.Fatalf("expected output copy %q: %v", want, err)
}
}
func mustParse(value string) time.Time { func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value) parsed, err := time.Parse(time.RFC3339, value)
if err != nil { if err != nil {
@@ -872,6 +943,31 @@ type recordingRenderer struct {
runBody string runBody string
} }
type selectiveRenderer struct {
renderCalls int
runCalls int
failRenderPrompt string
runBody string
}
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) Run(_ context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) {
r.runCalls++
if r.runBody != "" {
if err := os.WriteFile(req.OutputPath, []byte(r.runBody), 0o600); err != nil {
return nil, err
}
}
return &scriptorium.RunResult{ExitCode: 0, OutputPath: req.OutputPath}, nil
}
func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) { func (r *recordingRenderer) Render(_ context.Context, req scriptorium.RenderRequest) (*scriptorium.RenderResult, error) {
r.renderCalls++ r.renderCalls++
r.renderRequest = req r.renderRequest = req

View File

@@ -2,6 +2,7 @@ package cli
import ( import (
"context" "context"
"encoding/json"
"flag" "flag"
"fmt" "fmt"
"io" "io"
@@ -20,15 +21,16 @@ Usage:
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
Options: Options:
-h, --help Show this help message. -h, --help Show this help message.
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml. --config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units. --units VALUE Override weather API units.
--tz NAME Override weather API timezone. --tz NAME Override weather API timezone.
--out PATH Write an extra Markdown report copy for generate daily or tomorrow. --out PATH Write an extra Markdown report copy for generate commands.
--out-dir PATH Write extra Markdown report copies for run commands.
` `
type Runner struct { type Runner struct {
@@ -61,7 +63,17 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
if err != nil { if err != nil {
return err return err
} }
return app.RunBatch(ctx, req) result, err := app.RunBatchDetailed(ctx, req)
if result != nil {
writeRunLogs(stderr, result)
if encodeErr := writeRunSummary(stdout, result); encodeErr != nil {
return encodeErr
}
if result.Failed > 0 {
return app.BatchError{Result: result}
}
}
return err
default: default:
return fmt.Errorf("unknown command %q", args[0]) return fmt.Errorf("unknown command %q", args[0])
} }
@@ -72,6 +84,7 @@ type commonOptions struct {
Units string Units string
Timezone string Timezone string
Output string Output string
OutputDir string
} }
type generateOptions struct { type generateOptions struct {
@@ -174,7 +187,7 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
if err != nil { if err != nil {
return app.BatchRequest{}, err return app.BatchRequest{}, err
} }
return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now()}, nil return app.BatchRequest{Config: cfg, Batch: batch, Now: r.Clock.Now(), OutputDir: opts.OutputDir}, nil
} }
func resolveRun(args []string) (app.BatchRequest, error) { func resolveRun(args []string) (app.BatchRequest, error) {
@@ -207,6 +220,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
fs.SetOutput(io.Discard) fs.SetOutput(io.Discard)
opts := commonOptions{} opts := commonOptions{}
addCommonFlags(fs, &opts, false) addCommonFlags(fs, &opts, false)
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return commonOptions{}, err return commonOptions{}, err
} }
@@ -216,6 +230,26 @@ func parseRunFlags(args []string) (commonOptions, error) {
return opts, nil return opts, nil
} }
func writeRunSummary(stdout io.Writer, result *app.BatchResult) error {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}
func writeRunLogs(stderr io.Writer, result *app.BatchResult) {
if stderr == nil || result == nil {
return
}
for _, item := range result.Reports {
if item.Status == "failed" {
_, _ = fmt.Fprintf(stderr, "report=%s status=failed error=%q\n", item.ReportID, item.Error)
continue
}
_, _ = fmt.Fprintf(stderr, "report=%s status=succeeded output=%q\n", item.ReportID, item.OutputPath)
}
_, _ = fmt.Fprintf(stderr, "batch=%s total=%d succeeded=%d failed=%d\n", result.Batch, result.Total, result.Succeeded, result.Failed)
}
func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) { func addCommonFlags(fs *flag.FlagSet, opts *commonOptions, includeOutput bool) {
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path") fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.StringVar(&opts.Units, "units", "", "weather API units") fs.StringVar(&opts.Units, "units", "", "weather API units")

View File

@@ -3,6 +3,7 @@ package cli
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -283,6 +284,92 @@ func TestRunMorningIncludesWeekendExceptSunday(t *testing.T) {
} }
} }
func TestRunMorningReportsPartialFailureAndContinues(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFailingScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"run", "morning",
"--config", configPath,
}, &stdout, &stderr)
if err == nil {
t.Fatal("Run() error = nil, want aggregate failure")
}
if !strings.Contains(err.Error(), "1 of 3 reports failed") {
t.Fatalf("Run() error = %q, want aggregate failure", err.Error())
}
var summary app.BatchResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
}
if summary.Total != 3 || summary.Succeeded != 2 || summary.Failed != 1 {
t.Fatalf("summary total/succeeded/failed = %d/%d/%d, want 3/2/1", summary.Total, summary.Succeeded, summary.Failed)
}
if !strings.Contains(stderr.String(), "status=failed") || !strings.Contains(stderr.String(), "status=succeeded") {
t.Fatalf("stderr missing structured report logs:\n%s", stderr.String())
}
dailyPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "daily", "2026-05-29", "*.data_package.json"))
if err != nil {
t.Fatalf("glob daily packages: %v", err)
}
weekendPackages, err := filepath.Glob(filepath.Join(workspaceRoot, "data-packages", "weekend", "2026-05-29", "*.data_package.json"))
if err != nil {
t.Fatalf("glob weekend packages: %v", err)
}
if len(dailyPackages) != 1 || len(weekendPackages) != 1 {
t.Fatalf("daily packages = %#v, weekend packages = %#v; want successful reports to continue", dailyPackages, weekendPackages)
}
}
func TestRunEveningUsesOutputDirectoryAndSummary(t *testing.T) {
server := dailyServer(t)
tempDir := t.TempDir()
scriptoriumPath := writeFakeScriptorium(t, tempDir)
configPath := filepath.Join(tempDir, "config.yml")
workspaceRoot := filepath.Join(tempDir, "workspace")
outputDir := filepath.Join(tempDir, "copies")
configBody := "weather_api:\n base_url: " + server.URL + "/\n timezone: America/Chicago\nscriptorium:\n binary: " + scriptoriumPath + "\nworkspace:\n root: " + workspaceRoot + "\n"
if err := os.WriteFile(configPath, []byte(configBody), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
runner := Runner{Clock: fixedClock()}
err := runner.Run(context.Background(), []string{
"run", "evening",
"--config", configPath,
"--out-dir", outputDir,
}, &stdout, &stderr)
if err != nil {
t.Fatalf("Run() error = %v", err)
}
var summary app.BatchResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &summary); decodeErr != nil {
t.Fatalf("decode summary: %v\n%s", decodeErr, stdout.String())
}
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 TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) { func TestRunMorningGeneratesDailyAndThreeDayOnSunday(t *testing.T) {
server := dailyServer(t) server := dailyServer(t)
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -505,6 +592,16 @@ func TestResolveRunRejectsOutputFlag(t *testing.T) {
} }
} }
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 { func fixedClock() timeutil.Clock {
return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)} return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)}
} }
@@ -562,3 +659,44 @@ exit 1
} }
return path 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.three_day_outlook" ]; then
printf 'render failed\n' >&2
exit 1
fi
printf '{"ok":true,"prompt":"%s"}' "$prompt"
exit 0
fi
if [ "$1" = "run" ]; then
out=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--out" ]; then
shift
out="$1"
fi
shift
done
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
}