From f6e20d14126c25c02173d602fb35561f4e49db0a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 14 Jun 2026 05:13:40 +0000 Subject: [PATCH] Notify hourly generated reports after success --- docs/cli.md | 16 ++-- docs/internal/app-orchestration.md | 5 + docs/operations.md | 14 +-- internal/app/app.go | 25 ++++- internal/app/app_test.go | 143 +++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 15 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 3f45a27..1960cee 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -35,14 +35,14 @@ weatherreporter inspect prior [--config PATH] RUN_ID weatherreporter inspect sources [--config PATH] RUN_ID ``` -Implemented Markdown `generate` commands write a JSON module snapshot, YAML -data package, preflight artifact, managed Markdown report, and metadata under -the configured workspace. `--out` writes an extra Markdown copy for the -operator on those Markdown-path commands; distributor notification uses the -managed report path, not the extra copy. `generate hourly` covers the next six -hours in the effective report timezone, does not accept date or event window -flags, writes managed generated-text artifacts, validates the structured text, -and renders the managed Markdown report from the embedded hourly template. +Implemented `generate` commands write a JSON module snapshot, YAML data package, +preflight artifact, managed Markdown report, and metadata under the configured +workspace. `--out` writes an extra Markdown copy for the operator; distributor +notification uses the managed report path, not the extra copy. `generate +hourly` covers the next six hours in the effective report timezone, does not +accept date or event window flags, writes managed generated-text artifacts, +validates the structured text, and renders the managed Markdown report from the +embedded hourly template. `generate storm` requires explicit event-window bounds with `--start` and `--end`. diff --git a/docs/internal/app-orchestration.md b/docs/internal/app-orchestration.md index a7b188c..bd15877 100644 --- a/docs/internal/app-orchestration.md +++ b/docs/internal/app-orchestration.md @@ -97,6 +97,11 @@ For `generated_text_template` reports, generation then: 16. Renders Markdown from the embedded template to the managed report path. 17. Saves final metadata with generated-text paths, render context path, schema ID, and managed report path. +18. Copies the managed report to the requested `--out` path when provided. +19. If distributor notification is enabled, notifies using the managed report + path as the source file. +20. Saves a distributor notification debug artifact and updates metadata with + its path. If render preflight returns both a result and an error, preflight JSON and metadata are persisted before the error is returned. If Scriptorium report diff --git a/docs/operations.md b/docs/operations.md index 5aa22cd..83334cd 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -31,7 +31,10 @@ the effective report timezone and is not part of scheduled morning or evening batches. It builds the same module snapshot and data package, runs `scriptorium render` as preflight, runs structured `scriptorium run` to raw GeneratedText JSON, validates the structured text, saves a render context, and -renders the managed Markdown report from the embedded hourly template. +renders the managed Markdown report from the embedded hourly template. When +distributor notification is enabled, hourly uploads the managed Markdown report +after final metadata is saved. `--out PATH` writes an extra Markdown copy and +is not used as the distributor upload source. Batch commands: @@ -193,11 +196,10 @@ report generation has a distinct retry identity. The default bundle path uses the valid-period start date, artifact group, and RunID. Distributor owns destination merge, retention, and derived snapshot behavior such as `latest`. -Notification happens after final metadata save for Markdown-path generation. -Weather API, module snapshot, data-package, render preflight, Scriptorium run, -and metadata-save failures do not trigger notification. Hourly generated-text -reports write the managed Markdown report and generated-text artifacts but do -not notify distributor yet. A notification failure fails that report. +Notification happens after final metadata save for generated reports. Weather +API, module snapshot, data-package, render preflight, Scriptorium run, +generated-text validation, template rendering, and metadata-save failures do +not trigger notification. A notification failure fails that report. In a batch, other reports continue, the failed report includes notification fields in the JSON summary, and the batch returns nonzero. diff --git a/internal/app/app.go b/internal/app/app.go index a9b2638..8b42694 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -720,6 +720,27 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) if err != nil { return nil, err } + outputPath := reportPath + if req.OutputPath != "" { + outputPath = req.OutputPath + if req.OutputPath != reportPath { + if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil { + return nil, err + } + } + } + + notification, notificationPath, notificationErr := notifyReport(ctx, req.Config, req.Resolved, reportPath, req.metadata, req.Notifier, req.store) + if notificationPath != "" { + req.metadata.NotificationPath = notificationPath + metadataPath, err = req.store.SaveMetadata(ctx, req.metadata) + if err != nil { + return nil, err + } + } + if notificationErr != nil { + return nil, notificationErr + } return &ReportResult{ ModuleSnapshot: req.moduleSnapshot, @@ -728,7 +749,8 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) DataPackagePath: req.dataPackagePath, PreflightPath: req.preflightPath, ReportPath: reportPath, - OutputPath: reportPath, + OutputPath: outputPath, + NotificationPath: notificationPath, Metadata: req.metadata, MetadataPath: metadataPath, RecentChanges: req.recentChanges, @@ -738,6 +760,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest) GeneratedTextResultPath: generatedTextResultPath, GeneratedTextPath: generatedTextPath, RenderContextPath: renderContextPath, + Notification: notification, }, nil } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 453d5bd..f6254fe 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -501,6 +501,137 @@ func TestGenerateHourlyReportUsesGeneratedTextTemplateWorkflow(t *testing.T) { } } +func TestGenerateHourlyReportCopiesOutputAndNotifiesManagedReport(t *testing.T) { + cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) + notifier.result = &NotificationResult{ + RunID: "distributor-run", + Status: "succeeded", + UploadStatus: "accepted", + PipelineID: "reports", + Report: []byte(`{"actions":[{"action":"replace_older"}]}`), + } + renderer := &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, + structuredRunBody: validHourlyGeneratedTextJSON(), + } + + result, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + OutputPath: outputPath, + Renderer: renderer, + Store: store, + Notifier: notifier, + }) + if err != nil { + t.Fatalf("GenerateReport() error = %v", err) + } + if result.OutputPath != outputPath { + t.Fatalf("OutputPath = %q, want requested copy %q", result.OutputPath, outputPath) + } + assertPathsExist(t, result.ReportPath, outputPath, result.NotificationPath) + reportData, err := os.ReadFile(result.ReportPath) + if err != nil { + t.Fatalf("read managed report: %v", err) + } + copyData, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read output copy: %v", err) + } + if string(copyData) != string(reportData) { + t.Fatalf("output copy differs from managed report") + } + if len(notifier.requests) != 1 { + t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) + } + req := notifier.requests[0] + if req.ReportPath != result.ReportPath { + t.Fatalf("notification ReportPath = %q, want managed report path %q", req.ReportPath, result.ReportPath) + } + if req.ReportPath == outputPath { + t.Fatalf("notification used output copy %q, want managed report path", outputPath) + } + wantBundlePaths := []string{"2026-05-29/hourly/hourly.md"} + if strings.Join(req.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") { + t.Fatalf("notification BundlePaths = %#v, want %#v", req.BundlePaths, wantBundlePaths) + } + if req.PipelineID != "weatherreporter.hourly" { + t.Fatalf("notification PipelineID = %q, want weatherreporter.hourly", req.PipelineID) + } + if req.BundleID != "weatherreporter.home.hourly" { + t.Fatalf("notification BundleID = %q, want weatherreporter.home.hourly", req.BundleID) + } + if req.IdempotencyKey != req.BundleID+"."+result.Metadata.RunID { + t.Fatalf("notification IdempotencyKey = %q, want per-run key", req.IdempotencyKey) + } + if result.Notification == nil || result.Notification.RunID != "distributor-run" { + t.Fatalf("Notification = %#v, want distributor result", result.Notification) + } + if result.Metadata.NotificationPath != result.NotificationPath { + t.Fatalf("metadata NotificationPath = %q, want %q", result.Metadata.NotificationPath, result.NotificationPath) + } + notificationData, err := os.ReadFile(result.NotificationPath) + if err != nil { + t.Fatalf("read notification artifact: %v", err) + } + var notificationArtifact state.DistributorNotificationArtifact + if err := json.Unmarshal(notificationData, ¬ificationArtifact); err != nil { + t.Fatalf("decode notification artifact: %v", err) + } + if notificationArtifact.SourcePath != result.ReportPath || strings.Join(notificationArtifact.BundlePaths, "\n") != strings.Join(wantBundlePaths, "\n") || notificationArtifact.RunStatus == nil { + t.Fatalf("notification artifact = %#v, want managed source and run status", notificationArtifact) + } +} + +func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) { + cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) + notifier.err = errors.New("upload rejected") + renderer := &recordingRenderer{ + renderResult: &scriptorium.RenderResult{ExitCode: 0}, + structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0}, + structuredRunBody: validHourlyGeneratedTextJSON(), + } + + _, err := GenerateReport(context.Background(), ReportRequest{ + Config: cfg, + Resolved: resolved, + OutputPath: outputPath, + Renderer: renderer, + Store: store, + Notifier: notifier, + }) + if err == nil { + t.Fatal("GenerateReport() error = nil, want notification error") + } + var notificationErr *NotificationError + if !errors.As(err, ¬ificationErr) { + t.Fatalf("GenerateReport() error = %T %v, want NotificationError", err, err) + } + if !strings.Contains(err.Error(), `notify report "hourly"`) || !strings.Contains(err.Error(), "upload rejected") { + t.Fatalf("error = %q, want hourly notification context", err.Error()) + } + if len(notifier.requests) != 1 { + t.Fatalf("notification requests = %d, want 1", len(notifier.requests)) + } + paths := hourlyArtifactPaths(t, store, resolved) + assertPathsExist(t, paths.RenderedReport, outputPath, paths.Metadata, paths.Notification) + if notifier.requests[0].ReportPath != paths.RenderedReport { + t.Fatalf("notification ReportPath = %q, want managed report path %q", notifier.requests[0].ReportPath, paths.RenderedReport) + } + notificationData, readErr := os.ReadFile(paths.Notification) + if readErr != nil { + t.Fatalf("read notification artifact after failure: %v", readErr) + } + var notification state.DistributorNotificationArtifact + if err := json.Unmarshal(notificationData, ¬ification); err != nil { + t.Fatalf("decode notification artifact: %v", err) + } + if notification.Status != "failed" || !strings.Contains(notification.Error, "upload rejected") || notification.SourcePath != paths.RenderedReport { + t.Fatalf("notification failure artifact = %+v, want failed managed-source context", notification) + } +} + func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) { cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) renderer := &recordingRenderer{ @@ -1876,6 +2007,18 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) { } } +func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) { + definition, err := report.DefaultRegistry().Lookup(report.Hourly) + if err != nil { + t.Fatalf("Lookup(hourly) error = %v", err) + } + outputDir := filepath.Join(t.TempDir(), "reports") + want := filepath.Join(outputDir, "hourly.md") + if got := batchOutputPath(outputDir, definition); got != want { + t.Fatalf("batchOutputPath() = %q, want %q", got, want) + } +} + func mustParse(value string) time.Time { parsed, err := time.Parse(time.RFC3339, value) if err != nil {