diff --git a/docs/cli.md b/docs/cli.md index f66bca8..0b795ae 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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`. diff --git a/docs/operations.md b/docs/operations.md index 85ba716..7f6def5 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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. diff --git a/internal/app/app.go b/internal/app/app.go index bf6aaff..cacc35a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index a848081..744388e 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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 diff --git a/internal/cli/root.go b/internal/cli/root.go index e291f21..0e6e260 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -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") diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 6e0e4f6..a4d8cb1 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -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 +}