Harden scheduled report runs
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user