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
workspace. `run evening` generates the Tomorrow Planning Brief. `run morning`
generates Daily Today and the 3-Day Outlook, plus Weekend Outlook except on
Sunday. Other `generate` commands resolve one report request and stop before
report generation.
Sunday. Run commands continue remaining reports after an independent report
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
@@ -62,6 +64,7 @@ report generation.
- `--units VALUE`: override configured Weather API units.
- `--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-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.
- `--start TIME`: required start 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
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
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
`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 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
- 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
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
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
remote storage behavior.

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
@@ -48,9 +49,12 @@ type GenerateRequest struct {
}
type BatchRequest struct {
Config config.Config
Batch BatchKind
Now time.Time
Config config.Config
Batch BatchKind
Now time.Time
OutputDir string
Renderer Renderer
Store state.Store
}
type FetchBundleRequest struct {
@@ -101,6 +105,44 @@ type ReportResult struct {
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 {
Render(context.Context, scriptorium.RenderRequest) (*scriptorium.RenderResult, 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 {
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
if now.IsZero() {
now = time.Now()
}
resolvedReports, err := ResolveBatch(req, now)
if err != nil {
return err
return nil, err
}
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 {
if !isGeneratedReport(resolved.Definition.ID) {
return fmt.Errorf("run is not implemented")
return nil, fmt.Errorf("run is not implemented")
}
}
for _, resolved := range resolvedReports {
if _, err := GenerateReport(ctx, ReportRequest{
Config: req.Config,
Resolved: resolved,
}); err != nil {
return err
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,
Resolved: resolved,
OutputPath: outputPath,
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)
}
return nil
result.Total = len(result.Reports)
result.FinishedAt = time.Now()
return result, nil
}
return fmt.Errorf("run is not implemented")
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,
}
}
func batchOutputPath(outputDir string, definition report.Definition) string {
if outputDir == "" || definition.DefaultOutputName == "" {
return ""
}
name := strings.ReplaceAll(definition.DefaultOutputName, "_", "-")
return filepath.Join(outputDir, name)
}
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 {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
@@ -872,6 +943,31 @@ type recordingRenderer struct {
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) {
r.renderCalls++
r.renderRequest = req

View File

@@ -2,6 +2,7 @@ package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
@@ -20,15 +21,16 @@ Usage:
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 storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME]
weatherreporter run evening [--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] [--out-dir PATH]
Options:
-h, --help Show this help message.
--config PATH Load configuration from PATH instead of /usr/local/etc/weatherreporter/config.yml.
--units VALUE Override weather API units.
--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 {
@@ -61,7 +63,17 @@ func (r Runner) Run(ctx context.Context, args []string, stdout io.Writer, stderr
if err != nil {
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:
return fmt.Errorf("unknown command %q", args[0])
}
@@ -72,6 +84,7 @@ type commonOptions struct {
Units string
Timezone string
Output string
OutputDir string
}
type generateOptions struct {
@@ -174,7 +187,7 @@ func (r Runner) resolveRun(args []string) (app.BatchRequest, error) {
if err != nil {
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) {
@@ -207,6 +220,7 @@ func parseRunFlags(args []string) (commonOptions, error) {
fs.SetOutput(io.Discard)
opts := commonOptions{}
addCommonFlags(fs, &opts, false)
fs.StringVar(&opts.OutputDir, "out-dir", "", "extra Markdown report copy directory")
if err := fs.Parse(args); err != nil {
return commonOptions{}, err
}
@@ -216,6 +230,26 @@ func parseRunFlags(args []string) (commonOptions, error) {
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) {
fs.StringVar(&opts.ConfigPath, "config", "", "configuration file path")
fs.StringVar(&opts.Units, "units", "", "weather API units")

View File

@@ -3,6 +3,7 @@ package cli
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"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) {
server := dailyServer(t)
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 {
return timeutil.FixedClock{Time: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)}
}
@@ -562,3 +659,44 @@ exit 1
}
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
}