Notify hourly generated reports after success

This commit is contained in:
2026-06-14 05:13:40 +00:00
parent 9e14a9b7c7
commit f6e20d1412
5 changed files with 188 additions and 15 deletions

View File

@@ -35,14 +35,14 @@ weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID weatherreporter inspect sources [--config PATH] RUN_ID
``` ```
Implemented Markdown `generate` commands write a JSON module snapshot, YAML Implemented `generate` commands write a JSON module snapshot, YAML data package,
data package, preflight artifact, managed Markdown report, and metadata under preflight artifact, managed Markdown report, and metadata under the configured
the configured workspace. `--out` writes an extra Markdown copy for the workspace. `--out` writes an extra Markdown copy for the operator; distributor
operator on those Markdown-path commands; distributor notification uses the notification uses the managed report path, not the extra copy. `generate
managed report path, not the extra copy. `generate hourly` covers the next six hourly` covers the next six hours in the effective report timezone, does not
hours in the effective report timezone, does not accept date or event window accept date or event window flags, writes managed generated-text artifacts,
flags, writes managed generated-text artifacts, validates the structured text, validates the structured text, and renders the managed Markdown report from the
and renders the managed Markdown report from the embedded hourly template. embedded hourly template.
`generate storm` requires explicit event-window bounds with `--start` and `generate storm` requires explicit event-window bounds with `--start` and
`--end`. `--end`.

View File

@@ -97,6 +97,11 @@ For `generated_text_template` reports, generation then:
16. Renders Markdown from the embedded template to the managed report path. 16. Renders Markdown from the embedded template to the managed report path.
17. Saves final metadata with generated-text paths, render context path, schema 17. Saves final metadata with generated-text paths, render context path, schema
ID, and managed report path. 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 If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report metadata are persisted before the error is returned. If Scriptorium report

View File

@@ -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 batches. It builds the same module snapshot and data package, runs
`scriptorium render` as preflight, runs structured `scriptorium run` to raw `scriptorium render` as preflight, runs structured `scriptorium run` to raw
GeneratedText JSON, validates the structured text, saves a render context, and 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: 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 the valid-period start date, artifact group, and RunID. Distributor owns
destination merge, retention, and derived snapshot behavior such as `latest`. destination merge, retention, and derived snapshot behavior such as `latest`.
Notification happens after final metadata save for Markdown-path generation. Notification happens after final metadata save for generated reports. Weather
Weather API, module snapshot, data-package, render preflight, Scriptorium run, API, module snapshot, data-package, render preflight, Scriptorium run,
and metadata-save failures do not trigger notification. Hourly generated-text generated-text validation, template rendering, and metadata-save failures do
reports write the managed Markdown report and generated-text artifacts but do not trigger notification. A notification failure fails that report.
not notify distributor yet. A notification failure fails that report.
In a batch, other reports continue, the failed report includes notification In a batch, other reports continue, the failed report includes notification
fields in the JSON summary, and the batch returns nonzero. fields in the JSON summary, and the batch returns nonzero.

View File

@@ -720,6 +720,27 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
if err != nil { if err != nil {
return nil, err 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{ return &ReportResult{
ModuleSnapshot: req.moduleSnapshot, ModuleSnapshot: req.moduleSnapshot,
@@ -728,7 +749,8 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
DataPackagePath: req.dataPackagePath, DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath, PreflightPath: req.preflightPath,
ReportPath: reportPath, ReportPath: reportPath,
OutputPath: reportPath, OutputPath: outputPath,
NotificationPath: notificationPath,
Metadata: req.metadata, Metadata: req.metadata,
MetadataPath: metadataPath, MetadataPath: metadataPath,
RecentChanges: req.recentChanges, RecentChanges: req.recentChanges,
@@ -738,6 +760,7 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
GeneratedTextResultPath: generatedTextResultPath, GeneratedTextResultPath: generatedTextResultPath,
GeneratedTextPath: generatedTextPath, GeneratedTextPath: generatedTextPath,
RenderContextPath: renderContextPath, RenderContextPath: renderContextPath,
Notification: notification,
}, nil }, nil
} }

View File

@@ -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, &notificationArtifact); 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, &notificationErr) {
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, &notification); 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) { func TestGenerateHourlyReportPersistsPreflightFailure(t *testing.T) {
cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t) cfg, resolved, store, notifier, outputPath := hourlyGeneratedTextFixture(t)
renderer := &recordingRenderer{ 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 { func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value) parsed, err := time.Parse(time.RFC3339, value)
if err != nil { if err != nil {