Centralize final report finalization

This commit is contained in:
2026-06-15 12:28:54 +00:00
parent a02af0bce0
commit e8f1aa5caf
3 changed files with 223 additions and 72 deletions

View File

@@ -94,12 +94,6 @@ Single-report generation shares this setup:
For `scriptorium_markdown` reports, generation then: For `scriptorium_markdown` reports, generation then:
12. Runs Scriptorium report generation to the managed report path. 12. Runs Scriptorium report generation to the managed report path.
13. Copies the managed report to the requested `--out` path when provided.
14. Saves metadata with the managed report path.
15. If distributor notification is enabled, notifies using the managed report
path as the source file.
16. Saves a distributor notification debug artifact and updates metadata with
its path.
For `generated_text_template` reports, generation then: For `generated_text_template` reports, generation then:
@@ -110,13 +104,17 @@ For `generated_text_template` reports, generation then:
15. Validates and saves normalized generated text. 15. Validates and saves normalized generated text.
16. Builds and saves a typed render context. 16. Builds and saves a typed render context.
17. Renders Markdown from the embedded template to the managed report path. 17. Renders Markdown from the embedded template to the managed report path.
18. Saves final metadata with generated-text paths, render context path, schema
ID, and managed report path. After either mode has produced a managed Markdown report, shared finalization:
19. Copies the managed report to the requested `--out` path when provided.
20. If distributor notification is enabled, notifies using the managed report 1. Copies the managed report to the requested `--out` or `--out-dir` path when
path as the source file. provided.
21. Saves a distributor notification debug artifact and updates metadata with 2. Saves final metadata with the managed report path and any generated-text
its path. artifact paths already produced.
3. If distributor notification is enabled, notifies using the managed report
path as the source file.
4. 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

@@ -584,32 +584,16 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
DataPackagePath: dataPackagePath, DataPackagePath: dataPackagePath,
OutputPath: reportPath, OutputPath: reportPath,
}) })
if runErr == nil && req.OutputPath != "" && req.OutputPath != reportPath { finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
if err := fileutil.CopyFileAtomic(reportPath, req.OutputPath); err != nil { Config: req.Config,
return nil, err Store: store,
} Resolved: req.Resolved,
} Metadata: metadata,
outputPath := reportPath ManagedReportPath: reportPath,
if req.OutputPath != "" { OutputPath: req.OutputPath,
outputPath = req.OutputPath Notifier: req.Notifier,
} GenerationErr: runErr,
metadata.RenderedReportPath = reportPath })
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
if runErr != nil {
return nil, runErr
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, reportPath, metadata, req.Notifier, store)
if notificationPath != "" {
metadata.NotificationPath = notificationPath
metadataPath, metadataErr = store.SaveMetadata(ctx, metadata)
if metadataErr != nil {
return nil, metadataErr
}
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -621,15 +605,15 @@ func GenerateReport(ctx context.Context, req ReportRequest) (*ReportResult, erro
DataPackagePath: dataPackagePath, DataPackagePath: dataPackagePath,
PreflightPath: preflightPath, PreflightPath: preflightPath,
ReportPath: reportPath, ReportPath: reportPath,
OutputPath: outputPath, OutputPath: finalized.OutputPath,
NotificationPath: notificationPath, NotificationPath: finalized.NotificationPath,
Metadata: metadata, Metadata: finalized.Metadata,
MetadataPath: metadataPath, MetadataPath: finalized.MetadataPath,
PriorSnapshot: priorSnapshot, PriorSnapshot: priorSnapshot,
RecentChanges: recentChanges, RecentChanges: recentChanges,
RenderResult: renderResult, RenderResult: renderResult,
RunResult: runResult, RunResult: runResult,
Notification: notification, Notification: finalized.Notification,
}, nil }, nil
} }
@@ -723,32 +707,18 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil { if err := fileutil.WriteFileAtomic(reportPath, rendered); err != nil {
return nil, err return nil, err
} }
req.metadata.RenderedReportPath = reportPath finalized, err := finalizeRenderedReport(ctx, finalizeRenderedReportRequest{
metadataPath, err := req.store.SaveMetadata(ctx, req.metadata) Config: req.Config,
Store: req.store,
Resolved: req.Resolved,
Metadata: req.metadata,
ManagedReportPath: reportPath,
OutputPath: req.OutputPath,
Notifier: req.Notifier,
})
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,
@@ -757,10 +727,10 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
DataPackagePath: req.dataPackagePath, DataPackagePath: req.dataPackagePath,
PreflightPath: req.preflightPath, PreflightPath: req.preflightPath,
ReportPath: reportPath, ReportPath: reportPath,
OutputPath: outputPath, OutputPath: finalized.OutputPath,
NotificationPath: notificationPath, NotificationPath: finalized.NotificationPath,
Metadata: req.metadata, Metadata: finalized.Metadata,
MetadataPath: metadataPath, MetadataPath: finalized.MetadataPath,
PriorSnapshot: req.priorSnapshot, PriorSnapshot: req.priorSnapshot,
RecentChanges: req.recentChanges, RecentChanges: req.recentChanges,
RenderResult: req.renderResult, RenderResult: req.renderResult,
@@ -769,10 +739,80 @@ func generateTextTemplateReport(ctx context.Context, req generatedReportRequest)
GeneratedTextResultPath: generatedTextResultPath, GeneratedTextResultPath: generatedTextResultPath,
GeneratedTextPath: generatedTextPath, GeneratedTextPath: generatedTextPath,
RenderContextPath: renderContextPath, RenderContextPath: renderContextPath,
Notification: notification, Notification: finalized.Notification,
}, nil }, nil
} }
type finalizeRenderedReportRequest struct {
Config config.Config
Store state.Store
Resolved report.Resolved
Metadata state.Metadata
ManagedReportPath string
OutputPath string
Notifier Notifier
GenerationErr error
}
type finalizeRenderedReportResult struct {
OutputPath string
NotificationPath string
Metadata state.Metadata
MetadataPath string
Notification *NotificationResult
}
func finalizeRenderedReport(ctx context.Context, req finalizeRenderedReportRequest) (finalizeRenderedReportResult, error) {
if req.Store == nil {
return finalizeRenderedReportResult{}, fmt.Errorf("state store is required")
}
if req.ManagedReportPath == "" {
return finalizeRenderedReportResult{}, fmt.Errorf("managed report path is required for report %q", req.Resolved.Definition.ID)
}
metadata := req.Metadata
metadata.RenderedReportPath = req.ManagedReportPath
outputPath := req.ManagedReportPath
if req.OutputPath != "" {
outputPath = req.OutputPath
if req.GenerationErr == nil && req.OutputPath != req.ManagedReportPath {
if err := fileutil.CopyFileAtomic(req.ManagedReportPath, req.OutputPath); err != nil {
return finalizeRenderedReportResult{}, err
}
}
}
metadataPath, err := req.Store.SaveMetadata(ctx, metadata)
if err != nil {
return finalizeRenderedReportResult{}, err
}
result := finalizeRenderedReportResult{
OutputPath: outputPath,
Metadata: metadata,
MetadataPath: metadataPath,
}
if req.GenerationErr != nil {
return result, req.GenerationErr
}
notification, notificationPath, err := notifyReport(ctx, req.Config, req.Resolved, req.ManagedReportPath, metadata, req.Notifier, req.Store)
if notificationPath != "" {
metadata.NotificationPath = notificationPath
metadataPath, saveErr := req.Store.SaveMetadata(ctx, metadata)
if saveErr != nil {
return finalizeRenderedReportResult{}, saveErr
}
result.Metadata = metadata
result.MetadataPath = metadataPath
result.NotificationPath = notificationPath
}
result.Notification = notification
if err != nil {
return result, err
}
return result, nil
}
func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) { func notifyReport(ctx context.Context, cfg config.Config, resolved report.Resolved, reportPath string, metadata state.Metadata, notifier Notifier, store state.Store) (*NotificationResult, string, error) {
notifier, enabled := reportNotifier(cfg, notifier) notifier, enabled := reportNotifier(cfg, notifier)
if !enabled { if !enabled {

View File

@@ -645,6 +645,73 @@ func TestGenerateHourlyReportNotificationFailureFailsReport(t *testing.T) {
} }
} }
func TestGenerateReportSavesFinalMetadataForMarkdownAndGeneratedTextReports(t *testing.T) {
t.Run("Markdown", func(t *testing.T) {
server := dailyBundleServer(t)
cfg := dailyTestConfig(t, server)
cfg.Workspace.Root = t.TempDir()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg,
Report: ReportDaily,
Date: mustParse("2026-05-29T12:00:00-05:00"),
}, mustParse("2026-05-29T05:00:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: successfulRenderer("# Daily Report\n"),
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
saved := readMetadataForTest(t, result.MetadataPath)
if saved.RenderedReportPath != result.ReportPath || saved.NotificationPath != "" {
t.Fatalf("saved metadata = %#v, want final rendered path without notification", saved)
}
if saved.GeneratedTextSchemaID != "" || saved.GeneratedTextPath != "" || saved.RenderContextPath != "" {
t.Fatalf("saved markdown metadata has generated-text fields: %#v", saved)
}
})
t.Run("GeneratedTextTemplate", func(t *testing.T) {
server := hourlyBundleServer(t)
cfg := hourlyGeneratedTextConfig(t, server)
cfg.Notify.Distributor.Enabled = false
resolved, store, _, _ := resolveHourlyGeneratedTextFixture(t, cfg)
renderer := &recordingRenderer{
renderResult: &scriptorium.RenderResult{ExitCode: 0},
structuredRunResult: &scriptorium.StructuredRunResult{ExitCode: 0},
structuredRunBody: validHourlyGeneratedTextJSON(),
}
result, err := GenerateReport(context.Background(), ReportRequest{
Config: cfg,
Resolved: resolved,
Renderer: renderer,
Store: store,
})
if err != nil {
t.Fatalf("GenerateReport() error = %v", err)
}
saved := readMetadataForTest(t, result.MetadataPath)
if saved.RenderedReportPath != result.ReportPath || saved.NotificationPath != "" {
t.Fatalf("saved metadata = %#v, want final rendered path without notification", saved)
}
if saved.GeneratedTextSchemaID != "hourly" ||
saved.GeneratedTextRawPath != result.GeneratedTextRawPath ||
saved.GeneratedTextResultPath != result.GeneratedTextResultPath ||
saved.GeneratedTextPath != result.GeneratedTextPath ||
saved.RenderContextPath != result.RenderContextPath {
t.Fatalf("saved generated-text metadata = %#v, want generated-text artifact links", saved)
}
})
}
func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing.T) { func TestGenerateTomorrowReportNotificationUsesTomorrowTemplateValues(t *testing.T) {
server := dailyBundleServer(t) server := dailyBundleServer(t)
cfg := config.Defaults() cfg := config.Defaults()
@@ -2065,6 +2132,28 @@ func TestRunBatchContinuesAfterNotificationFailure(t *testing.T) {
if !failedThreeDay { if !failedThreeDay {
t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports) t.Fatalf("reports = %#v, want notification failure on 3-day item", result.Reports)
} }
cfg.Workspace.Root = t.TempDir()
err = RunBatch(context.Background(), BatchRequest{
Config: cfg,
Batch: BatchMorning,
Now: mustParse("2026-05-29T05:00:00-05:00"),
Renderer: &selectiveRenderer{
runBody: "# Batch Report\n",
},
Notifier: &recordingNotifier{
errByReport: map[report.ID]error{
report.ThreeDay: errors.New("distributor unavailable"),
},
},
})
var batchErr BatchError
if !errors.As(err, &batchErr) {
t.Fatalf("RunBatch() error = %T %v, want BatchError", err, err)
}
if batchErr.Result == nil || batchErr.Result.Failed != 1 || batchErr.Result.Succeeded != 2 {
t.Fatalf("RunBatch() result = %#v, want notification failure aggregate", batchErr.Result)
}
} }
func TestRunBatchUsesOutputDirectory(t *testing.T) { func TestRunBatchUsesOutputDirectory(t *testing.T) {
@@ -2095,6 +2184,17 @@ func TestRunBatchUsesOutputDirectory(t *testing.T) {
if _, err := os.Stat(want); err != nil { if _, err := os.Stat(want); err != nil {
t.Fatalf("expected output copy %q: %v", want, err) t.Fatalf("expected output copy %q: %v", want, err)
} }
reportData, err := os.ReadFile(result.Reports[0].ReportPath)
if err != nil {
t.Fatalf("read managed report: %v", err)
}
copyData, err := os.ReadFile(want)
if err != nil {
t.Fatalf("read output copy: %v", err)
}
if string(copyData) != string(reportData) {
t.Fatalf("batch output copy differs from managed report")
}
} }
func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) { func TestBatchOutputPathUsesHourlyOutputName(t *testing.T) {
@@ -2286,6 +2386,19 @@ func hourlyArtifactPaths(t *testing.T, store state.Store, resolved report.Resolv
return paths return paths
} }
func readMetadataForTest(t *testing.T, path string) state.Metadata {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read metadata %q: %v", path, err)
}
var metadata state.Metadata
if err := json.Unmarshal(data, &metadata); err != nil {
t.Fatalf("decode metadata %q: %v", path, err)
}
return metadata
}
func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) { func assertGeneratedReportError(t *testing.T, err error, resolved report.Resolved, operation string) {
t.Helper() t.Helper()
if err == nil { if err == nil {