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